From 6c9d9206c88be14edb268420809adf2604bb2fb7 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 14:06:51 +0530 Subject: [PATCH 01/35] feat: detect mic-in-use meetings and prompt for note-taking --- apps/x/apps/main/package.json | 6 +- apps/x/apps/main/src/deeplink.ts | 44 +- apps/x/apps/main/src/main.ts | 23 +- .../src/meeting-detect/browser-match.test.ts | 44 ++ .../main/src/meeting-detect/browser-match.ts | 63 +++ .../meeting-detect/calendar-correlate.test.ts | 111 ++++ .../src/meeting-detect/calendar-correlate.ts | 123 +++++ .../main/src/meeting-detect/detector.test.ts | 134 +++++ .../apps/main/src/meeting-detect/detector.ts | 107 ++++ .../src/meeting-detect/foreground-window.ts | 97 ++++ apps/x/apps/main/src/meeting-detect/index.ts | 26 + .../main/src/meeting-detect/meeting-apps.ts | 49 ++ .../main/src/meeting-detect/probe-macos.ts | 47 ++ .../main/src/meeting-detect/probe-windows.ts | 85 +++ .../main/src/meeting-detect/service.test.ts | 166 ++++++ .../x/apps/main/src/meeting-detect/service.ts | 153 ++++++ .../src/meeting-detect/suppression.test.ts | 76 +++ .../main/src/meeting-detect/suppression.ts | 151 +++++ apps/x/apps/main/src/meeting-detect/types.ts | 12 + apps/x/apps/main/tsconfig.json | 3 + apps/x/apps/main/vitest.config.ts | 8 + apps/x/pnpm-lock.yaml | 520 ++++++++++++++++++ 22 files changed, 2031 insertions(+), 17 deletions(-) create mode 100644 apps/x/apps/main/src/meeting-detect/browser-match.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/browser-match.ts create mode 100644 apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/calendar-correlate.ts create mode 100644 apps/x/apps/main/src/meeting-detect/detector.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/detector.ts create mode 100644 apps/x/apps/main/src/meeting-detect/foreground-window.ts create mode 100644 apps/x/apps/main/src/meeting-detect/index.ts create mode 100644 apps/x/apps/main/src/meeting-detect/meeting-apps.ts create mode 100644 apps/x/apps/main/src/meeting-detect/probe-macos.ts create mode 100644 apps/x/apps/main/src/meeting-detect/probe-windows.ts create mode 100644 apps/x/apps/main/src/meeting-detect/service.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/service.ts create mode 100644 apps/x/apps/main/src/meeting-detect/suppression.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/suppression.ts create mode 100644 apps/x/apps/main/src/meeting-detect/types.ts create mode 100644 apps/x/apps/main/vitest.config.ts diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 74cb1598..de3f462e 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -10,7 +10,8 @@ "start": "electron .", "build": "rm -rf dist && tsc && node bundle.mjs", "package": "electron-forge package", - "make": "electron-forge make" + "make": "electron-forge make", + "test": "vitest run" }, "dependencies": { "@x/core": "workspace:*", @@ -37,6 +38,7 @@ "@types/electron-squirrel-startup": "^1.0.2", "@types/node": "^25.0.3", "electron": "^39.2.7", - "esbuild": "^0.24.2" + "esbuild": "^0.24.2", + "vitest": "^2.1.9" } } \ No newline at end of file diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts index aaaaa3bc..9e058fd9 100644 --- a/apps/x/apps/main/src/deeplink.ts +++ b/apps/x/apps/main/src/deeplink.ts @@ -63,7 +63,11 @@ export function dispatchDeepLink(url: string): void { interface MeetingNotesAction { type: "take-meeting-notes" | "join-and-take-meeting-notes"; - eventId: string; + // eventId is required for join-and-take-meeting-notes (calendar-time fire) + // but optional for take-meeting-notes — mic-detection ad-hoc fires use a + // title-only payload when the call isn't on the calendar. + eventId?: string; + title?: string; } type ParsedAction = MeetingNotesAction; @@ -76,10 +80,16 @@ function parseAction(url: string): ParsedAction | null { if (host !== ACTION_HOST) return null; const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); const type = params.get("type"); - if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") { - const eventId = params.get("eventId"); + const eventId = params.get("eventId") || undefined; + const title = params.get("title") || undefined; + if (type === "join-and-take-meeting-notes") { return eventId ? { type, eventId } : null; } + if (type === "take-meeting-notes") { + // Need at least one identifier — eventId (calendar) or title (ad-hoc). + if (!eventId && !title) return null; + return { type, eventId, title }; + } return null; } @@ -88,25 +98,31 @@ async function dispatchAction(url: string): Promise { if (!parsed) return; const openMeeting = parsed.type === "join-and-take-meeting-notes"; - await handleTakeMeetingNotes(parsed.eventId, openMeeting); + await handleTakeMeetingNotes(parsed.eventId, parsed.title, openMeeting); } -async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise { +async function handleTakeMeetingNotes( + eventId: string | undefined, + title: string | undefined, + openMeeting: boolean, +): Promise { const win = mainWindowRef; if (!win || win.isDestroyed()) return; focusWindow(win); - const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`); - let event: unknown; - try { - const raw = await fs.readFile(filePath, "utf-8"); - event = JSON.parse(raw); - } catch (err) { - console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err); - return; + let event: unknown = null; + if (eventId) { + const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`); + try { + const raw = await fs.readFile(filePath, "utf-8"); + event = JSON.parse(raw); + } catch (err) { + console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err); + // Fall through with event=null so the renderer can still open an ad-hoc note. + } } - const payload = { event, openMeeting }; + const payload = { event, openMeeting, title: title ?? null }; if (win.webContents.isLoading()) { win.webContents.once("did-finish-load", () => { diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index ab026fff..a5e9eb5f 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -45,6 +45,11 @@ import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; import { setupBrowserEventForwarding } from "./browser/ipc.js"; import { ElectronBrowserControlService } from "./browser/control-service.js"; import { ElectronNotificationService } from "./notification/electron-notification-service.js"; +import { + createPlatformDetector, + MeetingDetectService, + Suppression, +} from "./meeting-detect/index.js"; import { DEEP_LINK_SCHEME, dispatchUrl, @@ -312,7 +317,8 @@ app.whenReady().then(async () => { }); registerBrowserControlService(new ElectronBrowserControlService()); - registerNotificationService(new ElectronNotificationService()); + const notificationService = new ElectronNotificationService(); + registerNotificationService(notificationService); setupIpcHandlers(); setupBrowserEventForwarding(); @@ -384,6 +390,21 @@ app.whenReady().then(async () => { // start calendar meeting notification service (fires 1-minute warnings) initCalendarNotifications(); + // start meeting-detect service (mic-in-use detection -> popup asking if user wants notes) + const meetingDetector = createPlatformDetector(); + if (meetingDetector) { + const meetingDetectService = new MeetingDetectService({ + detector: meetingDetector, + notifier: notificationService, + suppression: new Suppression(), + }); + meetingDetectService.start().catch((err) => { + console.error("[MeetingDetect] failed to start:", err); + }); + } else { + console.log("[MeetingDetect] no detector for this platform; skipping"); + } + // start chrome extension sync server initChromeSync(); diff --git a/apps/x/apps/main/src/meeting-detect/browser-match.test.ts b/apps/x/apps/main/src/meeting-detect/browser-match.test.ts new file mode 100644 index 00000000..6fbc80bf --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/browser-match.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { matchTitleOrUrl } from "./browser-match.js"; + +describe("matchTitleOrUrl", () => { + it("matches Google Meet by URL", () => { + const m = matchTitleOrUrl("Meet — Standup", "https://meet.google.com/abc-defg-hij"); + expect(m?.platform).toBe("google-meet"); + }); + + it("matches Google Meet by window title alone (Windows/Mac no-URL case)", () => { + const m = matchTitleOrUrl("Meet - Daily Standup - Google Chrome", undefined); + expect(m?.platform).toBe("google-meet"); + }); + + it("matches Meet with em-dash variant (locale-dependent title)", () => { + const m = matchTitleOrUrl("Meet — Daily Standup", undefined); + expect(m?.platform).toBe("google-meet"); + }); + + it("matches Zoom web client", () => { + const m = matchTitleOrUrl("Zoom Meeting", "https://us02web.zoom.us/j/123456789"); + expect(m?.platform).toBe("zoom-web"); + }); + + it("matches Teams web", () => { + const m = matchTitleOrUrl("Meeting | Microsoft Teams", "https://teams.microsoft.com/_#/calendarv2"); + expect(m?.platform).toBe("teams-web"); + }); + + it("ignores random YouTube tab", () => { + const m = matchTitleOrUrl("Mock Interview - YouTube", "https://www.youtube.com/watch?v=abc"); + expect(m).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(matchTitleOrUrl(undefined, undefined)).toBeNull(); + expect(matchTitleOrUrl("", "")).toBeNull(); + }); + + it("is case-insensitive", () => { + const m = matchTitleOrUrl("ZOOM MEETING", "https://ZOOM.US/J/999"); + expect(m?.platform).toBe("zoom-web"); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/browser-match.ts b/apps/x/apps/main/src/meeting-detect/browser-match.ts new file mode 100644 index 00000000..efbf829f --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/browser-match.ts @@ -0,0 +1,63 @@ +import { getForegroundWindow } from "./foreground-window.js"; + +export type BrowserMeetingPlatform = "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web"; + +export interface BrowserMeetingMatch { + platform: BrowserMeetingPlatform; + // Best-effort URL or tab title we matched on — useful for the popup copy. + hint: string; +} + +interface TitleRule { + platform: BrowserMeetingPlatform; + // Substrings checked against the (case-insensitive) window title / URL. + needles: string[]; +} + +// Substrings we look for in the foreground window title (or URL when we +// have it). On Chrome/Edge/Firefox the page title is embedded in the window +// title, which is the most reliable cross-platform signal. +// Meet page title: "Meet - Daily Standup" → matches "meet -" +// Zoom web client: "Zoom Meeting" → matches "zoom meeting" +// Teams web: " | Microsoft Teams" → matches "microsoft teams" +const RULES: TitleRule[] = [ + { platform: "google-meet", needles: ["meet.google.com", "google meet", "meet -", "meet —", "meet |"] }, + { platform: "zoom-web", needles: ["zoom.us/j/", "zoom.us/wc/", "zoom meeting"] }, + { platform: "teams-web", needles: ["teams.microsoft.com", "microsoft teams"] }, + { platform: "slack-huddle", needles: ["app.slack.com", "slack huddle"] }, + { platform: "webex-web", needles: ["webex.com/meet", "webex.com/wbxmjs", "webex meeting"] }, +]; + +/** + * Look at the foreground window. If it's a browser and the title matches a + * known meeting URL/platform, return a match. Returns null otherwise. + * + * Caller is expected to only invoke this when the detector classified the + * mic-holder as `kind: "browser"`. That keeps active-win calls cheap — we + * only ask the OS when there's a reason to ask. + */ +export async function matchBrowserMeeting(): Promise { + const win = await getForegroundWindow(); + if (!win) return null; + // We only have a title (no URL from these OS calls), but Chrome / Edge / + // Firefox include the tab title in the window title, which contains the + // meeting service name for Meet/Zoom-web/Teams-web pages. + return matchTitleOrUrl(win.title, undefined); +} + +/** Pure matcher — exposed for tests; no OS calls. */ +export function matchTitleOrUrl(title: string | undefined, url: string | undefined): BrowserMeetingMatch | null { + // active-win returns `url` on macOS for Chromium-family + Safari (Accessibility-perm gated). + // On Windows, only `title` is reliable. Match against both. + const haystack = `${url ?? ""}\n${title ?? ""}`.toLowerCase(); + if (!haystack.trim()) return null; + + for (const rule of RULES) { + for (const needle of rule.needles) { + if (haystack.includes(needle)) { + return { platform: rule.platform, hint: url || title || "" }; + } + } + } + return null; +} diff --git a/apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts b/apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts new file mode 100644 index 00000000..5772ea56 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/calendar-correlate.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import path from "node:path"; +import fs from "node:fs/promises"; +import os from "node:os"; +import { correlateFromDir } from "./calendar-correlate.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rb-meeting-detect-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +async function writeEvent(name: string, body: unknown): Promise { + await fs.writeFile(path.join(tmpDir, `${name}.json`), JSON.stringify(body), "utf-8"); +} + +function evt(opts: { + id: string; + summary: string; + startMinutes: number; // minutes from `anchor` + endMinutes: number; + cancelled?: boolean; + declined?: boolean; + hangoutLink?: string; +}): unknown { + const anchor = new Date("2026-05-15T10:00:00Z").getTime(); + return { + id: opts.id, + summary: opts.summary, + status: opts.cancelled ? "cancelled" : "confirmed", + start: { dateTime: new Date(anchor + opts.startMinutes * 60_000).toISOString() }, + end: { dateTime: new Date(anchor + opts.endMinutes * 60_000).toISOString() }, + attendees: [ + { self: true, responseStatus: opts.declined ? "declined" : "accepted" }, + { email: "alice@example.com", displayName: "Alice" }, + ], + hangoutLink: opts.hangoutLink, + }; +} + +describe("correlateFromDir", () => { + const NOW = new Date("2026-05-15T10:30:00Z"); + + it("returns null when the directory does not exist", async () => { + const result = await correlateFromDir(path.join(tmpDir, "does-not-exist"), NOW); + expect(result).toBeNull(); + }); + + it("returns null when no events overlap", async () => { + await writeEvent("e1", evt({ id: "e1", summary: "Morning", startMinutes: -120, endMinutes: -60 })); + const result = await correlateFromDir(tmpDir, NOW); + expect(result).toBeNull(); + }); + + it("matches an event in progress", async () => { + await writeEvent("e1", evt({ + id: "e1", + summary: "Q2 Planning", + startMinutes: 25, // 10:25, NOW=10:30 → in progress + endMinutes: 55, + hangoutLink: "https://meet.google.com/abc", + })); + const result = await correlateFromDir(tmpDir, NOW); + expect(result?.eventId).toBe("e1"); + expect(result?.summary).toBe("Q2 Planning"); + expect(result?.meetingUrl).toBe("https://meet.google.com/abc"); + expect(result?.attendees).toHaveLength(1); // self filtered + expect(result?.attendees[0].email).toBe("alice@example.com"); + }); + + it("matches an event starting within pre-roll", async () => { + await writeEvent("e1", evt({ + id: "e1", + summary: "Upcoming", + startMinutes: 31, // NOW=10:30, event at 10:31 → 1 min away, within 2-min pre-roll + endMinutes: 60, + })); + const result = await correlateFromDir(tmpDir, NOW); + expect(result?.eventId).toBe("e1"); + }); + + it("ignores cancelled events", async () => { + await writeEvent("e1", evt({ id: "e1", summary: "Dead", startMinutes: 25, endMinutes: 55, cancelled: true })); + const result = await correlateFromDir(tmpDir, NOW); + expect(result).toBeNull(); + }); + + it("ignores events the user declined", async () => { + await writeEvent("e1", evt({ id: "e1", summary: "Nope", startMinutes: 25, endMinutes: 55, declined: true })); + const result = await correlateFromDir(tmpDir, NOW); + expect(result).toBeNull(); + }); + + it("picks the closest event when multiple overlap", async () => { + await writeEvent("far", evt({ id: "far", summary: "Far", startMinutes: -10, endMinutes: 35 })); + await writeEvent("near", evt({ id: "near", summary: "Near", startMinutes: 29, endMinutes: 59 })); + const result = await correlateFromDir(tmpDir, NOW); + expect(result?.eventId).toBe("near"); + }); + + it("ignores sync_state.json", async () => { + await writeEvent("sync_state", { lastSync: "whatever" }); + await writeEvent("e1", evt({ id: "e1", summary: "Real", startMinutes: 25, endMinutes: 55 })); + const result = await correlateFromDir(tmpDir, NOW); + expect(result?.eventId).toBe("e1"); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/calendar-correlate.ts b/apps/x/apps/main/src/meeting-detect/calendar-correlate.ts new file mode 100644 index 00000000..67f7b35e --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/calendar-correlate.ts @@ -0,0 +1,123 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import { WorkDir } from "@x/core/dist/config/config.js"; + +// Match a detection event against the user's synced calendar. The detector +// fires when the mic flips on; if there's a calendar event currently in +// progress (or about to start / just ended), we attach its metadata so the +// popup can show the right title and the deeplink can target the right note. + +const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync"); + +// Pre-roll: someone joining 2 min early should still match the upcoming event. +const PRE_ROLL_MS = 2 * 60 * 1000; +// Post-roll: someone joining 2 min late (or a meeting that ran long and the +// next-event window already started) should still match. +const POST_ROLL_MS = 2 * 60 * 1000; + +interface CalendarEventFile { + id?: string; + summary?: string; + status?: string; + start?: { dateTime?: string }; + end?: { dateTime?: string }; + attendees?: Array<{ email?: string; displayName?: string; self?: boolean; responseStatus?: string }>; + hangoutLink?: string; + conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }; +} + +export interface CorrelatedEvent { + eventId: string; + summary: string; + startMs: number; + endMs: number; + attendees: Array<{ email?: string; displayName?: string }>; + meetingUrl?: string; +} + +/** + * Find a calendar event whose [start - PRE_ROLL, end + POST_ROLL] window + * contains `now`. Returns the closest match (smallest |now - start|) when + * multiple events overlap (back-to-back meetings). + */ +export async function correlateNow(now: Date = new Date()): Promise { + return correlateFromDir(CALENDAR_SYNC_DIR, now); +} + +/** Exposed for tests — accepts an arbitrary directory of calendar JSON files. */ +export async function correlateFromDir(dir: string, now: Date): Promise { + let entries: string[]; + try { + entries = await fs.readdir(dir); + } catch { + return null; + } + + const nowMs = now.getTime(); + let best: { event: CorrelatedEvent; distance: number } | null = null; + + for (const name of entries) { + if (!name.endsWith(".json")) continue; + if (name === "sync_state.json" || name.startsWith("sync_state")) continue; + + let raw: string; + try { + raw = await fs.readFile(path.join(dir, name), "utf-8"); + } catch { + continue; + } + let event: CalendarEventFile; + try { + event = JSON.parse(raw); + } catch { + continue; + } + + if (event.status === "cancelled") continue; + if (isDeclinedBySelf(event)) continue; + + const startStr = event.start?.dateTime; + const endStr = event.end?.dateTime; + if (!startStr || !endStr) continue; + + const startMs = Date.parse(startStr); + const endMs = Date.parse(endStr); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue; + + // Skip events outside the active window. + if (nowMs < startMs - PRE_ROLL_MS) continue; + if (nowMs > endMs + POST_ROLL_MS) continue; + + const eventId = event.id || name.replace(/\.json$/, ""); + const correlated: CorrelatedEvent = { + eventId, + summary: event.summary?.trim() || "Untitled meeting", + startMs, + endMs, + attendees: (event.attendees || []) + .filter((a) => !a.self) + .map((a) => ({ email: a.email, displayName: a.displayName })), + meetingUrl: extractMeetingUrl(event), + }; + + const distance = Math.abs(nowMs - startMs); + if (!best || distance < best.distance) { + best = { event: correlated, distance }; + } + } + + return best?.event ?? null; +} + +function isDeclinedBySelf(event: CalendarEventFile): boolean { + if (!event.attendees) return false; + const self = event.attendees.find((a) => a.self); + return self?.responseStatus === "declined"; +} + +function extractMeetingUrl(event: CalendarEventFile): string | undefined { + if (event.hangoutLink) return event.hangoutLink; + const eps = event.conferenceData?.entryPoints || []; + const video = eps.find((e) => e.entryPointType === "video"); + return video?.uri; +} diff --git a/apps/x/apps/main/src/meeting-detect/detector.test.ts b/apps/x/apps/main/src/meeting-detect/detector.test.ts new file mode 100644 index 00000000..b161789c --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/detector.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { MeetingDetector, type MeetingActiveEvent, type MeetingClearedEvent } from "./detector.js"; +import type { MicProbe, MicUser } from "./types.js"; + +class FakeProbe implements MicProbe { + private next: MicUser[] = []; + setNext(users: MicUser[]): void { this.next = users; } + async probe(): Promise { return this.next; } +} + +function collect(detector: MeetingDetector) { + const active: MeetingActiveEvent[] = []; + const cleared: MeetingClearedEvent[] = []; + detector.on("meeting-active", (e) => active.push(e)); + detector.on("meeting-cleared", (e) => cleared.push(e)); + return { active, cleared }; +} + +describe("MeetingDetector", () => { + let probe: FakeProbe; + let detector: MeetingDetector; + + beforeEach(() => { + probe = new FakeProbe(); + // tickMs is irrelevant — we drive ticks manually. + detector = new MeetingDetector(probe, 999_999); + }); + + it("emits meeting-active once when a Zoom-like exe appears", async () => { + const { active } = collect(detector); + + probe.setNext([{ executable: "C:\\Program Files\\Zoom\\bin\\Zoom.exe" }]); + await detector.tick(); + + expect(active).toHaveLength(1); + expect(active[0].kind).toBe("zoom"); + expect(active[0].executable).toContain("Zoom.exe"); + }); + + it("does not re-emit while the same exe keeps appearing", async () => { + const { active } = collect(detector); + const user = { executable: "/Applications/zoom.us.app/Contents/MacOS/zoom.us", pid: 4711 }; + + probe.setNext([user]); + await detector.tick(); + await detector.tick(); + await detector.tick(); + + expect(active).toHaveLength(1); + }); + + it("emits meeting-cleared when the exe disappears", async () => { + const { active, cleared } = collect(detector); + const user = { executable: "zoom.us", pid: 4711 }; + + probe.setNext([user]); + await detector.tick(); + + probe.setNext([]); + await detector.tick(); + + expect(active).toHaveLength(1); + expect(cleared).toHaveLength(1); + expect(cleared[0].sessionKey).toBe(active[0].sessionKey); + }); + + it("ignores unknown executables (Voice Memos, OBS, etc.)", async () => { + const { active, cleared } = collect(detector); + + probe.setNext([{ executable: "Voice Memos", pid: 999 }]); + await detector.tick(); + + probe.setNext([]); + await detector.tick(); + + expect(active).toHaveLength(0); + expect(cleared).toHaveLength(0); + }); + + it("classifies a browser as kind=browser (for downstream tab-title check)", async () => { + const { active } = collect(detector); + + probe.setNext([{ executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", pid: 5050 }]); + await detector.tick(); + + expect(active).toHaveLength(1); + expect(active[0].kind).toBe("browser"); + }); + + it("treats a relaunched app (new pid) as a new session on macOS", async () => { + const { active, cleared } = collect(detector); + + probe.setNext([{ executable: "zoom.us", pid: 100 }]); + await detector.tick(); + + probe.setNext([]); // app closed + await detector.tick(); + + probe.setNext([{ executable: "zoom.us", pid: 200 }]); // re-opened + await detector.tick(); + + expect(active).toHaveLength(2); + expect(cleared).toHaveLength(1); + expect(active[0].sessionKey).not.toBe(active[1].sessionKey); + }); + + it("handles multiple concurrent meeting apps independently", async () => { + const { active, cleared } = collect(detector); + + probe.setNext([ + { executable: "zoom.us", pid: 100 }, + { executable: "Microsoft Teams", pid: 200 }, + ]); + await detector.tick(); + + probe.setNext([{ executable: "Microsoft Teams", pid: 200 }]); + await detector.tick(); + + expect(active).toHaveLength(2); + expect(active.map((e) => e.kind).sort()).toEqual(["teams", "zoom"]); + expect(cleared).toHaveLength(1); + expect(cleared[0].sessionKey).toContain("zoom.us"); + }); + + it("recovers without crashing when the probe throws", async () => { + const flaky: MicProbe = { probe: vi.fn().mockRejectedValueOnce(new Error("boom")) }; + const d = new MeetingDetector(flaky, 999_999); + // tick() awaits probe.probe() so a rejection bubbles — start() catches it. Verify start() doesn't throw. + d.start(); + await new Promise((r) => setTimeout(r, 10)); + d.stop(); + expect(flaky.probe).toHaveBeenCalled(); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/detector.ts b/apps/x/apps/main/src/meeting-detect/detector.ts new file mode 100644 index 00000000..58e95613 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/detector.ts @@ -0,0 +1,107 @@ +import { EventEmitter } from "node:events"; +import { classifyExecutable, type MeetingAppKind } from "./meeting-apps.js"; +import type { MicProbe, MicUser } from "./types.js"; + +const DEFAULT_TICK_MS = 3_000; + +export interface MeetingActiveEvent { + executable: string; + pid?: number; + kind: MeetingAppKind; + // Stable key for dedup — exe path (plus pid on mac so a Zoom relaunch counts as a new session). + sessionKey: string; + startedAt: Date; +} + +export interface MeetingClearedEvent { + sessionKey: string; + endedAt: Date; +} + +/** + * Polls a platform-specific MicProbe and emits when a whitelisted meeting app + * starts / stops holding the mic. One emit per distinct session — a session + * lasts as long as the same exe (+pid on macOS) keeps appearing in probe + * results across ticks. + * + * Pure logic; UI/notification wiring lives in the service layer. Probe is + * injected so this is testable without a real OS. + */ +export class MeetingDetector extends EventEmitter { + private readonly probe: MicProbe; + private readonly tickMs: number; + private active = new Map(); + private timer: NodeJS.Timeout | null = null; + private running = false; + + constructor(probe: MicProbe, tickMs: number = DEFAULT_TICK_MS) { + super(); + this.probe = probe; + this.tickMs = tickMs; + } + + start(): void { + if (this.timer) return; + const loop = async () => { + if (!this.running) return; + try { + await this.tick(); + } catch (err) { + console.error("[MeetingDetect] tick failed:", err); + } + if (this.running) this.timer = setTimeout(loop, this.tickMs); + }; + this.running = true; + // Run first tick immediately; subsequent ticks scheduled by the loop. + this.timer = setTimeout(loop, 0); + } + + stop(): void { + this.running = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + /** Exposed for tests — drive a single probe-and-diff cycle. */ + async tick(): Promise { + const users = await this.probe.probe(); + const seenKeys = new Set(); + const now = new Date(); + + for (const user of users) { + const kind = classifyExecutable(user.executable); + if (kind === "unknown") continue; + + const key = sessionKey(user); + seenKeys.add(key); + + if (!this.active.has(key)) { + const event: MeetingActiveEvent = { + executable: user.executable, + pid: user.pid, + kind, + sessionKey: key, + startedAt: now, + }; + this.active.set(key, event); + this.emit("meeting-active", event); + } + } + + for (const [key, event] of this.active) { + if (seenKeys.has(key)) continue; + this.active.delete(key); + const cleared: MeetingClearedEvent = { sessionKey: key, endedAt: now }; + this.emit("meeting-cleared", cleared); + } + } +} + +function sessionKey(user: MicUser): string { + // On macOS we include pid so an app relaunch counts as a new session. + // On Windows there's no pid; the exe path alone is sufficient because + // Windows can't tell us *which instance* of an exe is holding the mic. + return user.pid !== undefined ? `${user.executable}#${user.pid}` : user.executable; +} diff --git a/apps/x/apps/main/src/meeting-detect/foreground-window.ts b/apps/x/apps/main/src/meeting-detect/foreground-window.ts new file mode 100644 index 00000000..72c1c8ce --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/foreground-window.ts @@ -0,0 +1,97 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface ForegroundWindow { + title: string; + // Best-effort process name; we don't always get this from osascript. + appName?: string; +} + +/** + * Read the title of whatever window is in the foreground. Cross-platform, + * zero native deps — shells out to a built-in OS tool. Returns null if the + * platform isn't supported or the call fails. + * + * We dropped `active-win` because its prebuilt native binary depends on + * runtime package.json lookups that don't survive esbuild bundling. + */ +export async function getForegroundWindow(): Promise { + if (process.platform === "win32") return getForegroundWindowWindows(); + if (process.platform === "darwin") return getForegroundWindowMacOS(); + return null; +} + +// Win32 GetForegroundWindow + GetWindowText via inline P/Invoke in PowerShell. +// Single one-shot call; cheap enough to run on every meeting-active event. +const WINDOWS_SCRIPT = ` +$src = @' +using System; +using System.Runtime.InteropServices; +using System.Text; +public class RowboatFW { + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll", CharSet=CharSet.Auto, SetLastError=true)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); + [DllImport("user32.dll", SetLastError=true)] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); +} +'@ +Add-Type -TypeDefinition $src -ErrorAction SilentlyContinue +$hwnd = [RowboatFW]::GetForegroundWindow() +$sb = New-Object System.Text.StringBuilder 1024 +[RowboatFW]::GetWindowText($hwnd, $sb, $sb.Capacity) | Out-Null +$pid2 = 0 +[RowboatFW]::GetWindowThreadProcessId($hwnd, [ref]$pid2) | Out-Null +$proc = $null +try { $proc = (Get-Process -Id $pid2 -ErrorAction SilentlyContinue).ProcessName } catch {} +[PSCustomObject]@{ Title = $sb.ToString(); App = $proc } | ConvertTo-Json -Compress +`.trim(); + +async function getForegroundWindowWindows(): Promise { + try { + const { stdout } = await execFileAsync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT], + { timeout: 5_000, windowsHide: true }, + ); + const trimmed = stdout.trim(); + if (!trimmed) return null; + const parsed = JSON.parse(trimmed) as { Title?: string; App?: string }; + if (typeof parsed.Title !== "string") return null; + return { title: parsed.Title, appName: parsed.App }; + } catch (err) { + console.error("[MeetingDetect] foreground-window (windows) failed:", err); + return null; + } +} + +// macOS via osascript — title of the frontmost window of the frontmost app. +// Requires Accessibility permission for the Electron app; without it, the +// `name of front window` lookup returns empty. +const MACOS_SCRIPT = ` +tell application "System Events" + set frontApp to first application process whose frontmost is true + set appName to name of frontApp + try + set winTitle to name of front window of frontApp + on error + set winTitle to "" + end try + return appName & "\\n" & winTitle +end tell +`.trim(); + +async function getForegroundWindowMacOS(): Promise { + try { + const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", MACOS_SCRIPT], { + timeout: 5_000, + }); + const [appName, ...titleParts] = stdout.trim().split("\n"); + return { title: titleParts.join("\n"), appName }; + } catch (err) { + console.error("[MeetingDetect] foreground-window (macOS) failed:", err); + return null; + } +} diff --git a/apps/x/apps/main/src/meeting-detect/index.ts b/apps/x/apps/main/src/meeting-detect/index.ts new file mode 100644 index 00000000..9f7c2fe7 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/index.ts @@ -0,0 +1,26 @@ +import { MeetingDetector } from "./detector.js"; +import { WindowsMicProbe } from "./probe-windows.js"; +import { MacOsMicProbe } from "./probe-macos.js"; +import type { MicProbe } from "./types.js"; + +export { MeetingDetector } from "./detector.js"; +export type { MeetingActiveEvent, MeetingClearedEvent } from "./detector.js"; +export { classifyExecutable, isMeetingApp, isBrowser } from "./meeting-apps.js"; +export type { MeetingAppKind } from "./meeting-apps.js"; +export type { MicProbe, MicUser } from "./types.js"; +export { Suppression, InMemorySuppressionStore } from "./suppression.js"; +export type { SuppressionStore } from "./suppression.js"; +export { MeetingDetectService, buildPopup } from "./service.js"; +export type { MeetingDetectServiceOptions } from "./service.js"; + +export function createPlatformDetector(): MeetingDetector | null { + const probe = createPlatformProbe(); + if (!probe) return null; + return new MeetingDetector(probe); +} + +function createPlatformProbe(): MicProbe | null { + if (process.platform === "win32") return new WindowsMicProbe(); + if (process.platform === "darwin") return new MacOsMicProbe(); + return null; +} diff --git a/apps/x/apps/main/src/meeting-detect/meeting-apps.ts b/apps/x/apps/main/src/meeting-detect/meeting-apps.ts new file mode 100644 index 00000000..a18dec0f --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/meeting-apps.ts @@ -0,0 +1,49 @@ +// Whitelist of executables / bundle IDs we treat as "the user is in a meeting" +// when they're holding the microphone. Native meeting apps map 1:1; browsers +// map to "maybe — check the foreground tab title before firing." + +export type MeetingAppKind = "zoom" | "teams" | "slack" | "discord" | "webex" | "browser" | "unknown"; + +interface AppRule { + kind: MeetingAppKind; + // Case-insensitive substring match against the executable path / basename + // (Windows: full exe path from registry; macOS: command name from lsof). + match: string[]; +} + +const RULES: AppRule[] = [ + { kind: "zoom", match: ["zoom.exe", "zoom.us", "cpthost.exe"] }, + { kind: "teams", match: ["ms-teams.exe", "teams.exe", "microsoft teams"] }, + { kind: "slack", match: ["slack.exe", "slack helper", "slack"] }, + { kind: "discord", match: ["discord.exe", "discord"] }, + { kind: "webex", match: ["webex.exe", "ciscowebex", "webexmta"] }, + // Browsers — kind "browser" means we still need a tab-title check before firing. + { kind: "browser", match: [ + "chrome.exe", "google chrome", + "msedge.exe", "microsoft edge", + "firefox.exe", "firefox", + "arc.exe", "arc", + "brave.exe", "brave browser", + "safari", + "vivaldi.exe", "vivaldi", + "opera.exe", "opera", + ]}, +]; + +export function classifyExecutable(executable: string): MeetingAppKind { + const haystack = executable.toLowerCase(); + for (const rule of RULES) { + for (const needle of rule.match) { + if (haystack.includes(needle)) return rule.kind; + } + } + return "unknown"; +} + +export function isMeetingApp(executable: string): boolean { + return classifyExecutable(executable) !== "unknown"; +} + +export function isBrowser(executable: string): boolean { + return classifyExecutable(executable) === "browser"; +} diff --git a/apps/x/apps/main/src/meeting-detect/probe-macos.ts b/apps/x/apps/main/src/meeting-detect/probe-macos.ts new file mode 100644 index 00000000..bc2fddd2 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/probe-macos.ts @@ -0,0 +1,47 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { MicProbe, MicUser } from "./types.js"; + +const execFileAsync = promisify(execFile); + +// macOS doesn't expose a public "who is using the mic right now" API. Two +// pragmatic signals we can read from a shell without a native helper: +// +// 1. `pmset -g assertions` — apps in a video call almost always hold a +// PreventUserIdleDisplaySleep wake-lock to keep the screen on. Strong +// proxy for "active call." False positives: video playback (YouTube, +// Netflix) — Phase 2's tab-title check filters those out for browsers. +// +// 2. `lsof | grep coreaudiod` — clients connected to coreaudiod. Noisy and +// doesn't always include the mic user, so we prefer pmset as primary. +// +// Output format from `pmset -g assertions`: +// pid 4711(zoom.us): [0x00000ff...] 00:23:14 PreventUserIdleDisplaySleep named: "..." +const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdle\w+)/; + +export class MacOsMicProbe implements MicProbe { + async probe(): Promise { + let stdout: string; + try { + const result = await execFileAsync("/usr/bin/pmset", ["-g", "assertions"], { + timeout: 10_000, + }); + stdout = result.stdout; + } catch (err) { + console.error("[MeetingDetect] macOS probe failed:", err); + return []; + } + + const seen = new Map(); + for (const line of stdout.split("\n")) { + const m = ASSERTION_LINE.exec(line); + if (!m) continue; + const pid = Number(m[1]); + const command = m[2].trim(); + if (!Number.isFinite(pid)) continue; + if (seen.has(pid)) continue; + seen.set(pid, { executable: command, pid }); + } + return Array.from(seen.values()); + } +} diff --git a/apps/x/apps/main/src/meeting-detect/probe-windows.ts b/apps/x/apps/main/src/meeting-detect/probe-windows.ts new file mode 100644 index 00000000..08af5ccc --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/probe-windows.ts @@ -0,0 +1,85 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { MicProbe, MicUser } from "./types.js"; + +const execFileAsync = promisify(execFile); + +// Windows records every mic-using app under CapabilityAccessManager. Each app +// subkey has LastUsedTimeStart and LastUsedTimeStop (FILETIME, int64). When +// Start > Stop, the app is currently holding the mic. Subkey names under +// NonPackaged are the executable path with `\` replaced by `#`. +// +// We shell out to PowerShell (single Get-ChildItem walk) rather than pulling +// in a native registry binding — far simpler to ship inside Electron and the +// poll cadence is 3s, so spawn cost is irrelevant. +const POWERSHELL_SCRIPT = ` +$paths = @( + 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone\\NonPackaged', + 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone' +) +$out = New-Object System.Collections.ArrayList +foreach ($p in $paths) { + if (-not (Test-Path $p)) { continue } + Get-ChildItem -Path $p -ErrorAction SilentlyContinue | ForEach-Object { + $props = Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue + if ($null -eq $props) { return } + $start = $props.LastUsedTimeStart + $stop = $props.LastUsedTimeStop + if ($null -ne $start -and $null -ne $stop -and $start -gt $stop) { + [void]$out.Add([PSCustomObject]@{ Name = $_.PSChildName }) + } + } +} +$out | ConvertTo-Json -Compress +`.trim(); + +interface RawRow { + Name?: string; +} + +function decodeNonPackagedName(name: string): string { + // NonPackaged subkeys: "C:#Program Files#Zoom#bin#Zoom.exe" → "C:\Program Files\Zoom\bin\Zoom.exe" + // Packaged subkeys are AUMIDs (e.g. "Microsoft.Teams_..._mscorlib") — leave as-is. + if (name.includes("#") && !name.includes("\\")) { + return name.replace(/#/g, "\\"); + } + return name; +} + +export class WindowsMicProbe implements MicProbe { + async probe(): Promise { + let stdout: string; + try { + const result = await execFileAsync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", POWERSHELL_SCRIPT], + { timeout: 10_000, windowsHide: true }, + ); + stdout = result.stdout.trim(); + } catch (err) { + console.error("[MeetingDetect] Windows probe failed:", err); + return []; + } + if (!stdout) return []; + + let parsed: RawRow[] | RawRow; + try { + parsed = JSON.parse(stdout); + } catch (err) { + console.error("[MeetingDetect] Windows probe parse failed:", err); + return []; + } + // ConvertTo-Json emits a single object (not an array) when the list has one item. + const rows: RawRow[] = Array.isArray(parsed) ? parsed : [parsed]; + const seen = new Set(); + const out: MicUser[] = []; + for (const row of rows) { + if (!row || typeof row.Name !== "string") continue; + const exe = decodeNonPackagedName(row.Name); + if (seen.has(exe)) continue; + seen.add(exe); + out.push({ executable: exe }); + } + return out; + } +} diff --git a/apps/x/apps/main/src/meeting-detect/service.test.ts b/apps/x/apps/main/src/meeting-detect/service.test.ts new file mode 100644 index 00000000..870ddb9b --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/service.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js"; +import { MeetingDetector } from "./detector.js"; +import type { MicProbe, MicUser } from "./types.js"; +import { MeetingDetectService, buildPopup } from "./service.js"; +import { Suppression, InMemorySuppressionStore } from "./suppression.js"; +import type { BrowserMeetingMatch } from "./browser-match.js"; +import type { CorrelatedEvent } from "./calendar-correlate.js"; + +class FakeProbe implements MicProbe { + next: MicUser[] = []; + async probe(): Promise { return this.next; } +} + +class FakeNotifier implements INotificationService { + sent: NotifyInput[] = []; + isSupported(): boolean { return true; } + notify(input: NotifyInput): void { this.sent.push(input); } +} + +describe("buildPopup", () => { + it("uses the calendar event summary when correlated", () => { + const corr: CorrelatedEvent = { + eventId: "abc123", + summary: "Q2 Planning", + startMs: 0, endMs: 0, + attendees: [], + }; + const popup = buildPopup("zoom", null, corr); + expect(popup?.notify.message).toContain("Q2 Planning"); + expect(popup?.notify.link).toContain("eventId=abc123"); + expect(popup?.notify.link).toContain("take-meeting-notes"); + }); + + it("falls back to ad-hoc copy when no calendar match", () => { + const popup = buildPopup("zoom", null, null); + expect(popup?.notify.title).toBe("You're in a meeting"); + expect(popup?.notify.link).toContain("title="); + expect(popup?.notify.link).not.toContain("eventId="); + }); + + it("uses browser match platform label when kind=browser", () => { + const m: BrowserMeetingMatch = { platform: "google-meet", hint: "https://meet.google.com/abc" }; + const popup = buildPopup("browser", m, null); + expect(popup?.notify.message).toContain("Google Meet"); + }); + + it("returns null for unknown app without browser match (defensive)", () => { + expect(buildPopup("unknown", null, null)).toBeNull(); + }); +}); + +describe("MeetingDetectService end-to-end", () => { + let probe: FakeProbe; + let detector: MeetingDetector; + let notifier: FakeNotifier; + let suppression: Suppression; + + beforeEach(() => { + probe = new FakeProbe(); + detector = new MeetingDetector(probe, 999_999); + notifier = new FakeNotifier(); + suppression = new Suppression(new InMemorySuppressionStore()); + }); + + it("fires notification when a zoom call is detected, with calendar context", async () => { + const correlated: CorrelatedEvent = { + eventId: "evt-1", + summary: "Standup", + startMs: 0, endMs: 0, + attendees: [], + }; + const service = new MeetingDetectService({ + detector, + notifier, + suppression, + matchBrowser: async () => null, + correlate: async () => correlated, + }); + await service.start(); + + probe.next = [{ executable: "zoom.us", pid: 100 }]; + await detector.tick(); + await service.settle(); + + expect(notifier.sent).toHaveLength(1); + expect(notifier.sent[0].title).toBe("Take notes for this meeting?"); + expect(notifier.sent[0].message).toContain("Standup"); + expect(notifier.sent[0].link).toContain("eventId=evt-1"); + }); + + it("does NOT fire for a browser if the foreground tab is not a meeting page", async () => { + const service = new MeetingDetectService({ + detector, + notifier, + suppression, + matchBrowser: async () => null, // browser foreground = not a meeting + correlate: async () => null, + }); + await service.start(); + + probe.next = [{ executable: "Google Chrome", pid: 200 }]; + await detector.tick(); + await service.settle(); + + expect(notifier.sent).toHaveLength(0); + }); + + it("FIRES for a browser when the foreground tab IS a meeting page", async () => { + const service = new MeetingDetectService({ + detector, + notifier, + suppression, + matchBrowser: async () => ({ platform: "google-meet", hint: "https://meet.google.com/x" }), + correlate: async () => null, + }); + await service.start(); + + probe.next = [{ executable: "Google Chrome", pid: 200 }]; + await detector.tick(); + await service.settle(); + + expect(notifier.sent).toHaveLength(1); + expect(notifier.sent[0].message).toContain("Google Meet"); + expect(notifier.sent[0].link).toContain("title="); // ad-hoc, no eventId + }); + + it("does not re-fire on consecutive ticks for the same session", async () => { + const service = new MeetingDetectService({ + detector, + notifier, + suppression, + matchBrowser: async () => null, + correlate: async () => null, + }); + await service.start(); + + probe.next = [{ executable: "zoom.us", pid: 100 }]; + await detector.tick(); + await detector.tick(); + await detector.tick(); + await service.settle(); + + expect(notifier.sent).toHaveLength(1); + }); + + it("respects per-app mute", async () => { + await suppression.init(); + await suppression.muteApp("Discord"); + + const service = new MeetingDetectService({ + detector, + notifier, + suppression, + matchBrowser: async () => null, + correlate: async () => null, + }); + await service.start(); + + probe.next = [{ executable: "Discord", pid: 300 }]; + await detector.tick(); + await service.settle(); + + expect(notifier.sent).toHaveLength(0); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts new file mode 100644 index 00000000..82a19824 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -0,0 +1,153 @@ +import type { INotificationService } from "@x/core/dist/application/notification/service.js"; +import { MeetingDetector, type MeetingActiveEvent } from "./detector.js"; +import { matchBrowserMeeting, type BrowserMeetingMatch } from "./browser-match.js"; +import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js"; +import { Suppression } from "./suppression.js"; +import type { MeetingAppKind } from "./meeting-apps.js"; + +// Glue layer: turns detector events into popup notifications, gated by browser +// tab matching, calendar correlation, and the suppression store. +// +// Tests inject their own detector + notification service + suppression so this +// runs without touching the OS. + +type Matcher = () => Promise; +type Correlator = (now: Date) => Promise; + +export interface MeetingDetectServiceOptions { + detector: MeetingDetector; + notifier: INotificationService; + suppression: Suppression; + // Defaults run the real OS-touching versions; tests override. + matchBrowser?: Matcher; + correlate?: Correlator; +} + +export class MeetingDetectService { + private readonly detector: MeetingDetector; + private readonly notifier: INotificationService; + private readonly suppression: Suppression; + private readonly matchBrowser: Matcher; + private readonly correlate: Correlator; + // Track async work spawned from detector events so tests (and shutdown) + // can wait for it to settle. + private pending = new Set>(); + + constructor(opts: MeetingDetectServiceOptions) { + this.detector = opts.detector; + this.notifier = opts.notifier; + this.suppression = opts.suppression; + this.matchBrowser = opts.matchBrowser ?? matchBrowserMeeting; + this.correlate = opts.correlate ?? ((now) => correlateNow(now)); + } + + async start(): Promise { + await this.suppression.init(); + if (!this.notifier.isSupported()) { + console.warn("[MeetingDetect] notification service unsupported; detector will run but no popups will fire"); + } + this.detector.on("meeting-active", (event) => { + const work = this.handleActive(event).catch((err) => { + console.error("[MeetingDetect] handleActive failed:", err); + }); + this.pending.add(work); + void work.finally(() => this.pending.delete(work)); + }); + this.detector.start(); + } + + stop(): void { + this.detector.stop(); + } + + /** Test hook — resolves once all in-flight handleActive() calls complete. */ + async settle(): Promise { + while (this.pending.size > 0) { + await Promise.all([...this.pending]); + } + } + + private async handleActive(event: MeetingActiveEvent): Promise { + if (!this.suppression.shouldNotify(event.sessionKey, event.executable)) return; + + // For browsers we MUST confirm the foreground tab is a meeting page — + // otherwise we'd popup for YouTube, Spotify web, etc. + let browserMatch: BrowserMeetingMatch | null = null; + if (event.kind === "browser") { + browserMatch = await this.matchBrowser(); + if (!browserMatch) return; + } + + const correlated = await this.correlate(new Date()).catch(() => null); + const payload = buildPopup(event.kind, browserMatch, correlated); + if (!payload) return; + + try { + this.notifier.notify(payload.notify); + await this.suppression.markNotified(event.sessionKey); + console.log(`[MeetingDetect] popup fired for ${event.executable} (kind=${event.kind}, eventId=${correlated?.eventId ?? "ad-hoc"})`); + } catch (err) { + console.error("[MeetingDetect] notify failed:", err); + } + } +} + +interface BuiltPopup { + notify: { + title: string; + message: string; + link: string; + actionLabel: string; + }; +} + +export function buildPopup( + kind: MeetingAppKind, + browserMatch: BrowserMeetingMatch | null, + correlated: CorrelatedEvent | null, +): BuiltPopup | null { + const platformLabel = describePlatform(kind, browserMatch); + if (!platformLabel) return null; + + if (correlated) { + return { + notify: { + title: "Take notes for this meeting?", + message: `${correlated.summary} — on ${platformLabel}. Click to capture notes with Rowboat.`, + link: `rowboat://action?type=take-meeting-notes&eventId=${encodeURIComponent(correlated.eventId)}`, + actionLabel: "Take notes", + }, + }; + } + + // Ad-hoc — no calendar event matched. Still offer notes, with generic copy. + return { + notify: { + title: "You're in a meeting", + message: `Detected on ${platformLabel}. Click to take notes with Rowboat.`, + link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(`Ad-hoc ${platformLabel} call`)}`, + actionLabel: "Take notes", + }, + }; +} + +function describePlatform(kind: MeetingAppKind, browserMatch: BrowserMeetingMatch | null): string | null { + if (browserMatch) { + switch (browserMatch.platform) { + case "google-meet": return "Google Meet"; + case "zoom-web": return "Zoom"; + case "teams-web": return "Microsoft Teams"; + case "slack-huddle": return "Slack huddle"; + case "webex-web": return "Webex"; + } + } + switch (kind) { + case "zoom": return "Zoom"; + case "teams": return "Microsoft Teams"; + case "slack": return "Slack"; + case "discord": return "Discord"; + case "webex": return "Webex"; + case "browser": return null; // shouldn't happen — caller bails before us when no browserMatch + case "unknown": return null; + } +} diff --git a/apps/x/apps/main/src/meeting-detect/suppression.test.ts b/apps/x/apps/main/src/meeting-detect/suppression.test.ts new file mode 100644 index 00000000..ba569fdd --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/suppression.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Suppression, InMemorySuppressionStore } from "./suppression.js"; + +describe("Suppression", () => { + let store: InMemorySuppressionStore; + let suppression: Suppression; + + beforeEach(async () => { + store = new InMemorySuppressionStore(); + suppression = new Suppression(store); + await suppression.init(); + }); + + it("allows the first popup for a fresh session", () => { + expect(suppression.shouldNotify("zoom.us#100", "zoom.us")).toBe(true); + }); + + it("blocks re-popup for the same session once marked notified", async () => { + await suppression.markNotified("zoom.us#100"); + expect(suppression.shouldNotify("zoom.us#100", "zoom.us")).toBe(false); + }); + + it("allows a different session for the same exe", async () => { + await suppression.markNotified("zoom.us#100"); + expect(suppression.shouldNotify("zoom.us#101", "zoom.us")).toBe(true); + }); + + it("respects the dismiss cooldown window", async () => { + const t0 = new Date("2026-05-15T10:00:00Z"); + await suppression.markDismissed("/Applications/zoom.us.app/Contents/MacOS/zoom.us", t0); + + const within = new Date(t0.getTime() + 10 * 60 * 1000); // 10 min later + expect(suppression.shouldNotify("zoom.us#200", "zoom.us", within)).toBe(false); + + const after = new Date(t0.getTime() + 31 * 60 * 1000); // 31 min later — past 30-min cooldown + // Cooldown GC drops entries past the window — re-load to apply GC. + const reloaded = new Suppression(store); + await reloaded.init(); + expect(reloaded.shouldNotify("zoom.us#200", "zoom.us", after)).toBe(true); + }); + + it("permanently mutes an app", async () => { + await suppression.muteApp("/Applications/Discord.app/Contents/MacOS/Discord"); + expect(suppression.shouldNotify("Discord#9", "Discord")).toBe(false); + // And after reload, still muted. + const reloaded = new Suppression(store); + await reloaded.init(); + expect(reloaded.shouldNotify("Discord#10", "Discord")).toBe(false); + }); + + it("persists state through save/load", async () => { + await suppression.markNotified("zoom.us#100"); + await suppression.muteApp("Discord"); + + const snap = store.snapshot(); + expect(snap.notifiedSessions["zoom.us#100"]).toBeDefined(); + expect(snap.mutedApps).toContain("discord"); + + const reloaded = new Suppression(store); + await reloaded.init(); + expect(reloaded.shouldNotify("zoom.us#100", "zoom.us")).toBe(false); + expect(reloaded.isMuted("Discord")).toBe(true); + }); + + it("dismiss key normalizes path differences (Win path vs basename)", async () => { + const winPath = "C:\\Program Files\\Zoom\\bin\\Zoom.exe"; + const macPath = "/Applications/Zoom.app/Contents/MacOS/zoom.us"; + + // Mute via mac-style path, expect it to apply when the detector reports the Windows-style path + // only if the basename matches. zoom.exe vs zoom.us differ, so they should NOT cross-match + // — verifying the dismiss key is the bare exe name and we don't over-match. + await suppression.muteApp(winPath); + expect(suppression.isMuted(winPath)).toBe(true); + expect(suppression.isMuted(macPath)).toBe(false); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/suppression.ts b/apps/x/apps/main/src/meeting-detect/suppression.ts new file mode 100644 index 00000000..cdea3648 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/suppression.ts @@ -0,0 +1,151 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import { WorkDir } from "@x/core/dist/config/config.js"; + +const STATE_FILE = path.join(WorkDir, "meeting_detect_state.json"); +// Don't re-popup for the same exe within this window if the user dismissed. +const DISMISS_COOLDOWN_MS = 30 * 60 * 1000; +// Drop session-key entries older than 24h. +const SESSION_TTL_MS = 24 * 60 * 60 * 1000; + +interface SuppressionState { + // Mic sessions we've already shown a popup for — keyed by detector sessionKey. + notifiedSessions: Record; + // User explicitly dismissed for this exe at this time. + recentlyDismissed: Record; + // Permanent "never offer for this app" list — exe substring matches. + mutedApps: string[]; +} + +function empty(): SuppressionState { + return { notifiedSessions: {}, recentlyDismissed: {}, mutedApps: [] }; +} + +export interface SuppressionStore { + load(): Promise; + save(state: SuppressionState): Promise; +} + +class FileSuppressionStore implements SuppressionStore { + private readonly file: string; + constructor(file: string) { this.file = file; } + + async load(): Promise { + try { + const raw = await fs.readFile(this.file, "utf-8"); + const parsed = JSON.parse(raw); + return normalize(parsed); + } catch { + return empty(); + } + } + + async save(state: SuppressionState): Promise { + const tmp = `${this.file}.tmp`; + await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8"); + await fs.rename(tmp, this.file); + } +} + +function normalize(raw: unknown): SuppressionState { + if (!raw || typeof raw !== "object") return empty(); + const obj = raw as Partial; + return { + notifiedSessions: obj.notifiedSessions && typeof obj.notifiedSessions === "object" ? obj.notifiedSessions : {}, + recentlyDismissed: obj.recentlyDismissed && typeof obj.recentlyDismissed === "object" ? obj.recentlyDismissed : {}, + mutedApps: Array.isArray(obj.mutedApps) ? obj.mutedApps.filter((x) => typeof x === "string") : [], + }; +} + +export class Suppression { + private readonly store: SuppressionStore; + private state: SuppressionState = empty(); + private loaded = false; + + constructor(store?: SuppressionStore) { + this.store = store ?? new FileSuppressionStore(STATE_FILE); + } + + async init(): Promise { + this.state = gc(await this.store.load()); + this.loaded = true; + } + + /** Should we fire a popup for this (sessionKey, executable)? */ + shouldNotify(sessionKey: string, executable: string, now: Date = new Date()): boolean { + if (!this.loaded) return true; // fail open — better to occasionally re-popup than to silently miss. + if (this.isMuted(executable)) return false; + if (this.state.notifiedSessions[sessionKey]) return false; + + const dismissKey = dismissKeyFor(executable); + const recent = this.state.recentlyDismissed[dismissKey]; + if (recent) { + const dismissedAt = Date.parse(recent.dismissedAt); + if (Number.isFinite(dismissedAt) && now.getTime() - dismissedAt < DISMISS_COOLDOWN_MS) { + return false; + } + } + return true; + } + + async markNotified(sessionKey: string, now: Date = new Date()): Promise { + this.state.notifiedSessions[sessionKey] = { notifiedAt: now.toISOString() }; + await this.persist(); + } + + async markDismissed(executable: string, now: Date = new Date()): Promise { + this.state.recentlyDismissed[dismissKeyFor(executable)] = { dismissedAt: now.toISOString() }; + await this.persist(); + } + + async muteApp(executable: string): Promise { + const key = dismissKeyFor(executable); + if (!this.state.mutedApps.includes(key)) { + this.state.mutedApps.push(key); + await this.persist(); + } + } + + isMuted(executable: string): boolean { + const needle = dismissKeyFor(executable); + return this.state.mutedApps.some((m) => needle.includes(m) || m.includes(needle)); + } + + private async persist(): Promise { + this.state = gc(this.state); + try { + await this.store.save(this.state); + } catch (err) { + console.error("[MeetingDetect] failed to persist suppression state:", err); + } + } +} + +function dismissKeyFor(executable: string): string { + // Reduce a path/exe to a stable key — strip directory, lowercase. + const base = executable.replace(/^.*[/\\]/, "").toLowerCase(); + return base || executable.toLowerCase(); +} + +function gc(state: SuppressionState): SuppressionState { + const now = Date.now(); + const sessions: SuppressionState["notifiedSessions"] = {}; + for (const [k, v] of Object.entries(state.notifiedSessions)) { + const ts = Date.parse(v.notifiedAt); + if (Number.isFinite(ts) && now - ts < SESSION_TTL_MS) sessions[k] = v; + } + const dismissed: SuppressionState["recentlyDismissed"] = {}; + for (const [k, v] of Object.entries(state.recentlyDismissed)) { + const ts = Date.parse(v.dismissedAt); + if (Number.isFinite(ts) && now - ts < DISMISS_COOLDOWN_MS) dismissed[k] = v; + } + return { notifiedSessions: sessions, recentlyDismissed: dismissed, mutedApps: state.mutedApps }; +} + +/** In-memory store for tests. */ +export class InMemorySuppressionStore implements SuppressionStore { + private state: SuppressionState = empty(); + async load(): Promise { return JSON.parse(JSON.stringify(this.state)); } + async save(s: SuppressionState): Promise { this.state = JSON.parse(JSON.stringify(s)); } + snapshot(): SuppressionState { return JSON.parse(JSON.stringify(this.state)); } +} diff --git a/apps/x/apps/main/src/meeting-detect/types.ts b/apps/x/apps/main/src/meeting-detect/types.ts new file mode 100644 index 00000000..c4420197 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/types.ts @@ -0,0 +1,12 @@ +export interface MicUser { + // Best-effort executable identifier — full path on Windows, command name on macOS. + executable: string; + // Process id when the platform exposes it (macOS via lsof). Undefined on Windows + // because the registry only records the exe path, not which pid is currently + // holding the mic. + pid?: number; +} + +export interface MicProbe { + probe(): Promise; +} diff --git a/apps/x/apps/main/tsconfig.json b/apps/x/apps/main/tsconfig.json index d7166cc7..fbfc3525 100644 --- a/apps/x/apps/main/tsconfig.json +++ b/apps/x/apps/main/tsconfig.json @@ -10,5 +10,8 @@ }, "include": [ "src" + ], + "exclude": [ + "src/**/*.test.ts" ] } \ No newline at end of file diff --git a/apps/x/apps/main/vitest.config.ts b/apps/x/apps/main/vitest.config.ts new file mode 100644 index 00000000..7eeb3f85 --- /dev/null +++ b/apps/x/apps/main/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 0605adaf..b783403c 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -4,6 +4,12 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + vitest: + specifier: 4.1.7 + version: 4.1.7 + importers: .: @@ -111,6 +117,9 @@ importers: esbuild: specifier: ^0.24.2 version: 0.24.2 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0) apps/preload: dependencies: @@ -927,6 +936,12 @@ packages: engines: {node: '>=14.14'} hasBin: true + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.24.2': resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} engines: {node: '>=18'} @@ -939,6 +954,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.24.2': resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} engines: {node: '>=18'} @@ -951,6 +972,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.24.2': resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} engines: {node: '>=18'} @@ -963,6 +990,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.24.2': resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} engines: {node: '>=18'} @@ -975,6 +1008,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.24.2': resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} engines: {node: '>=18'} @@ -987,6 +1026,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.24.2': resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} engines: {node: '>=18'} @@ -999,6 +1044,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.24.2': resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} engines: {node: '>=18'} @@ -1011,6 +1062,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.24.2': resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} engines: {node: '>=18'} @@ -1023,6 +1080,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.24.2': resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} engines: {node: '>=18'} @@ -1035,6 +1098,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.24.2': resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} engines: {node: '>=18'} @@ -1047,6 +1116,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.24.2': resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} engines: {node: '>=18'} @@ -1059,6 +1134,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.24.2': resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} engines: {node: '>=18'} @@ -1071,6 +1152,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.24.2': resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} engines: {node: '>=18'} @@ -1083,6 +1170,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.24.2': resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} engines: {node: '>=18'} @@ -1095,6 +1188,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.24.2': resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} engines: {node: '>=18'} @@ -1107,6 +1206,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.24.2': resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} engines: {node: '>=18'} @@ -1119,6 +1224,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.24.2': resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} engines: {node: '>=18'} @@ -1143,6 +1254,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.24.2': resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} engines: {node: '>=18'} @@ -1167,6 +1284,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.24.2': resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} engines: {node: '>=18'} @@ -1185,6 +1308,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.24.2': resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} engines: {node: '>=18'} @@ -1197,6 +1326,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.24.2': resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} engines: {node: '>=18'} @@ -1209,6 +1344,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.24.2': resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} engines: {node: '>=18'} @@ -1221,6 +1362,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.24.2': resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} engines: {node: '>=18'} @@ -3581,9 +3728,23 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@4.1.7': resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.1.7': resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} peerDependencies: @@ -3595,18 +3756,33 @@ packages: vite: optional: true + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@4.1.7': resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@4.1.7': resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@4.1.7': resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@4.1.7': resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@4.1.7': resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} @@ -3938,6 +4114,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacache@16.1.3: resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -3982,6 +4162,10 @@ packages: resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} engines: {node: '>=0.8'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -4005,6 +4189,10 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chevrotain-allstar@0.4.1: resolution: {integrity: sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==} peerDependencies: @@ -4411,6 +4599,10 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -4623,6 +4815,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} @@ -4640,6 +4835,11 @@ packages: es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.24.2: resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} engines: {node: '>=18'} @@ -5753,6 +5953,9 @@ packages: lop@0.4.2: resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -6461,9 +6664,16 @@ packages: resolution: {integrity: sha512-dUnb5dXUf+kzhC/W/F4e5/SkluXIFf5VUHolW1Eg1irn1hGWjPGdsRcvYJ1nD6lhk8Ir7VM0bHJKsYTx8Jx9OQ==} engines: {node: '>=4'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pdf-parse@2.4.5: resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==} engines: {node: '>=20.16.0 <21 || >=22.3.0'} @@ -7165,6 +7375,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -7312,6 +7525,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -7320,10 +7536,22 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tiptap-markdown@0.9.0: resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==} peerDependencies: @@ -7598,6 +7826,42 @@ packages: resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} engines: {node: '>= 6'} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7638,6 +7902,31 @@ packages: yaml: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.1.7: resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -8994,102 +9283,153 @@ snapshots: transitivePeerDependencies: - supports-color + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.24.2': optional: true '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.24.2': optional: true '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.24.2': optional: true '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.24.2': optional: true '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.24.2': optional: true '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.24.2': optional: true '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.24.2': optional: true '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.24.2': optional: true '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.24.2': optional: true '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.24.2': optional: true '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.24.2': optional: true '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.24.2': optional: true '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.24.2': optional: true '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.24.2': optional: true '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.24.2': optional: true '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.24.2': optional: true '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.24.2': optional: true @@ -9102,6 +9442,9 @@ snapshots: '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.24.2': optional: true @@ -9114,6 +9457,9 @@ snapshots: '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.24.2': optional: true @@ -9123,24 +9469,36 @@ snapshots: '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.24.2': optional: true '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.24.2': optional: true '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.24.2': optional: true '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.24.2': optional: true @@ -11818,6 +12176,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + '@vitest/expect@4.1.7': dependencies: '@standard-schema/spec': 1.1.0 @@ -11827,6 +12192,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0) + '@vitest/mocker@4.1.7(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.7 @@ -11835,15 +12208,30 @@ snapshots: optionalDependencies: vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2) + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + '@vitest/pretty-format@4.1.7': dependencies: tinyrainbow: 3.1.0 + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + '@vitest/runner@4.1.7': dependencies: '@vitest/utils': 4.1.7 pathe: 2.0.3 + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + '@vitest/snapshot@4.1.7': dependencies: '@vitest/pretty-format': 4.1.7 @@ -11851,8 +12239,18 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + '@vitest/spy@4.1.7': {} + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@vitest/utils@4.1.7': dependencies: '@vitest/pretty-format': 4.1.7 @@ -12218,6 +12616,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + cacache@16.1.3: dependencies: '@npmcli/fs': 2.1.2 @@ -12288,6 +12688,14 @@ snapshots: adler-32: 1.3.1 crc-32: 1.2.2 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} chalk@4.1.2: @@ -12305,6 +12713,8 @@ snapshots: chardet@0.7.0: {} + check-error@2.1.3: {} + chevrotain-allstar@0.4.1(chevrotain@12.0.0): dependencies: chevrotain: 12.0.0 @@ -12708,6 +13118,8 @@ snapshots: dependencies: mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} defaults@1.0.4: @@ -12970,6 +13382,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} es-object-atoms@1.1.1: @@ -12988,6 +13402,32 @@ snapshots: es6-error@4.1.1: optional: true + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.24.2: optionalDependencies: '@esbuild/aix-ppc64': 0.24.2 @@ -14361,6 +14801,8 @@ snapshots: option: 0.2.4 underscore: 1.13.8 + loupe@3.2.1: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -15299,8 +15741,12 @@ snapshots: dependencies: pify: 2.3.0 + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.1: {} + pdf-parse@2.4.5: dependencies: '@napi-rs/canvas': 0.1.80 @@ -16199,6 +16645,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + std-env@4.1.0: {} stream-browserify@3.0.0: @@ -16369,6 +16817,8 @@ snapshots: tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -16376,8 +16826,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + tinyrainbow@3.1.0: {} + tinyspy@3.0.2: {} + tiptap-markdown@0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)): dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) @@ -16676,6 +17132,35 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + vite-node@2.1.9(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.54.0 + optionalDependencies: + '@types/node': 25.0.3 + fsevents: 2.3.3 + lightningcss: 1.30.2 + terser: 5.46.0 + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 @@ -16708,6 +17193,41 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 + vitest@2.1.9(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0) + vite-node: 2.1.9(@types/node@25.0.3)(lightningcss@1.30.2)(terser@5.46.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.0.3 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.7 From 8da40bd9bb1f486d1ac7fa445673bafd20ca91a8 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 14:41:18 +0530 Subject: [PATCH 02/35] feat: wire mic-detect popup into note-taking flow --- .../x/apps/main/src/meeting-detect/service.ts | 15 +++- .../main/src/meeting-detect/suppression.ts | 12 ++++ apps/x/apps/renderer/src/App.tsx | 71 ++++++++++++------- apps/x/packages/shared/src/ipc.ts | 4 +- 4 files changed, 73 insertions(+), 29 deletions(-) diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 82a19824..91080ee0 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -53,7 +53,16 @@ export class MeetingDetectService { this.pending.add(work); void work.finally(() => this.pending.delete(work)); }); + this.detector.on("meeting-cleared", (event) => { + // Mic released → drop the session's suppression so the next call + // (same Chrome process, new Meet) can fire again. + this.suppression.clearSession(event.sessionKey).catch((err) => { + console.error("[MeetingDetect] clearSession failed:", err); + }); + console.log(`[MeetingDetect] session cleared: ${event.sessionKey}`); + }); this.detector.start(); + console.log("[MeetingDetect] service started — polling for meeting apps holding the mic"); } stop(): void { @@ -68,7 +77,11 @@ export class MeetingDetectService { } private async handleActive(event: MeetingActiveEvent): Promise { - if (!this.suppression.shouldNotify(event.sessionKey, event.executable)) return; + console.log(`[MeetingDetect] active: ${event.executable} (kind=${event.kind})`); + if (!this.suppression.shouldNotify(event.sessionKey, event.executable)) { + console.log(`[MeetingDetect] suppressed (already notified or muted): ${event.sessionKey}`); + return; + } // For browsers we MUST confirm the foreground tab is a meeting page — // otherwise we'd popup for YouTube, Spotify web, etc. diff --git a/apps/x/apps/main/src/meeting-detect/suppression.ts b/apps/x/apps/main/src/meeting-detect/suppression.ts index cdea3648..c2b42ece 100644 --- a/apps/x/apps/main/src/meeting-detect/suppression.ts +++ b/apps/x/apps/main/src/meeting-detect/suppression.ts @@ -93,6 +93,18 @@ export class Suppression { await this.persist(); } + /** + * Clear the notified mark for a session. Called when the detector observes + * the mic being released — without this, on Windows (no pid in sessionKey) + * the same browser would never re-fire because every new Meet call reuses + * the same exe-keyed session. + */ + async clearSession(sessionKey: string): Promise { + if (!this.state.notifiedSessions[sessionKey]) return; + delete this.state.notifiedSessions[sessionKey]; + await this.persist(); + } + async markDismissed(executable: string, now: Date = new Date()): Promise { this.state.recentlyDismissed[dismissKeyFor(executable)] = { dismissedAt: now.toISOString() }; await this.persist(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a35fbca7..c6184e47 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3968,36 +3968,53 @@ function App() { return window.ipc.on('app:openUrl', ({ url }) => handle(url)) }, []) - // Triggered by main when the user clicks a calendar-meeting notification. - // Reuses the same flow as the in-app "Join meeting & take notes" button. + // Triggered by main when the user clicks a meeting-notes notification — + // either the calendar-time notification (event populated) or the mic-detect + // ad-hoc notification (event=null, title=string). Both routes feed the same + // calendar-block flow which kicks off startMeetingNow(). // When `openMeeting` is true, also opens the meeting URL in the system browser. useEffect(() => { - return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => { - const e = event as { - summary?: string - start?: { dateTime?: string; date?: string; timeZone?: string } - end?: { dateTime?: string; date?: string; timeZone?: string } - location?: string - htmlLink?: string - hangoutLink?: string - conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } - } - if (!e || typeof e !== 'object') return - const conferenceLink = extractConferenceLink(e as Record) - if (openMeeting && conferenceLink) { - window.open(conferenceLink, '_blank') - } else if (openMeeting) { - console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e) - } - window.__pendingCalendarEvent = { - summary: e.summary, - start: e.start, - end: e.end, - location: e.location, - htmlLink: e.htmlLink, - conferenceLink, - source: 'calendar-sync', + return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting, title }) => { + const payload = event as + | { + summary?: string + start?: { dateTime?: string; date?: string; timeZone?: string } + end?: { dateTime?: string; date?: string; timeZone?: string } + location?: string + htmlLink?: string + hangoutLink?: string + conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } + } + | null + | undefined + + if (payload && typeof payload === 'object') { + const conferenceLink = extractConferenceLink(payload as Record) + if (openMeeting && conferenceLink) { + window.open(conferenceLink, '_blank') + } else if (openMeeting) { + console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', payload) + } + window.__pendingCalendarEvent = { + summary: payload.summary, + start: payload.start, + end: payload.end, + location: payload.location, + htmlLink: payload.htmlLink, + conferenceLink, + source: 'calendar-sync', + } + } else if (typeof title === 'string' && title.length > 0) { + // Ad-hoc detection — no calendar event matched. Build a minimal + // pending event from the title so the meeting flow can still start. + window.__pendingCalendarEvent = { + summary: title, + source: 'meeting-detect', + } + } else { + return } + window.dispatchEvent(new Event('calendar-block:join-meeting')) }) }, []) diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 230d384c..f61aae7e 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -395,11 +395,13 @@ const ipcSchemas = { }, 'app:takeMeetingNotes': { req: z.object({ - // Pass the raw calendar event JSON through; renderer adapts to its existing flow. + // Calendar event JSON when correlated; null for mic-detect ad-hoc fires. event: z.unknown(), // When true, the renderer should also open the meeting URL (Zoom/Meet/etc.) // in addition to triggering the take-notes flow. openMeeting: z.boolean().optional(), + // Fallback title for ad-hoc detection (no calendar event matched). + title: z.string().nullable().optional(), }), res: z.null(), }, From 2901379d2338bdbc8294a9b8a61b2d64e9f0cf3a Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 15:30:50 +0530 Subject: [PATCH 03/35] feat: name ad-hoc meeting notes by platform with same-day counter --- .../src/meeting-detect/ad-hoc-title.test.ts | 89 +++++++++++++++ .../main/src/meeting-detect/ad-hoc-title.ts | 101 ++++++++++++++++++ .../main/src/meeting-detect/browser-match.ts | 19 ++-- .../src/meeting-detect/foreground-window.ts | 99 +++++++++-------- .../main/src/meeting-detect/service.test.ts | 7 ++ .../x/apps/main/src/meeting-detect/service.ts | 33 +++++- 6 files changed, 284 insertions(+), 64 deletions(-) create mode 100644 apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts diff --git a/apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts new file mode 100644 index 00000000..97bbf92b --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import path from "node:path"; +import fs from "node:fs/promises"; +import os from "node:os"; +import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js"; + +let tmpRoot: string; +const NOW = new Date(2026, 4, 15, 14, 0, 0); // 2026-05-15 14:00 local + +beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rb-adhoc-title-")); +}); + +afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }); +}); + +async function writeNote(day: string, filename: string): Promise { + const dir = path.join(tmpRoot, day); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, filename), "stub", "utf-8"); +} + +describe("buildAdHocTitle", () => { + it("returns the bare title for the first occurrence of the day", async () => { + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom"); + }); + + it("appends #2 when one already exists", async () => { + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom #2"); + }); + + it("increments past #2 (#3, #4, ...)", async () => { + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md"); + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom_#2.md"); + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom_#3.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom #4"); + }); + + it("doesn't cross-count platforms (Meet vs Zoom stay distinct)", async () => { + await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md"); + const title = await buildAdHocTitle({ platformLabel: "Meet", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Meet"); + }); + + it("resets the counter on a different day", async () => { + await writeNote("2026-05-14", "Meeting_Notes_-_Zoom.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom"); + }); + + it("ignores non-meeting notes in the same folder", async () => { + await writeNote("2026-05-15", "standup.md"); + await writeNote("2026-05-15", "random_note.md"); + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom"); + }); + + it("matches slug-variant filenames (different separators)", async () => { + // Whatever the renderer's slugifier does, normalize() should match. + await writeNote("2026-05-15", "Meeting Notes - Zoom.md"); + await writeNote("2026-05-15", "Meeting-Notes--Zoom.md"); // hypothetical alt slug + const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot }); + expect(title).toBe("Meeting Notes - Zoom #3"); + }); +}); + +describe("shortPlatformLabel", () => { + it("maps browser platforms to short labels", () => { + expect(shortPlatformLabel({ browserPlatform: "google-meet", kind: "browser" })).toBe("Meet"); + expect(shortPlatformLabel({ browserPlatform: "zoom-web", kind: "browser" })).toBe("Zoom"); + expect(shortPlatformLabel({ browserPlatform: "teams-web", kind: "browser" })).toBe("Teams"); + }); + + it("maps native kinds to short labels", () => { + expect(shortPlatformLabel({ kind: "zoom" })).toBe("Zoom"); + expect(shortPlatformLabel({ kind: "teams" })).toBe("Teams"); + expect(shortPlatformLabel({ kind: "discord" })).toBe("Discord"); + }); + + it("returns null for unmatched browser / unknown", () => { + expect(shortPlatformLabel({ kind: "browser" })).toBeNull(); + expect(shortPlatformLabel({ kind: "unknown" })).toBeNull(); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts new file mode 100644 index 00000000..e71baaf7 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/ad-hoc-title.ts @@ -0,0 +1,101 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import { WorkDir } from "@x/core/dist/config/config.js"; + +// Ad-hoc meeting titles: "Meeting Notes - " with a per-day counter +// suffix when there's already one for the same platform on the same day. +// +// first Zoom today → "Meeting Notes - Zoom" +// second Zoom today → "Meeting Notes - Zoom #2" +// first Zoom tomorrow → "Meeting Notes - Zoom" (fresh folder, fresh count) + +const MEETINGS_ROOT = path.join(WorkDir, "knowledge", "Meetings", "rowboat"); +const TITLE_PREFIX = "Meeting Notes - "; + +export interface AdHocTitleOptions { + platformLabel: string; + now?: Date; + // Override for tests; defaults to the user's real meetings folder. + root?: string; +} + +export async function buildAdHocTitle(opts: AdHocTitleOptions): Promise { + const platform = opts.platformLabel; + const base = `${TITLE_PREFIX}${platform}`; + + const now = opts.now ?? new Date(); + const dayFolder = path.join(opts.root ?? MEETINGS_ROOT, formatDay(now)); + + const existing = await countMatching(dayFolder, base); + if (existing === 0) return base; + return `${base} #${existing + 1}`; +} + +function formatDay(d: Date): string { + // YYYY-MM-DD in local time — matches the existing knowledge/Meetings/rowboat layout. + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +async function countMatching(dir: string, baseTitle: string): Promise { + let entries: string[]; + try { + entries = await fs.readdir(dir); + } catch { + return 0; + } + const needle = normalize(baseTitle); + let count = 0; + for (const name of entries) { + if (!name.endsWith(".md")) continue; + const stem = name.slice(0, -3); // strip .md + if (normalize(stem).startsWith(needle)) count++; + } + return count; +} + +/** + * Normalize a title or filename to alphanumerics-only-lowercase so we can + * compare across slugification rules: + * "Meeting Notes - Zoom" → "meetingnoteszoom" + * "Meeting_Notes_-_Zoom.md" → "meetingnoteszoom" (after .md strip) + * "Meeting Notes - Zoom #2" → "meetingnoteszoom2" + * + * Anchoring with startsWith() then catches both the bare title and any + * counter-suffixed variant, without colliding across platforms ("Meet" + * vs "Zoom" stay distinct because the platform name appears after the + * common "meetingnotes" prefix). + */ +function normalize(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +// Map our internal platform/kind names to user-facing short labels. +// Re-exported so service.ts can produce both the popup body label and the +// note title from the same source of truth. +export function shortPlatformLabel(input: { + browserPlatform?: "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web"; + kind: "zoom" | "teams" | "slack" | "discord" | "webex" | "browser" | "unknown"; +}): string | null { + if (input.browserPlatform) { + switch (input.browserPlatform) { + case "google-meet": return "Meet"; + case "zoom-web": return "Zoom"; + case "teams-web": return "Teams"; + case "slack-huddle": return "Slack"; + case "webex-web": return "Webex"; + } + } + switch (input.kind) { + case "zoom": return "Zoom"; + case "teams": return "Teams"; + case "slack": return "Slack"; + case "discord": return "Discord"; + case "webex": return "Webex"; + case "browser": + case "unknown": + return null; + } +} diff --git a/apps/x/apps/main/src/meeting-detect/browser-match.ts b/apps/x/apps/main/src/meeting-detect/browser-match.ts index efbf829f..6de404f9 100644 --- a/apps/x/apps/main/src/meeting-detect/browser-match.ts +++ b/apps/x/apps/main/src/meeting-detect/browser-match.ts @@ -1,4 +1,4 @@ -import { getForegroundWindow } from "./foreground-window.js"; +import { getWindowSnapshot } from "./foreground-window.js"; export type BrowserMeetingPlatform = "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web"; @@ -36,13 +36,16 @@ const RULES: TitleRule[] = [ * mic-holder as `kind: "browser"`. That keeps active-win calls cheap — we * only ask the OS when there's a reason to ask. */ -export async function matchBrowserMeeting(): Promise { - const win = await getForegroundWindow(); - if (!win) return null; - // We only have a title (no URL from these OS calls), but Chrome / Edge / - // Firefox include the tab title in the window title, which contains the - // meeting service name for Meet/Zoom-web/Teams-web pages. - return matchTitleOrUrl(win.title, undefined); +export async function matchBrowserMeeting(executable?: string): Promise { + const snap = await getWindowSnapshot(executable); + if (!snap) return null; + // Scan ALL known window titles — on Windows tasklist returns every window, + // so even a backgrounded Meet tab still matches while Chrome holds the mic. + for (const title of snap.titles) { + const m = matchTitleOrUrl(title, undefined); + if (m) return m; + } + return null; } /** Pure matcher — exposed for tests; no OS calls. */ diff --git a/apps/x/apps/main/src/meeting-detect/foreground-window.ts b/apps/x/apps/main/src/meeting-detect/foreground-window.ts index 72c1c8ce..e9f523d0 100644 --- a/apps/x/apps/main/src/meeting-detect/foreground-window.ts +++ b/apps/x/apps/main/src/meeting-detect/foreground-window.ts @@ -3,70 +3,66 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -export interface ForegroundWindow { - title: string; - // Best-effort process name; we don't always get this from osascript. - appName?: string; +export interface WindowSnapshot { + // Window titles we know about. Implementations may return one (foreground) + // or many (all titles for a process). browser-match scans the whole list, + // so we don't need to identify which is foreground. + titles: string[]; } /** - * Read the title of whatever window is in the foreground. Cross-platform, - * zero native deps — shells out to a built-in OS tool. Returns null if the - * platform isn't supported or the call fails. + * Best-effort look at currently-open window titles for a given executable. + * On Windows: `tasklist /v /fi "imagename eq "` — fast because it skips + * every system process. On macOS: AppleScript for the frontmost window. * - * We dropped `active-win` because its prebuilt native binary depends on - * runtime package.json lookups that don't survive esbuild bundling. + * Pass the basename of the exe (e.g. "chrome.exe"). Returns null on failure; + * an empty title list means "process is running but no window has a title." */ -export async function getForegroundWindow(): Promise { - if (process.platform === "win32") return getForegroundWindowWindows(); - if (process.platform === "darwin") return getForegroundWindowMacOS(); +export async function getWindowSnapshot(executable?: string): Promise { + if (process.platform === "win32") return getWindowSnapshotWindows(executable); + if (process.platform === "darwin") return getWindowSnapshotMacOS(); return null; } -// Win32 GetForegroundWindow + GetWindowText via inline P/Invoke in PowerShell. -// Single one-shot call; cheap enough to run on every meeting-active event. -const WINDOWS_SCRIPT = ` -$src = @' -using System; -using System.Runtime.InteropServices; -using System.Text; -public class RowboatFW { - [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll", CharSet=CharSet.Auto, SetLastError=true)] - public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); - [DllImport("user32.dll", SetLastError=true)] - public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); -} -'@ -Add-Type -TypeDefinition $src -ErrorAction SilentlyContinue -$hwnd = [RowboatFW]::GetForegroundWindow() -$sb = New-Object System.Text.StringBuilder 1024 -[RowboatFW]::GetWindowText($hwnd, $sb, $sb.Capacity) | Out-Null -$pid2 = 0 -[RowboatFW]::GetWindowThreadProcessId($hwnd, [ref]$pid2) | Out-Null -$proc = $null -try { $proc = (Get-Process -Id $pid2 -ErrorAction SilentlyContinue).ProcessName } catch {} -[PSCustomObject]@{ Title = $sb.ToString(); App = $proc } | ConvertTo-Json -Compress -`.trim(); +async function getWindowSnapshotWindows(executable?: string): Promise { + // Reduce to a basename — full paths can't be passed to tasklist's + // imagename filter, and the filter wants e.g. "chrome.exe", not the path. + const imageName = executable ? executable.replace(/^.*[\\/]/, "") : ""; + const args = ["/v", "/fo", "csv", "/nh"]; + if (imageName) args.push("/fi", `imagename eq ${imageName}`); -async function getForegroundWindowWindows(): Promise { try { const { stdout } = await execFileAsync( - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT], - { timeout: 5_000, windowsHide: true }, + "tasklist.exe", + args, + { timeout: 10_000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, ); - const trimmed = stdout.trim(); - if (!trimmed) return null; - const parsed = JSON.parse(trimmed) as { Title?: string; App?: string }; - if (typeof parsed.Title !== "string") return null; - return { title: parsed.Title, appName: parsed.App }; + const titles: string[] = []; + for (const line of stdout.split(/\r?\n/)) { + if (!line) continue; + const fields = parseCsvLine(line); + if (fields.length === 0) continue; + const title = fields[fields.length - 1]; + if (!title || title === "N/A") continue; + titles.push(title); + } + return { titles }; } catch (err) { - console.error("[MeetingDetect] foreground-window (windows) failed:", err); + console.error("[MeetingDetect] window-snapshot (windows) failed:", err); return null; } } +function parseCsvLine(line: string): string[] { + // tasklist /fo csv quotes every field and doesn't embed quotes within fields, + // so a simple comma-split between quoted segments works. + const out: string[] = []; + const re = /"([^"]*)"/g; + let m: RegExpExecArray | null; + while ((m = re.exec(line)) !== null) out.push(m[1]); + return out; +} + // macOS via osascript — title of the frontmost window of the frontmost app. // Requires Accessibility permission for the Electron app; without it, the // `name of front window` lookup returns empty. @@ -83,15 +79,16 @@ tell application "System Events" end tell `.trim(); -async function getForegroundWindowMacOS(): Promise { +async function getWindowSnapshotMacOS(): Promise { try { const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", MACOS_SCRIPT], { timeout: 5_000, }); - const [appName, ...titleParts] = stdout.trim().split("\n"); - return { title: titleParts.join("\n"), appName }; + const [, ...titleParts] = stdout.trim().split("\n"); + const title = titleParts.join("\n"); + return { titles: title ? [title] : [] }; } catch (err) { - console.error("[MeetingDetect] foreground-window (macOS) failed:", err); + console.error("[MeetingDetect] window-snapshot (macOS) failed:", err); return null; } } diff --git a/apps/x/apps/main/src/meeting-detect/service.test.ts b/apps/x/apps/main/src/meeting-detect/service.test.ts index 870ddb9b..7586efde 100644 --- a/apps/x/apps/main/src/meeting-detect/service.test.ts +++ b/apps/x/apps/main/src/meeting-detect/service.test.ts @@ -37,6 +37,13 @@ describe("buildPopup", () => { expect(popup?.notify.title).toBe("You're in a meeting"); expect(popup?.notify.link).toContain("title="); expect(popup?.notify.link).not.toContain("eventId="); + // Default ad-hoc title (no precomputed counter) is "Meeting Notes - Zoom". + expect(decodeURIComponent(popup!.notify.link.split("title=")[1])).toBe("Meeting Notes - Zoom"); + }); + + it("uses the precomputed ad-hoc title when provided (counter case)", () => { + const popup = buildPopup("zoom", null, null, "Meeting Notes - Zoom #2"); + expect(decodeURIComponent(popup!.notify.link.split("title=")[1])).toBe("Meeting Notes - Zoom #2"); }); it("uses browser match platform label when kind=browser", () => { diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 91080ee0..5e38c521 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -4,6 +4,7 @@ import { matchBrowserMeeting, type BrowserMeetingMatch } from "./browser-match.j import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js"; import { Suppression } from "./suppression.js"; import type { MeetingAppKind } from "./meeting-apps.js"; +import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js"; // Glue layer: turns detector events into popup notifications, gated by browser // tab matching, calendar correlation, and the suppression store. @@ -11,7 +12,7 @@ import type { MeetingAppKind } from "./meeting-apps.js"; // Tests inject their own detector + notification service + suppression so this // runs without touching the OS. -type Matcher = () => Promise; +type Matcher = (executable?: string) => Promise; type Correlator = (now: Date) => Promise; export interface MeetingDetectServiceOptions { @@ -87,12 +88,30 @@ export class MeetingDetectService { // otherwise we'd popup for YouTube, Spotify web, etc. let browserMatch: BrowserMeetingMatch | null = null; if (event.kind === "browser") { - browserMatch = await this.matchBrowser(); + browserMatch = await this.matchBrowser(event.executable); if (!browserMatch) return; } const correlated = await this.correlate(new Date()).catch(() => null); - const payload = buildPopup(event.kind, browserMatch, correlated); + + // Ad-hoc only: compute "Meeting Notes - [#N]" so the note + // file lands with a useful title. Skip when we have a real calendar + // event — that already provides the right summary. + let adHocTitle: string | undefined; + if (!correlated) { + const short = shortPlatformLabel({ + browserPlatform: browserMatch?.platform, + kind: event.kind, + }); + if (short) { + adHocTitle = await buildAdHocTitle({ platformLabel: short }).catch((err) => { + console.error("[MeetingDetect] buildAdHocTitle failed:", err); + return `Meeting Notes - ${short}`; + }); + } + } + + const payload = buildPopup(event.kind, browserMatch, correlated, adHocTitle); if (!payload) return; try { @@ -118,6 +137,7 @@ export function buildPopup( kind: MeetingAppKind, browserMatch: BrowserMeetingMatch | null, correlated: CorrelatedEvent | null, + adHocTitle?: string, ): BuiltPopup | null { const platformLabel = describePlatform(kind, browserMatch); if (!platformLabel) return null; @@ -133,12 +153,15 @@ export function buildPopup( }; } - // Ad-hoc — no calendar event matched. Still offer notes, with generic copy. + // Ad-hoc — no calendar event matched. Use the precomputed counter-aware + // title ("Meeting Notes - Zoom" / "... #2") if available; fall back to a + // simple platform-suffixed title. + const title = adHocTitle ?? `Meeting Notes - ${platformLabel}`; return { notify: { title: "You're in a meeting", message: `Detected on ${platformLabel}. Click to take notes with Rowboat.`, - link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(`Ad-hoc ${platformLabel} call`)}`, + link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(title)}`, actionLabel: "Take notes", }, }; From e7ea03c8d1492e71b283f3d391da7addd5380baa Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 15:47:00 +0530 Subject: [PATCH 04/35] feat: top-center notion-style toast for meeting-detect prompt --- apps/x/apps/main/src/main.ts | 9 + .../main/src/meeting-detect/service.test.ts | 32 ++++ .../x/apps/main/src/meeting-detect/service.ts | 22 ++- .../src/meeting-detect/toast-window.test.ts | 46 +++++ .../main/src/meeting-detect/toast-window.ts | 165 ++++++++++++++++++ 5 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 apps/x/apps/main/src/meeting-detect/toast-window.test.ts create mode 100644 apps/x/apps/main/src/meeting-detect/toast-window.ts diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index a5e9eb5f..c873a1f5 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -391,12 +391,21 @@ app.whenReady().then(async () => { initCalendarNotifications(); // start meeting-detect service (mic-in-use detection -> popup asking if user wants notes) + // + // Popup style — flip this one constant to switch the meeting-detect prompt + // between the custom Notion-style top-center toast and the native OS + // notification. Doesn't affect the separate calendar 1-min warnings. + // false (default) → custom toast + // true → native OS notification + const USE_NATIVE_NOTIFICATION_FOR_MEETING_DETECT = false; + const meetingDetector = createPlatformDetector(); if (meetingDetector) { const meetingDetectService = new MeetingDetectService({ detector: meetingDetector, notifier: notificationService, suppression: new Suppression(), + toast: USE_NATIVE_NOTIFICATION_FOR_MEETING_DETECT ? null : undefined, }); meetingDetectService.start().catch((err) => { console.error("[MeetingDetect] failed to start:", err); diff --git a/apps/x/apps/main/src/meeting-detect/service.test.ts b/apps/x/apps/main/src/meeting-detect/service.test.ts index 7586efde..6b076889 100644 --- a/apps/x/apps/main/src/meeting-detect/service.test.ts +++ b/apps/x/apps/main/src/meeting-detect/service.test.ts @@ -83,6 +83,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, correlate: async () => correlated, + toast: null, }); await service.start(); @@ -103,6 +104,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, // browser foreground = not a meeting correlate: async () => null, + toast: null, }); await service.start(); @@ -120,6 +122,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => ({ platform: "google-meet", hint: "https://meet.google.com/x" }), correlate: async () => null, + toast: null, }); await service.start(); @@ -139,6 +142,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, correlate: async () => null, + toast: null, }); await service.start(); @@ -151,6 +155,33 @@ describe("MeetingDetectService end-to-end", () => { expect(notifier.sent).toHaveLength(1); }); + it("uses the toast renderer when provided instead of the native notifier", async () => { + const calls: Array<{ title: string; message: string; actionLink: string }> = []; + const toast = { + show(p: { title: string; message: string; actionLabel: string; actionLink: string }) { + calls.push({ title: p.title, message: p.message, actionLink: p.actionLink }); + }, + }; + const service = new MeetingDetectService({ + detector, + notifier, + suppression, + matchBrowser: async () => null, + correlate: async () => null, + toast, + }); + await service.start(); + + probe.next = [{ executable: "zoom.us", pid: 100 }]; + await detector.tick(); + await service.settle(); + + expect(notifier.sent).toHaveLength(0); + expect(calls).toHaveLength(1); + expect(calls[0].title).toBe("You're in a meeting"); + expect(calls[0].actionLink).toContain("take-meeting-notes"); + }); + it("respects per-app mute", async () => { await suppression.init(); await suppression.muteApp("Discord"); @@ -161,6 +192,7 @@ describe("MeetingDetectService end-to-end", () => { suppression, matchBrowser: async () => null, correlate: async () => null, + toast: null, }); await service.start(); diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 5e38c521..37339825 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -5,6 +5,7 @@ import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js"; import { Suppression } from "./suppression.js"; import type { MeetingAppKind } from "./meeting-apps.js"; import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js"; +import { MeetingToastWindow, type ToastPayload } from "./toast-window.js"; // Glue layer: turns detector events into popup notifications, gated by browser // tab matching, calendar correlation, and the suppression store. @@ -22,6 +23,10 @@ export interface MeetingDetectServiceOptions { // Defaults run the real OS-touching versions; tests override. matchBrowser?: Matcher; correlate?: Correlator; + // Custom popup renderer. When provided (default in production), the toast + // is used instead of the native OS notification. Tests pass null to fall + // back to the notifier and assert on its calls. + toast?: { show(payload: ToastPayload): void } | null; } export class MeetingDetectService { @@ -30,6 +35,7 @@ export class MeetingDetectService { private readonly suppression: Suppression; private readonly matchBrowser: Matcher; private readonly correlate: Correlator; + private readonly toast: { show(payload: ToastPayload): void } | null; // Track async work spawned from detector events so tests (and shutdown) // can wait for it to settle. private pending = new Set>(); @@ -40,6 +46,9 @@ export class MeetingDetectService { this.suppression = opts.suppression; this.matchBrowser = opts.matchBrowser ?? matchBrowserMeeting; this.correlate = opts.correlate ?? ((now) => correlateNow(now)); + // `toast` is explicitly nullable so tests can opt out. Undefined → + // build the real one. Null → use the native notifier instead. + this.toast = opts.toast === undefined ? new MeetingToastWindow() : opts.toast; } async start(): Promise { @@ -115,11 +124,20 @@ export class MeetingDetectService { if (!payload) return; try { - this.notifier.notify(payload.notify); + if (this.toast) { + this.toast.show({ + title: payload.notify.title, + message: payload.notify.message, + actionLabel: payload.notify.actionLabel, + actionLink: payload.notify.link, + }); + } else { + this.notifier.notify(payload.notify); + } await this.suppression.markNotified(event.sessionKey); console.log(`[MeetingDetect] popup fired for ${event.executable} (kind=${event.kind}, eventId=${correlated?.eventId ?? "ad-hoc"})`); } catch (err) { - console.error("[MeetingDetect] notify failed:", err); + console.error("[MeetingDetect] popup failed:", err); } } } diff --git a/apps/x/apps/main/src/meeting-detect/toast-window.test.ts b/apps/x/apps/main/src/meeting-detect/toast-window.test.ts new file mode 100644 index 00000000..3d4c7244 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/toast-window.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { buildToastHtml } from "./toast-window.js"; + +describe("buildToastHtml", () => { + it("renders title, message, action label, and a primary link to the rowboat deeplink", () => { + const html = buildToastHtml({ + title: "You're in a meeting", + message: "Detected on Google Meet. Click to take notes.", + actionLabel: "Take notes", + actionLink: "rowboat://action?type=take-meeting-notes&title=Meeting%20Notes%20-%20Meet", + }); + + expect(html).toContain("You're in a meeting"); + expect(html).toContain("Detected on Google Meet"); + expect(html).toContain("Take notes"); + expect(html).toContain("rowboat://action?type=take-meeting-notes"); + }); + + it("includes a dismiss link the window will intercept", () => { + const html = buildToastHtml({ + title: "x", message: "y", actionLabel: "Go", actionLink: "rowboat://action", + }); + expect(html).toContain("rowboat-toast://dismiss"); + }); + + it("escapes HTML in title/message so a Meet titled `", + message: "& < > \" '", + actionLabel: "ok", + actionLink: "rowboat://action", + }); + expect(html).not.toContain(""); + expect(html).toContain("<script>alert(1)</script>"); + expect(html).toContain("& < > " '"); + }); + + it("escapes the action link so a malicious title in the URL can't break out of the href quotes", () => { + const html = buildToastHtml({ + title: "x", message: "y", actionLabel: "ok", + actionLink: `rowboat://action?title=evil"onerror=alert(1)`, + }); + expect(html).not.toContain(`"onerror=alert(1)`); + expect(html).toContain(""onerror=alert(1)"); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/toast-window.ts b/apps/x/apps/main/src/meeting-detect/toast-window.ts new file mode 100644 index 00000000..2b36252c --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/toast-window.ts @@ -0,0 +1,165 @@ +import { BrowserWindow, screen } from "electron"; +import { dispatchUrl } from "../deeplink.js"; + +// Custom Notion-style meeting toast: top-center frameless window with our +// own React-less HTML. We avoid the OS notification API because Windows +// and macOS both force-position native notifications (bottom-right / top- +// right respectively) and we want top-center. +// +// Lifecycle: +// show() → opens window, slides in, auto-closes at AUTO_DISMISS_MS +// action button click → navigation to rowboat:// → dispatchUrl + close +// dismiss button click → navigation to rowboat://dismiss → just close + +const TOAST_WIDTH = 460; +const TOAST_HEIGHT = 110; +const TOAST_TOP_MARGIN = 56; +const AUTO_DISMISS_MS = 30_000; + +export interface ToastPayload { + title: string; + message: string; + actionLabel: string; + actionLink: string; +} + +/** Build the self-contained HTML the toast window renders. Pure — tested. */ +export function buildToastHtml(payload: ToastPayload): string { + return ` + + + + + + +
+
+
${escapeHtml(payload.title)}
+
${escapeHtml(payload.message)}
+
+ +
+ +`; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c]!)); +} +function escapeAttr(s: string): string { return escapeHtml(s); } + +export class MeetingToastWindow { + private win: BrowserWindow | null = null; + private timer: NodeJS.Timeout | null = null; + + show(payload: ToastPayload): void { + // If a previous toast is still up, replace it. + this.closeImmediate(); + + const display = screen.getPrimaryDisplay(); + const wa = display.workArea; + const x = Math.round(wa.x + (wa.width - TOAST_WIDTH) / 2); + const y = wa.y + TOAST_TOP_MARGIN; + + const win = new BrowserWindow({ + width: TOAST_WIDTH, + height: TOAST_HEIGHT, + x, y, + frame: false, + transparent: true, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + alwaysOnTop: true, + focusable: false, + show: false, + hasShadow: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }); + // alwaysOnTop with screen-saver level so it floats above full-screen apps too. + win.setAlwaysOnTop(true, "screen-saver"); + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + + // Intercept the action links — both rowboat:// (real deeplinks) and + // rowboat-toast://dismiss (our internal dismiss signal). + win.webContents.on("will-navigate", (event, url) => { + event.preventDefault(); + if (url.startsWith("rowboat-toast://")) { + this.closeImmediate(); + return; + } + if (url.startsWith("rowboat://")) { + dispatchUrl(url); + this.closeImmediate(); + return; + } + // Anything else (shouldn't happen with our own HTML) — ignore. + }); + + win.once("ready-to-show", () => win.show()); + win.on("closed", () => { + if (this.win === win) this.win = null; + if (this.timer) { clearTimeout(this.timer); this.timer = null; } + }); + + const html = buildToastHtml(payload); + // data: URL so we don't have to ship a separate file. Encoded so the + // protocol parser doesn't choke on quotes / hashes in the payload. + win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); + + this.win = win; + this.timer = setTimeout(() => this.closeImmediate(), AUTO_DISMISS_MS); + } + + closeImmediate(): void { + if (this.timer) { clearTimeout(this.timer); this.timer = null; } + if (this.win && !this.win.isDestroyed()) this.win.close(); + this.win = null; + } +} From 1fca31f1c727f9f243834baf4ace76a23552e1ce Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 15 May 2026 16:19:09 +0530 Subject: [PATCH 05/35] feat: polish meeting toast design and add dev preview hotkey --- apps/x/apps/main/src/main.ts | 24 +++ .../main/src/meeting-detect/service.test.ts | 11 +- .../x/apps/main/src/meeting-detect/service.ts | 24 ++- .../src/meeting-detect/suppression.test.ts | 4 +- .../src/meeting-detect/toast-window.test.ts | 30 ++-- .../main/src/meeting-detect/toast-window.ts | 161 ++++++++++++------ 6 files changed, 177 insertions(+), 77 deletions(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index c873a1f5..79a7aead 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -50,6 +50,7 @@ import { MeetingDetectService, Suppression, } from "./meeting-detect/index.js"; +import { MeetingToastWindow } from "./meeting-detect/toast-window.js"; import { DEEP_LINK_SCHEME, dispatchUrl, @@ -242,6 +243,29 @@ function createWindow() { setMainWindowForDeepLinks(win); win.on("closed", () => setMainWindowForDeepLinks(null)); + // Dev-only: Ctrl+Shift+T fires a fake meeting-detect toast so we can + // iterate on the toast UI without joining a real Meet. Scoped to the + // main window's input events so it can't collide with browser/OS chords. + if (!app.isPackaged) { + win.webContents.on("before-input-event", (event, input) => { + const isToggle = + input.type === "keyDown" && + input.control && input.shift && !input.alt && !input.meta && + input.key.toLowerCase() === "t"; + if (!isToggle) return; + event.preventDefault(); + new MeetingToastWindow().show({ + title: "You are in a meeting", + subtitle: "Detected on Google Meet", + actionLabel: "Start taking notes", + actionLink: + "rowboat://action?type=take-meeting-notes&title=" + + encodeURIComponent("Meeting Notes - Meet"), + }); + console.log("[MeetingDetect] dev toast triggered (Ctrl+Shift+T)"); + }); + } + // Show window when content is ready to prevent blank screen win.once("ready-to-show", () => { win.maximize(); diff --git a/apps/x/apps/main/src/meeting-detect/service.test.ts b/apps/x/apps/main/src/meeting-detect/service.test.ts index 6b076889..476730fa 100644 --- a/apps/x/apps/main/src/meeting-detect/service.test.ts +++ b/apps/x/apps/main/src/meeting-detect/service.test.ts @@ -34,7 +34,7 @@ describe("buildPopup", () => { it("falls back to ad-hoc copy when no calendar match", () => { const popup = buildPopup("zoom", null, null); - expect(popup?.notify.title).toBe("You're in a meeting"); + expect(popup?.notify.title).toBe("You are in a meeting"); expect(popup?.notify.link).toContain("title="); expect(popup?.notify.link).not.toContain("eventId="); // Default ad-hoc title (no precomputed counter) is "Meeting Notes - Zoom". @@ -156,10 +156,10 @@ describe("MeetingDetectService end-to-end", () => { }); it("uses the toast renderer when provided instead of the native notifier", async () => { - const calls: Array<{ title: string; message: string; actionLink: string }> = []; + const calls: Array<{ title: string; subtitle: string; actionLink: string }> = []; const toast = { - show(p: { title: string; message: string; actionLabel: string; actionLink: string }) { - calls.push({ title: p.title, message: p.message, actionLink: p.actionLink }); + show(p: { title: string; subtitle: string; actionLabel: string; actionLink: string }) { + calls.push({ title: p.title, subtitle: p.subtitle, actionLink: p.actionLink }); }, }; const service = new MeetingDetectService({ @@ -178,7 +178,8 @@ describe("MeetingDetectService end-to-end", () => { expect(notifier.sent).toHaveLength(0); expect(calls).toHaveLength(1); - expect(calls[0].title).toBe("You're in a meeting"); + expect(calls[0].title).toBe("You are in a meeting"); + expect(calls[0].subtitle).toContain("Zoom"); expect(calls[0].actionLink).toContain("take-meeting-notes"); }); diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 37339825..0da141f5 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -126,8 +126,8 @@ export class MeetingDetectService { try { if (this.toast) { this.toast.show({ - title: payload.notify.title, - message: payload.notify.message, + title: payload.toast.title, + subtitle: payload.toast.subtitle, actionLabel: payload.notify.actionLabel, actionLink: payload.notify.link, }); @@ -149,6 +149,13 @@ interface BuiltPopup { link: string; actionLabel: string; }; + // Toast-specific fields (subtitle is the secondary line; the native + // notification API only has one body string, so we collapse title+subtitle + // into `message` when falling back to the OS notifier). + toast: { + title: string; + subtitle: string; + }; } export function buildPopup( @@ -161,13 +168,16 @@ export function buildPopup( if (!platformLabel) return null; if (correlated) { + const toastTitle = correlated.summary; + const toastSubtitle = `On ${platformLabel}`; return { notify: { title: "Take notes for this meeting?", message: `${correlated.summary} — on ${platformLabel}. Click to capture notes with Rowboat.`, link: `rowboat://action?type=take-meeting-notes&eventId=${encodeURIComponent(correlated.eventId)}`, - actionLabel: "Take notes", + actionLabel: "Start taking notes", }, + toast: { title: toastTitle, subtitle: toastSubtitle }, }; } @@ -177,10 +187,14 @@ export function buildPopup( const title = adHocTitle ?? `Meeting Notes - ${platformLabel}`; return { notify: { - title: "You're in a meeting", + title: "You are in a meeting", message: `Detected on ${platformLabel}. Click to take notes with Rowboat.`, link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(title)}`, - actionLabel: "Take notes", + actionLabel: "Start taking notes", + }, + toast: { + title: "You are in a meeting", + subtitle: `Detected on ${platformLabel}`, }, }; } diff --git a/apps/x/apps/main/src/meeting-detect/suppression.test.ts b/apps/x/apps/main/src/meeting-detect/suppression.test.ts index ba569fdd..ab521f11 100644 --- a/apps/x/apps/main/src/meeting-detect/suppression.test.ts +++ b/apps/x/apps/main/src/meeting-detect/suppression.test.ts @@ -26,7 +26,9 @@ describe("Suppression", () => { }); it("respects the dismiss cooldown window", async () => { - const t0 = new Date("2026-05-15T10:00:00Z"); + // Anchor at "now" — gc() filters by wall-clock age, so a hard-coded + // past date would be dropped on persist and the cooldown wouldn't apply. + const t0 = new Date(); await suppression.markDismissed("/Applications/zoom.us.app/Contents/MacOS/zoom.us", t0); const within = new Date(t0.getTime() + 10 * 60 * 1000); // 10 min later diff --git a/apps/x/apps/main/src/meeting-detect/toast-window.test.ts b/apps/x/apps/main/src/meeting-detect/toast-window.test.ts index 3d4c7244..eed24d7f 100644 --- a/apps/x/apps/main/src/meeting-detect/toast-window.test.ts +++ b/apps/x/apps/main/src/meeting-detect/toast-window.test.ts @@ -2,31 +2,41 @@ import { describe, it, expect } from "vitest"; import { buildToastHtml } from "./toast-window.js"; describe("buildToastHtml", () => { - it("renders title, message, action label, and a primary link to the rowboat deeplink", () => { + it("renders title, subtitle, CTA and a link to the rowboat deeplink", () => { const html = buildToastHtml({ - title: "You're in a meeting", - message: "Detected on Google Meet. Click to take notes.", - actionLabel: "Take notes", + title: "You are in a meeting", + subtitle: "Detected on Google Meet", + actionLabel: "Start taking notes", actionLink: "rowboat://action?type=take-meeting-notes&title=Meeting%20Notes%20-%20Meet", }); - expect(html).toContain("You're in a meeting"); + expect(html).toContain("You are in a meeting"); expect(html).toContain("Detected on Google Meet"); - expect(html).toContain("Take notes"); + expect(html).toContain("Start taking notes"); expect(html).toContain("rowboat://action?type=take-meeting-notes"); }); + it("includes the rowboat wordmark and accessibility attributes", () => { + const html = buildToastHtml({ + title: "x", subtitle: "y", actionLabel: "Go", actionLink: "rowboat://action", + }); + expect(html).toContain(">rowboat<"); + expect(html).toContain('role="alert"'); + expect(html).toContain('aria-live="polite"'); + expect(html).toContain('aria-label="Dismiss meeting notification"'); + }); + it("includes a dismiss link the window will intercept", () => { const html = buildToastHtml({ - title: "x", message: "y", actionLabel: "Go", actionLink: "rowboat://action", + title: "x", subtitle: "y", actionLabel: "Go", actionLink: "rowboat://action", }); expect(html).toContain("rowboat-toast://dismiss"); }); - it("escapes HTML in title/message so a Meet titled `", - message: "& < > \" '", + subtitle: "& < > \" '", actionLabel: "ok", actionLink: "rowboat://action", }); @@ -37,7 +47,7 @@ describe("buildToastHtml", () => { it("escapes the action link so a malicious title in the URL can't break out of the href quotes", () => { const html = buildToastHtml({ - title: "x", message: "y", actionLabel: "ok", + title: "x", subtitle: "y", actionLabel: "ok", actionLink: `rowboat://action?title=evil"onerror=alert(1)`, }); expect(html).not.toContain(`"onerror=alert(1)`); diff --git a/apps/x/apps/main/src/meeting-detect/toast-window.ts b/apps/x/apps/main/src/meeting-detect/toast-window.ts index 2b36252c..f66ae48b 100644 --- a/apps/x/apps/main/src/meeting-detect/toast-window.ts +++ b/apps/x/apps/main/src/meeting-detect/toast-window.ts @@ -1,24 +1,18 @@ import { BrowserWindow, screen } from "electron"; import { dispatchUrl } from "../deeplink.js"; -// Custom Notion-style meeting toast: top-center frameless window with our -// own React-less HTML. We avoid the OS notification API because Windows -// and macOS both force-position native notifications (bottom-right / top- -// right respectively) and we want top-center. +// Notion-style meeting toast: top-center frameless window with our own HTML. +// Persistent — closes only when the user clicks the CTA or the X. // -// Lifecycle: -// show() → opens window, slides in, auto-closes at AUTO_DISMISS_MS -// action button click → navigation to rowboat:// → dispatchUrl + close -// dismiss button click → navigation to rowboat://dismiss → just close +// Spec: white card, top: 24px, max-width 640, slide-down entry animation. -const TOAST_WIDTH = 460; -const TOAST_HEIGHT = 110; -const TOAST_TOP_MARGIN = 56; -const AUTO_DISMISS_MS = 30_000; +const TOAST_WIDTH = 560; +const TOAST_HEIGHT = 92; +const TOAST_TOP_MARGIN = 24; export interface ToastPayload { title: string; - message: string; + subtitle: string; actionLabel: string; actionLink: string; } @@ -31,50 +25,116 @@ export function buildToastHtml(payload: ToastPayload): string { -
-
+ `; @@ -89,7 +149,6 @@ function escapeAttr(s: string): string { return escapeHtml(s); } export class MeetingToastWindow { private win: BrowserWindow | null = null; - private timer: NodeJS.Timeout | null = null; show(payload: ToastPayload): void { // If a previous toast is still up, replace it. @@ -122,12 +181,9 @@ export class MeetingToastWindow { sandbox: true, }, }); - // alwaysOnTop with screen-saver level so it floats above full-screen apps too. win.setAlwaysOnTop(true, "screen-saver"); win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - // Intercept the action links — both rowboat:// (real deeplinks) and - // rowboat-toast://dismiss (our internal dismiss signal). win.webContents.on("will-navigate", (event, url) => { event.preventDefault(); if (url.startsWith("rowboat-toast://")) { @@ -139,26 +195,19 @@ export class MeetingToastWindow { this.closeImmediate(); return; } - // Anything else (shouldn't happen with our own HTML) — ignore. }); win.once("ready-to-show", () => win.show()); - win.on("closed", () => { - if (this.win === win) this.win = null; - if (this.timer) { clearTimeout(this.timer); this.timer = null; } - }); + win.on("closed", () => { if (this.win === win) this.win = null; }); const html = buildToastHtml(payload); - // data: URL so we don't have to ship a separate file. Encoded so the - // protocol parser doesn't choke on quotes / hashes in the payload. win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); this.win = win; - this.timer = setTimeout(() => this.closeImmediate(), AUTO_DISMISS_MS); + // No auto-dismiss — persistent until X or CTA click (per spec). } closeImmediate(): void { - if (this.timer) { clearTimeout(this.timer); this.timer = null; } if (this.win && !this.win.isDestroyed()) this.win.close(); this.win = null; } From 5d8eecd3dc1807a3c135c8ade08e730e5ba739fe Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 27 May 2026 11:24:36 +0530 Subject: [PATCH 06/35] fix for mac --- .../src/meeting-detect/foreground-window.ts | 112 ++++++++++++++++-- .../src/meeting-detect/meeting-apps.test.ts | 25 ++++ .../main/src/meeting-detect/meeting-apps.ts | 6 +- 3 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts diff --git a/apps/x/apps/main/src/meeting-detect/foreground-window.ts b/apps/x/apps/main/src/meeting-detect/foreground-window.ts index e9f523d0..4f4ac579 100644 --- a/apps/x/apps/main/src/meeting-detect/foreground-window.ts +++ b/apps/x/apps/main/src/meeting-detect/foreground-window.ts @@ -11,16 +11,19 @@ export interface WindowSnapshot { } /** - * Best-effort look at currently-open window titles for a given executable. - * On Windows: `tasklist /v /fi "imagename eq "` — fast because it skips - * every system process. On macOS: AppleScript for the frontmost window. + * Best-effort look at currently-open window titles (and, on macOS, tab URLs) + * for a given executable. On Windows: `tasklist /v /fi "imagename eq "` — + * fast because it skips every system process. On macOS: AppleScript that + * enumerates every browser tab (URL + title) for Chromium-family browsers and + * Safari, falling back to the frontmost window title for everything else. * - * Pass the basename of the exe (e.g. "chrome.exe"). Returns null on failure; - * an empty title list means "process is running but no window has a title." + * Pass the basename of the exe (e.g. "chrome.exe") or the macOS process name. + * Returns null on failure; an empty title list means "process is running but no + * window/tab title is available." */ export async function getWindowSnapshot(executable?: string): Promise { if (process.platform === "win32") return getWindowSnapshotWindows(executable); - if (process.platform === "darwin") return getWindowSnapshotMacOS(); + if (process.platform === "darwin") return getWindowSnapshotMacOS(executable); return null; } @@ -63,10 +66,49 @@ function parseCsvLine(line: string): string[] { return out; } -// macOS via osascript — title of the frontmost window of the frontmost app. -// Requires Accessibility permission for the Electron app; without it, the -// `name of front window` lookup returns empty. -const MACOS_SCRIPT = ` +// Chromium-family browsers share Chrome's AppleScript dictionary (each tab +// exposes `URL` and `title`). Safari uses `name` for the tab title. Firefox and +// anything else expose no tab scripting, so they fall back to the frontmost +// window title. Keyed by a substring of the pmset process name. +const CHROMIUM_APPS: Record = { + "google chrome": "Google Chrome", + "brave browser": "Brave Browser", + "microsoft edge": "Microsoft Edge", + "vivaldi": "Vivaldi", + "opera": "Opera", + "arc": "Arc", +}; + +function browserApp(executable?: string): { app: string; titleProp: "title" | "name" } | null { + const e = (executable ?? "").toLowerCase(); + for (const [needle, app] of Object.entries(CHROMIUM_APPS)) { + if (e.includes(needle)) return { app, titleProp: "title" }; + } + if (e.includes("safari")) return { app: "Safari", titleProp: "name" }; + return null; +} + +// Walk every window/tab of a browser and emit "\n" per tab. We need +// ALL tabs, not just the frontmost: the user is often looking at another app +// (e.g. taking notes) while the Meet/Zoom/Teams tab sits in the background. +function tabEnumScript(app: string, titleProp: "title" | "name"): string { + return [ + `tell application "${app}"`, + ` set _out to ""`, + ` repeat with _w in windows`, + ` repeat with _t in tabs of _w`, + ` set _out to _out & (URL of _t) & linefeed & (${titleProp} of _t) & linefeed`, + ` end repeat`, + ` end repeat`, + ` return _out`, + `end tell`, + ].join("\n"); +} + +// Frontmost window title — needs Accessibility permission. Last-resort signal +// for Firefox/unknown browsers (no tab scripting) or when tab enumeration is +// blocked. +const FRONT_WINDOW_SCRIPT = ` tell application "System Events" set frontApp to first application process whose frontmost is true set appName to name of frontApp @@ -79,16 +121,60 @@ tell application "System Events" end tell `.trim(); -async function getWindowSnapshotMacOS(): Promise<WindowSnapshot | null> { +function isPermissionError(err: unknown): boolean { + // osascript denied by TCC: Automation (-1743) or Accessibility (-1719). + const msg = err instanceof Error ? `${err.message} ${(err as { stderr?: string }).stderr ?? ""}` : String(err); + return msg.includes("-1743") || msg.includes("-1719") || /not authoriz|not allowed/i.test(msg); +} + +async function getWindowSnapshotMacOS(executable?: string): Promise<WindowSnapshot | null> { + const browser = browserApp(executable); + if (browser) { + const tabs = await enumerateBrowserTabs(browser.app, browser.titleProp); + if (tabs && tabs.length > 0) return { titles: tabs }; + // Empty/blocked → fall through to the frontmost-window title below. + } + return frontmostWindowTitle(); +} + +async function enumerateBrowserTabs(app: string, titleProp: "title" | "name"): Promise<string[] | null> { try { - const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", MACOS_SCRIPT], { + const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", tabEnumScript(app, titleProp)], { + timeout: 5_000, + maxBuffer: 4 * 1024 * 1024, + }); + // Each tab contributed a URL line and a title line; both feed matchTitleOrUrl. + return stdout.split("\n").map((l) => l.trim()).filter(Boolean); + } catch (err) { + if (isPermissionError(err)) { + console.warn( + `[MeetingDetect] cannot read ${app} tabs — grant Automation permission in ` + + `System Settings → Privacy & Security → Automation (Rowboat → ${app}). Falling back to window title.`, + ); + } else { + console.error(`[MeetingDetect] tab enumeration (${app}) failed:`, err); + } + return null; + } +} + +async function frontmostWindowTitle(): Promise<WindowSnapshot | null> { + try { + const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", FRONT_WINDOW_SCRIPT], { timeout: 5_000, }); const [, ...titleParts] = stdout.trim().split("\n"); const title = titleParts.join("\n"); return { titles: title ? [title] : [] }; } catch (err) { - console.error("[MeetingDetect] window-snapshot (macOS) failed:", err); + if (isPermissionError(err)) { + console.warn( + "[MeetingDetect] cannot read the frontmost window title — grant Accessibility " + + "permission in System Settings → Privacy & Security → Accessibility (Rowboat).", + ); + } else { + console.error("[MeetingDetect] window-snapshot (macOS) failed:", err); + } return null; } } diff --git a/apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts b/apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts new file mode 100644 index 00000000..c0072509 --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/meeting-apps.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { classifyExecutable } from "./meeting-apps.js"; + +describe("classifyExecutable", () => { + it("classifies Zoom on both platforms", () => { + expect(classifyExecutable("Zoom.exe")).toBe("zoom"); // Windows + expect(classifyExecutable("zoom.us")).toBe("zoom"); // macOS pmset name + }); + + it("classifies the new Teams client by its macOS/Windows process name", () => { + expect(classifyExecutable("MSTeams")).toBe("teams"); // macOS pmset name + expect(classifyExecutable("ms-teams.exe")).toBe("teams"); // Windows + expect(classifyExecutable("Microsoft Teams")).toBe("teams"); // classic + }); + + it("classifies browsers as the browser kind", () => { + expect(classifyExecutable("Google Chrome")).toBe("browser"); + expect(classifyExecutable("Safari")).toBe("browser"); + }); + + it("returns unknown for unrelated processes", () => { + expect(classifyExecutable("Finder")).toBe("unknown"); + expect(classifyExecutable("WindowServer")).toBe("unknown"); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/meeting-apps.ts b/apps/x/apps/main/src/meeting-detect/meeting-apps.ts index a18dec0f..fc1b35b4 100644 --- a/apps/x/apps/main/src/meeting-detect/meeting-apps.ts +++ b/apps/x/apps/main/src/meeting-detect/meeting-apps.ts @@ -7,13 +7,15 @@ export type MeetingAppKind = "zoom" | "teams" | "slack" | "discord" | "webex" | interface AppRule { kind: MeetingAppKind; // Case-insensitive substring match against the executable path / basename - // (Windows: full exe path from registry; macOS: command name from lsof). + // (Windows: full exe path from registry; macOS: process name from pmset). match: string[]; } const RULES: AppRule[] = [ { kind: "zoom", match: ["zoom.exe", "zoom.us", "cpthost.exe"] }, - { kind: "teams", match: ["ms-teams.exe", "teams.exe", "microsoft teams"] }, + // "msteams" covers the current macOS/Windows process name (the new Teams ships + // as MSTeams); the others cover the classic client and the AUMID/bundle forms. + { kind: "teams", match: ["ms-teams.exe", "teams.exe", "msteams", "microsoft teams"] }, { kind: "slack", match: ["slack.exe", "slack helper", "slack"] }, { kind: "discord", match: ["discord.exe", "discord"] }, { kind: "webex", match: ["webex.exe", "ciscowebex", "webexmta"] }, From f78f1380ebda999917c6bf0749b6bcbbfd1b0993 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 27 May 2026 23:17:25 +0530 Subject: [PATCH 07/35] added expand buttons for middle and side pane and fixed issue with moving new chat to side pane --- apps/x/apps/renderer/src/App.tsx | 24 ++++++--- .../renderer/src/components/chat-sidebar.tsx | 53 ++++++------------- 2 files changed, 32 insertions(+), 45 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a35fbca7..f7a50074 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, X } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, 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'; @@ -3391,8 +3391,10 @@ function App() { setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay]) - const handleCloseFullScreenChat = useCallback(() => { + const handleCloseFullScreenChat = useCallback((): boolean => { + let restored = false if (expandedFrom) { + restored = true if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) @@ -3434,10 +3436,16 @@ function App() { setIsSuggestedTopicsOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) setSelectedPath(expandedFrom.path) + } else { + // expandedFrom was captured from a view this restorer doesn't track + // (e.g. Home): there's nothing to re-open, so report it and let the + // caller fall back instead of leaving a blank full-screen chat. + restored = false } setExpandedFrom(null) setIsRightPaneMaximized(false) } + return restored }, [expandedFrom]) const currentViewState = React.useMemo<ViewState>(() => { @@ -3885,12 +3893,13 @@ function App() { const pushChatToSidePane = useCallback(() => { setIsRightPaneMaximized(false) setIsChatSidebarOpen(true) - if (expandedFrom) { - handleCloseFullScreenChat() - } else { + // Restore the view we expanded from; if there was nothing to restore + // (e.g. the chat was started fresh from Home), fall back to Home so a + // single click always docks the chat instead of needing two. + if (!handleCloseFullScreenChat()) { void navigateToView({ type: 'home' }) } - }, [expandedFrom, handleCloseFullScreenChat, navigateToView]) + }, [handleCloseFullScreenChat, navigateToView]) const navigateBack = useCallback(async () => { const { back, forward } = historyRef.current @@ -5375,7 +5384,7 @@ function App() { : (viewOpen && !isChatSidebarOpen) ? { onClick: openChatSidePane, icon: <MessageSquare className="size-5" />, label: 'Open chat' } : (viewOpen && isChatSidebarOpen && !isRightPaneMaximized) - ? { onClick: toggleRightPaneMaximize, icon: <X className="size-5" />, label: 'Expand chat' } + ? { onClick: () => setIsChatSidebarOpen(false), icon: <ArrowRight className="size-5" />, label: 'Expand pane' } : null return ( <Tooltip> @@ -5899,7 +5908,6 @@ function App() { }} onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} onOpenFullScreen={toggleRightPaneMaximize} - onCloseChat={() => { setIsRightPaneMaximized(false); setIsChatSidebarOpen(false) }} conversation={conversation} currentAssistantMessage={currentAssistantMessage} chatTabStates={chatViewStateByTab} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 0c054bb7..9197753b 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowRight, X } from 'lucide-react' +import { ArrowLeft, ArrowRight } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -125,7 +125,6 @@ interface ChatSidebarProps { onSelectRun?: (runId: string) => void onOpenChatHistory?: () => void onOpenFullScreen?: () => void - onCloseChat?: () => void conversation: ConversationItem[] currentAssistantMessage: string chatTabStates?: Record<string, ChatTabViewState> @@ -183,7 +182,6 @@ export function ChatSidebar({ onSelectRun, onOpenChatHistory, onOpenFullScreen, - onCloseChat, conversation, currentAssistantMessage, chatTabStates = {}, @@ -515,40 +513,21 @@ export function ChatSidebar({ onSelectRun={onSelectRun} onOpenChatHistory={onOpenChatHistory} /> - {isMaximized ? ( - onOpenFullScreen && ( - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - onClick={onOpenFullScreen} - className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground" - aria-label="Dock chat to side pane" - > - <ArrowRight className="size-5" /> - </Button> - </TooltipTrigger> - <TooltipContent side="bottom">Dock to side pane</TooltipContent> - </Tooltip> - ) - ) : ( - onCloseChat && ( - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - onClick={onCloseChat} - className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground" - aria-label="Close chat" - > - <X className="size-5" /> - </Button> - </TooltipTrigger> - <TooltipContent side="bottom">Close chat</TooltipContent> - </Tooltip> - ) + {onOpenFullScreen && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + onClick={onOpenFullScreen} + className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground" + aria-label={isMaximized ? 'Dock chat to side pane' : 'Expand chat'} + > + {isMaximized ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />} + </Button> + </TooltipTrigger> + <TooltipContent side="bottom">{isMaximized ? 'Dock to side pane' : 'Expand chat'}</TooltipContent> + </Tooltip> )} </header> From 2f9ce051c0fddaaef531fc4c03c8bbb9a55efa87 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 27 May 2026 23:17:47 +0530 Subject: [PATCH 08/35] Add chat log download menu --- apps/x/apps/main/src/ipc.ts | 30 ++++++++++ apps/x/apps/renderer/src/App.tsx | 56 ++++++++++++++++++- .../renderer/src/components/chat-sidebar.tsx | 56 ++++++++++++++++++- apps/x/packages/shared/src/ipc.ts | 9 +++ 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 78f8b55e..638af656 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -8,6 +8,7 @@ import { listProviders, } from './oauth-handler.js'; import { watcher as watcherCore, workspace } from '@x/core'; +import { WorkDir } from '@x/core/dist/config/config.js'; import { workspace as workspaceShared } from '@x/shared'; import * as mcpCore from '@x/core/dist/mcp/mcp.js'; import * as runsCore from '@x/core/dist/runs/runs.js'; @@ -531,6 +532,35 @@ export function setupIpcHandlers() { await runsCore.deleteRun(args.runId); return { success: true }; }, + 'runs:downloadLog': async (event, args) => { + const runFileName = `${args.runId}.jsonl`; + if (path.basename(runFileName) !== runFileName) { + return { success: false, error: 'Invalid run id' }; + } + + const sourcePath = path.join(WorkDir, 'runs', runFileName); + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showSaveDialog(win!, { + defaultPath: `${runFileName}.log`, + filters: [ + { name: 'Chat Log', extensions: ['log'] }, + { name: 'JSONL', extensions: ['jsonl'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + + if (result.canceled || !result.filePath) { + return { success: false }; + } + + try { + await fs.copyFile(sourcePath, result.filePath); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to download chat log'; + return { success: false, error: message }; + } + }, 'models:list': async () => { if (await isSignedIn()) { return await listGatewayModels(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index e6c050b3..cd668fa2 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, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react'; +import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, SquarePen, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -61,6 +61,12 @@ import { } from "@/components/ui/sidebar" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' @@ -4662,6 +4668,25 @@ function App() { return chatViewStateByTab[tabId] ?? emptyChatTabState }, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState]) const hasConversation = activeChatTabState.conversation.length > 0 || activeChatTabState.currentAssistantMessage + const activeRunIdForDownload = activeChatTabState.runId + const handleDownloadActiveChatLog = useCallback(async () => { + if (!activeRunIdForDownload) { + toast.error('No chat log available yet') + return + } + + try { + const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunIdForDownload }) + if (result.success) { + toast.success('Chat log saved') + } else if (result.error) { + toast.error(result.error) + } + } catch (err) { + console.error('Download chat log failed:', err) + toast.error('Failed to download chat log') + } + }, [activeRunIdForDownload]) const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null @@ -4885,6 +4910,35 @@ function App() { <TooltipContent side="bottom">New chat tab</TooltipContent> </Tooltip> )} + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && ( + <DropdownMenu> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <button + type="button" + className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0" + aria-label="Chat options" + > + <MoreHorizontal className="size-5" /> + </button> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="bottom">Chat options</TooltipContent> + </Tooltip> + <DropdownMenuContent align="end" className="min-w-48"> + <DropdownMenuItem + disabled={!activeRunIdForDownload} + onSelect={() => { + void handleDownloadActiveChatLog() + }} + > + <Bug className="size-4" /> + Download chat log + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + )} {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && ( <Tooltip> <TooltipTrigger asChild> diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 06680652..aeb0c167 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,9 +1,16 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Maximize2, Minimize2, SquarePen } from 'lucide-react' +import { Bug, Maximize2, Minimize2, MoreHorizontal, SquarePen } from 'lucide-react' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { Conversation, ConversationContent, @@ -381,6 +388,25 @@ export function ChatSidebar({ return chatTabStates[tabId] ?? emptyTabState }, [activeChatTabId, activeTabState, chatTabStates, emptyTabState]) const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage) + const activeRunId = activeTabState.runId + const handleDownloadChatLog = useCallback(async () => { + if (!activeRunId) { + toast.error('No chat log available yet') + return + } + + try { + const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId }) + if (result.success) { + toast.success('Chat log saved') + } else if (result.error) { + toast.error(result.error) + } + } catch (err) { + console.error('Download chat log failed:', err) + toast.error('Failed to download chat log') + } + }, [activeRunId]) const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { @@ -585,6 +611,34 @@ export function ChatSidebar({ </TooltipTrigger> <TooltipContent side="bottom">New chat tab</TooltipContent> </Tooltip> + <DropdownMenu> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="icon" + className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground" + aria-label="Chat options" + > + <MoreHorizontal className="size-5" /> + </Button> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="bottom">Chat options</TooltipContent> + </Tooltip> + <DropdownMenuContent align="end" className="min-w-48"> + <DropdownMenuItem + disabled={!activeRunId} + onSelect={() => { + void handleDownloadChatLog() + }} + > + <Bug className="size-4" /> + Download chat log + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> {onOpenFullScreen && ( <Tooltip> <TooltipTrigger asChild> diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 37cf41e7..b1f1e80c 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -246,6 +246,15 @@ const ipcSchemas = { }), res: z.object({ success: z.boolean() }), }, + 'runs:downloadLog': { + req: z.object({ + runId: z.string().min(1), + }), + res: z.object({ + success: z.boolean(), + error: z.string().optional(), + }), + }, 'runs:events': { req: z.null(), res: z.null(), From 0af48ecd4ad0ec22757215aba09a4af13383529a Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 00:33:21 +0530 Subject: [PATCH 09/35] new knowledge view --- apps/x/apps/renderer/src/App.tsx | 6 +- .../src/components/knowledge-view.tsx | 803 +++++++++++++----- 2 files changed, 597 insertions(+), 212 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f758e180..dd671eda 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -4554,10 +4554,8 @@ function App() { void navigateToView({ type: 'workspace', path }) }, openKnowledgeView: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) { - setIsChatSidebarOpen(false) - setIsRightPaneMaximized(false) - } + // Open in the middle pane without touching the chat sidebar — leave it + // open or closed exactly as the user had it (matches Email/Meetings). void navigateToView({ type: 'knowledge-view' }) }, createWorkspace: async (name: string): Promise<string> => { diff --git a/apps/x/apps/renderer/src/components/knowledge-view.tsx b/apps/x/apps/renderer/src/components/knowledge-view.tsx index a757a34c..c7674fb4 100644 --- a/apps/x/apps/renderer/src/components/knowledge-view.tsx +++ b/apps/x/apps/renderer/src/components/knowledge-view.tsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { + ArrowLeft, ChevronRight, Copy, ExternalLink, - File as FileIcon, FilePlus, - Folder as FolderIcon, + FileText, FolderOpen, FolderPlus, Network, @@ -56,9 +56,48 @@ type KnowledgeViewProps = { onVoiceNoteCreated?: (path: string) => void } -type FlatRow = { - node: TreeNode - depth: number +// Folders that have their own dedicated destinations elsewhere in the app. +const HIDDEN_PATHS = new Set(['knowledge/Meetings', 'knowledge/Workspace']) + +// Theme-aware accent palette for folder avatars — colored letter on a faint +// tint of the same hue. Mirrors the design's six-colour rotation. +const AVATAR_PALETTE = [ + 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400', + 'bg-violet-500/10 text-violet-600 dark:text-violet-400', + 'bg-amber-500/10 text-amber-600 dark:text-amber-400', + 'bg-rose-500/10 text-rose-600 dark:text-rose-400', + 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + 'bg-sky-500/10 text-sky-600 dark:text-sky-400', +] as const + +function avatarClass(name: string): string { + let hash = 0 + for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0 + return AVATAR_PALETTE[hash % AVATAR_PALETTE.length] +} + +function isMarkdown(node: TreeNode): boolean { + return node.kind === 'file' && node.name.toLowerCase().endsWith('.md') +} + +// All markdown notes within a node (recurses into subfolders). +function collectNotes(node: TreeNode): TreeNode[] { + if (node.kind === 'file') return isMarkdown(node) ? [node] : [] + const out: TreeNode[] = [] + for (const child of node.children ?? []) out.push(...collectNotes(child)) + return out +} + +function recentNotes(node: TreeNode, limit: number): TreeNode[] { + return collectNotes(node) + .sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0)) + .slice(0, limit) +} + +function latestMtime(node: TreeNode): number { + let max = node.stat?.mtimeMs ?? 0 + for (const child of node.children ?? []) max = Math.max(max, latestMtime(child)) + return max } function sortNodes(nodes: TreeNode[]): TreeNode[] { @@ -68,23 +107,22 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] { }) } -function flatten( - nodes: TreeNode[], - expanded: Set<string>, - depth: number, - out: FlatRow[], -): void { - for (const node of sortNodes(nodes)) { - out.push({ node, depth }) - if (node.kind === 'dir' && expanded.has(node.path) && node.children?.length) { - flatten(node.children, expanded, depth + 1, out) +function findNode(nodes: TreeNode[], path: string): TreeNode | null { + for (const node of nodes) { + if (node.path === path) return node + if (node.children) { + const found = findNode(node.children, path) + if (found) return found } } + return null } function formatModified(mtimeMs?: number): string { if (!mtimeMs) return '' - return formatRelativeTime(new Date(mtimeMs).toISOString()) + const rel = formatRelativeTime(new Date(mtimeMs).toISOString()) + if (!rel || rel === 'just now') return rel + return `${rel} ago` } function getFileManagerName(): string { @@ -96,15 +134,10 @@ function getFileManagerName(): string { } function displayName(node: TreeNode): string { - if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) { - return node.name.slice(0, -3) - } + if (isMarkdown(node)) return node.name.slice(0, -3) return node.name } -const INDENT_PX = 16 -const ROW_PADDING_PX = 12 - export function KnowledgeView({ tree, actions, @@ -114,191 +147,594 @@ export function KnowledgeView({ onOpenBases, onVoiceNoteCreated, }: KnowledgeViewProps) { - const [expanded, setExpanded] = useState<Set<string>>(new Set()) + // null = root (folder overview); otherwise the path of the folder being browsed. + const [folderPath, setFolderPath] = useState<string | null>(null) const [renameTarget, setRenameTarget] = useState<string | null>(null) - const rows = useMemo<FlatRow[]>(() => { - const out: FlatRow[] = [] - // Meetings and Workspace have dedicated destinations, so hide them here. - const visible = tree.filter((n) => n.path !== 'knowledge/Meetings' && n.path !== 'knowledge/Workspace') - flatten(visible, expanded, 0, out) - return out - }, [tree, expanded]) - - const handleRowClick = useCallback( - (node: TreeNode) => { - if (node.kind === 'dir') { - setExpanded((prev) => { - const next = new Set(prev) - if (next.has(node.path)) next.delete(node.path) - else next.add(node.path) - return next - }) - } else { - onOpenNote(node.path) - } - }, - [onOpenNote], + const topLevel = useMemo( + () => tree.filter((n) => !HIDDEN_PATHS.has(n.path)), + [tree], ) + const folders = useMemo( + () => sortNodes(topLevel.filter((n) => n.kind === 'dir')), + [topLevel], + ) + const looseNotes = useMemo( + () => sortNodes(topLevel.filter((n) => isMarkdown(n))), + [topLevel], + ) + + const totalNotes = useMemo( + () => topLevel.reduce((sum, n) => sum + collectNotes(n).length, 0), + [topLevel], + ) + + const openFolder = useCallback((path: string) => setFolderPath(path), []) + + // When the open folder no longer exists (deleted/renamed externally), fall + // back to the root overview rather than holding a dangling drill-down. + const currentFolder = folderPath ? findNode(tree, folderPath) : null + return ( <div className="flex h-full flex-col overflow-hidden"> - <div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6"> - <h1 className="text-2xl font-bold tracking-tight">Notes</h1> - <div className="flex items-center gap-2"> + <div className="shrink-0 flex items-start justify-between gap-4 border-b border-border px-8 py-6"> + <div className="min-w-0"> + <h1 className="text-2xl font-bold tracking-tight">Notes</h1> + <p className="mt-1 text-sm text-muted-foreground"> + {totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '} + {folders.length === 1 ? 'folder' : 'folders'} + </p> + </div> + <div className="flex shrink-0 items-center gap-2"> + <VoiceNoteButton onNoteCreated={onVoiceNoteCreated} /> + <SecondaryButton icon={SearchIcon} label="Search" onClick={onOpenSearch} /> + <SecondaryButton icon={Network} label="Graph" onClick={onOpenGraph} /> <button type="button" - onClick={() => actions.createNote()} - className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent" + onClick={() => actions.createNote(currentFolder?.path)} + className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" > <FilePlus className="size-4" /> <span>New note</span> </button> - <button - type="button" - onClick={async () => { - try { - const path = await actions.createFolder() - setRenameTarget(path) - } catch { /* ignore */ } - }} - className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent" - > - <FolderPlus className="size-4" /> - <span>New folder</span> - </button> - <VoiceNoteButton onNoteCreated={onVoiceNoteCreated} /> - <button - type="button" - onClick={onOpenSearch} - className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent" - > - <SearchIcon className="size-4" /> - <span>Search</span> - </button> - <button - type="button" - onClick={onOpenBases} - className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent" - > - <Table2 className="size-4" /> - <span>Bases</span> - </button> - <button - type="button" - onClick={onOpenGraph} - className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent" - > - <Network className="size-4" /> - <span>Graph view</span> - </button> - <button - type="button" - onClick={() => actions.revealInFileManager('knowledge', true)} - className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent" - > - <FolderOpen className="size-4" /> - <span>Open in {getFileManagerName()}</span> - </button> </div> </div> <div className="flex-1 overflow-y-auto"> - <div className="min-w-[480px]"> - <div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground"> - <div className="flex-1">Page name</div> - <div className="w-32 shrink-0">Modified</div> - </div> - - {rows.length === 0 ? ( - <div className="px-6 py-8 text-sm text-muted-foreground">No pages yet.</div> + <div className="mx-auto w-full max-w-3xl px-8 py-6"> + {currentFolder ? ( + <FolderDetail + folder={currentFolder} + actions={actions} + renameTarget={renameTarget} + onRequestRename={setRenameTarget} + onClearRename={() => setRenameTarget(null)} + onNavigate={setFolderPath} + onOpenFolder={openFolder} + onOpenNote={onOpenNote} + /> ) : ( - rows.map(({ node, depth }) => ( - <KnowledgeRow - key={node.path} - node={node} - depth={depth} - isExpanded={expanded.has(node.path)} - actions={actions} - renameActive={renameTarget === node.path} - onRequestRename={(p) => setRenameTarget(p)} - onClearRename={() => setRenameTarget(null)} - onClick={handleRowClick} - /> - )) + <> + <SectionHeader label={`Folders · ${folders.length}`} aside="Sorted by name" /> + {folders.length === 0 ? ( + <EmptyState text="No folders yet." /> + ) : ( + <div className="overflow-hidden rounded-xl border border-border"> + {folders.map((node, i) => ( + <div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}> + <FolderCard + node={node} + actions={actions} + renameTarget={renameTarget} + onRequestRename={setRenameTarget} + onClearRename={() => setRenameTarget(null)} + onOpenFolder={openFolder} + onOpenNote={onOpenNote} + /> + </div> + ))} + </div> + )} + + {looseNotes.length > 0 && ( + <div className="mt-8"> + <SectionHeader label={`Loose notes · ${looseNotes.length}`} /> + <div className="overflow-hidden rounded-xl border border-border"> + {looseNotes.map((node, i) => ( + <div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}> + <ItemRow + node={node} + actions={actions} + renameTarget={renameTarget} + onRequestRename={setRenameTarget} + onClearRename={() => setRenameTarget(null)} + onOpenFolder={openFolder} + onOpenNote={onOpenNote} + /> + </div> + ))} + </div> + </div> + )} + </> )} + + <QuickActions + actions={actions} + currentFolder={currentFolder} + onOpenBases={onOpenBases} + onFolderCreated={setRenameTarget} + /> </div> </div> </div> ) } -function KnowledgeRow({ - node, - depth, - isExpanded, +function QuickActions({ actions, - renameActive, - onRequestRename, - onClearRename, + currentFolder, + onOpenBases, + onFolderCreated, +}: { + actions: KnowledgeViewActions + currentFolder: TreeNode | null + onOpenBases: () => void + onFolderCreated: (path: string) => void +}) { + // Inside a folder these target that folder; at the root they target knowledge/. + const parent = currentFolder?.path + return ( + <div className="mt-8"> + <SectionHeader label="Quick actions" /> + <div className="flex flex-wrap gap-2"> + <QuickAction icon={FilePlus} label="New note" onClick={() => actions.createNote(parent)} /> + <QuickAction + icon={FolderPlus} + label="New folder" + onClick={async () => { + try { + const path = await actions.createFolder(parent) + onFolderCreated(path) + } catch { /* ignore */ } + }} + /> + <QuickAction icon={Table2} label="Open as base" onClick={onOpenBases} /> + <QuickAction + icon={FolderOpen} + label={`Reveal in ${getFileManagerName()}`} + onClick={() => actions.revealInFileManager(parent ?? 'knowledge', true)} + /> + </div> + </div> + ) +} + +function SecondaryButton({ + icon: Icon, + label, onClick, +}: { + icon: typeof SearchIcon + label: string + onClick: () => void +}) { + return ( + <button + type="button" + onClick={onClick} + className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent" + > + <Icon className="size-4" /> + <span>{label}</span> + </button> + ) +} + +function QuickAction({ + icon: Icon, + label, + onClick, +}: { + icon: typeof FilePlus + label: string + onClick: () => void +}) { + return ( + <button + type="button" + onClick={onClick} + className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent" + > + <Icon className="size-4 text-muted-foreground" /> + <span>{label}</span> + </button> + ) +} + +function SectionHeader({ label, aside }: { label: string; aside?: string }) { + return ( + <div className="mb-2.5 flex items-center justify-between"> + <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> + {label} + </span> + {aside && <span className="text-xs text-muted-foreground">{aside}</span>} + </div> + ) +} + +function EmptyState({ text }: { text: string }) { + return ( + <div className="rounded-xl border border-dashed border-border px-6 py-10 text-center text-sm text-muted-foreground"> + {text} + </div> + ) +} + +function FolderAvatar({ name, className }: { name: string; className?: string }) { + return ( + <div + className={cn( + 'flex size-8 shrink-0 items-center justify-center rounded-md text-[13px] font-bold', + avatarClass(name), + className, + )} + > + {name.charAt(0).toUpperCase() || '?'} + </div> + ) +} + +function FolderCard({ + node, + actions, + renameTarget, + onRequestRename, + onClearRename, + onOpenFolder, + onOpenNote, }: { node: TreeNode - depth: number - isExpanded: boolean actions: KnowledgeViewActions - renameActive: boolean + renameTarget: string | null onRequestRename: (path: string) => void onClearRename: () => void - onClick: (node: TreeNode) => void + onOpenFolder: (path: string) => void + onOpenNote: (path: string) => void +}) { + const count = useMemo(() => collectNotes(node).length, [node]) + const peek = useMemo(() => recentNotes(node, 3), [node]) + const modified = formatModified(latestMtime(node)) + const renameActive = renameTarget === node.path + + const card = ( + <div + role="button" + tabIndex={0} + onClick={() => onOpenFolder(node.path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpenFolder(node.path) + } + }} + className="group flex w-full cursor-pointer items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50" + > + <FolderAvatar name={node.name} className="mt-0.5" /> + <div className="min-w-0 flex-1"> + {renameActive ? ( + <RenameField + initial={node.name} + isDir + path={node.path} + actions={actions} + onDone={onClearRename} + /> + ) : ( + <span className="block truncate text-sm font-semibold text-foreground"> + {node.name} + </span> + )} + <div className="mt-0.5 text-xs text-muted-foreground"> + {count} {count === 1 ? 'note' : 'notes'} + </div> + {peek.length > 0 && ( + <div className="mt-2 flex flex-wrap gap-1.5"> + {peek.map((n) => ( + <button + key={n.path} + type="button" + onClick={(e) => { + e.stopPropagation() + onOpenNote(n.path) + }} + className="max-w-[200px] truncate rounded-full border border-border/60 bg-muted px-2.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + > + {displayName(n)} + </button> + ))} + </div> + )} + </div> + <div className="flex shrink-0 items-center gap-2 pt-1"> + <span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap"> + {modified} + </span> + <ChevronRight className="size-4 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" /> + </div> + </div> + ) + + return ( + <RowContextMenu node={node} actions={actions} onRequestRename={onRequestRename}> + {card} + </RowContextMenu> + ) +} + +function FolderDetail({ + folder, + actions, + renameTarget, + onRequestRename, + onClearRename, + onNavigate, + onOpenFolder, + onOpenNote, +}: { + folder: TreeNode + actions: KnowledgeViewActions + renameTarget: string | null + onRequestRename: (path: string) => void + onClearRename: () => void + onNavigate: (path: string | null) => void + onOpenFolder: (path: string) => void + onOpenNote: (path: string) => void +}) { + const items = useMemo(() => sortNodes(folder.children ?? []), [folder]) + + // Breadcrumb segments from "knowledge/A/B" → [{ name: 'A', path }, ...]. + const crumbs = useMemo(() => { + const rel = folder.path.startsWith('knowledge/') + ? folder.path.slice('knowledge/'.length) + : folder.path + const parts = rel.split('/').filter(Boolean) + const out: { name: string; path: string }[] = [] + let acc = 'knowledge' + for (const part of parts) { + acc = `${acc}/${part}` + out.push({ name: part, path: acc }) + } + return out + }, [folder.path]) + + return ( + <> + <div className="mb-4 flex min-w-0 items-center gap-1.5 text-sm"> + <button + type="button" + onClick={() => { + const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null + onNavigate(parent) + }} + className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + aria-label="Back" + > + <ArrowLeft className="size-4" /> + </button> + <button + type="button" + onClick={() => onNavigate(null)} + className="rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + > + Notes + </button> + {crumbs.map((c, i) => ( + <span key={c.path} className="flex min-w-0 items-center gap-1.5"> + <ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" /> + {i === crumbs.length - 1 ? ( + <span className="truncate font-medium text-foreground">{c.name}</span> + ) : ( + <button + type="button" + onClick={() => onNavigate(c.path)} + className="truncate rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + > + {c.name} + </button> + )} + </span> + ))} + </div> + + <SectionHeader label={`${items.length} ${items.length === 1 ? 'item' : 'items'}`} /> + {items.length === 0 ? ( + <EmptyState text="This folder is empty." /> + ) : ( + <div className="overflow-hidden rounded-xl border border-border"> + {items.map((node, i) => ( + <div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}> + <ItemRow + node={node} + actions={actions} + renameTarget={renameTarget} + onRequestRename={onRequestRename} + onClearRename={onClearRename} + onOpenFolder={onOpenFolder} + onOpenNote={onOpenNote} + /> + </div> + ))} + </div> + )} + </> + ) +} + +function ItemRow({ + node, + actions, + renameTarget, + onRequestRename, + onClearRename, + onOpenFolder, + onOpenNote, +}: { + node: TreeNode + actions: KnowledgeViewActions + renameTarget: string | null + onRequestRename: (path: string) => void + onClearRename: () => void + onOpenFolder: (path: string) => void + onOpenNote: (path: string) => void }) { const isDir = node.kind === 'dir' - const Icon = isDir ? FolderIcon : FileIcon - const paddingLeft = ROW_PADDING_PX + depth * INDENT_PX - const baseName = displayName(node) + const renameActive = renameTarget === node.path + const modified = formatModified(isDir ? latestMtime(node) : node.stat?.mtimeMs) + const count = useMemo(() => (isDir ? collectNotes(node).length : 0), [isDir, node]) - const [newName, setNewName] = useState(baseName) + const handleOpen = useCallback(() => { + if (isDir) onOpenFolder(node.path) + else onOpenNote(node.path) + }, [isDir, node.path, onOpenFolder, onOpenNote]) + + const row = ( + <div + role="button" + tabIndex={0} + onClick={handleOpen} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleOpen() + } + }} + className="group flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50" + > + {isDir ? ( + <FolderAvatar name={node.name} /> + ) : ( + <div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"> + <FileText className="size-4" /> + </div> + )} + <div className="min-w-0 flex-1"> + {renameActive ? ( + <RenameField + initial={displayName(node)} + isDir={isDir} + path={node.path} + actions={actions} + onDone={onClearRename} + /> + ) : ( + <span className="block truncate text-sm text-foreground">{displayName(node)}</span> + )} + {isDir && ( + <div className="mt-0.5 text-xs text-muted-foreground"> + {count} {count === 1 ? 'note' : 'notes'} + </div> + )} + </div> + <div className="flex shrink-0 items-center gap-2"> + <span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap"> + {modified} + </span> + {isDir && ( + <ChevronRight className="size-4 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" /> + )} + </div> + </div> + ) + + return ( + <RowContextMenu node={node} actions={actions} onRequestRename={onRequestRename}> + {row} + </RowContextMenu> + ) +} + +function RenameField({ + initial, + isDir, + path, + actions, + onDone, +}: { + initial: string + isDir: boolean + path: string + actions: KnowledgeViewActions + onDone: () => void +}) { + const [value, setValue] = useState(initial) const inputRef = useRef<HTMLInputElement | null>(null) const isSubmittingRef = useRef(false) useEffect(() => { - if (renameActive) { - setNewName(baseName) - isSubmittingRef.current = false - // focus on next tick after mount - requestAnimationFrame(() => { - inputRef.current?.focus() - inputRef.current?.select() - }) - } - }, [renameActive, baseName]) + requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + }, []) - const handleRenameSubmit = useCallback(async () => { + const submit = useCallback(async () => { if (isSubmittingRef.current) return isSubmittingRef.current = true - const trimmed = newName.trim() - if (trimmed && trimmed !== baseName) { + const trimmed = value.trim() + if (trimmed && trimmed !== initial) { try { - await actions.rename(node.path, trimmed, isDir) + await actions.rename(path, trimmed, isDir) toast('Renamed successfully', 'success') } catch { toast('Failed to rename', 'error') } } - onClearRename() - setTimeout(() => { - isSubmittingRef.current = false - }, 100) - }, [actions, baseName, isDir, newName, node.path, onClearRename]) + onDone() + }, [actions, initial, isDir, onDone, path, value]) - const cancelRename = useCallback(() => { + const cancel = useCallback(() => { isSubmittingRef.current = true - setNewName(baseName) - onClearRename() - setTimeout(() => { - isSubmittingRef.current = false - }, 100) - }, [baseName, onClearRename]) + onDone() + }, [onDone]) + + return ( + <Input + ref={inputRef} + value={value} + onChange={(e) => setValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') { + e.preventDefault() + void submit() + } else if (e.key === 'Escape') { + e.preventDefault() + cancel() + } + }} + onBlur={() => { + if (!isSubmittingRef.current) void submit() + }} + className="h-7 text-sm" + /> + ) +} + +function RowContextMenu({ + node, + actions, + onRequestRename, + children, +}: { + node: TreeNode + actions: KnowledgeViewActions + onRequestRename: (path: string) => void + children: React.ReactNode +}) { + const isDir = node.kind === 'dir' const handleDelete = useCallback(async () => { try { @@ -314,58 +750,9 @@ function KnowledgeRow({ toast('Path copied', 'success') }, [actions, node.path]) - const row = ( - <button - type="button" - onClick={() => onClick(node)} - className="group flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent" - > - <div className="flex flex-1 items-center gap-1.5 min-w-0" style={{ paddingLeft }}> - <span className="inline-flex w-4 shrink-0 items-center justify-center text-muted-foreground"> - {isDir ? ( - <ChevronRight - className={cn( - 'size-3.5 transition-transform', - isExpanded && 'rotate-90', - )} - /> - ) : null} - </span> - <Icon className="size-4 shrink-0 text-muted-foreground" /> - {renameActive ? ( - <Input - ref={inputRef} - value={newName} - onChange={(e) => setNewName(e.target.value)} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - e.stopPropagation() - if (e.key === 'Enter') { - e.preventDefault() - void handleRenameSubmit() - } else if (e.key === 'Escape') { - e.preventDefault() - cancelRename() - } - }} - onBlur={() => { - if (!isSubmittingRef.current) void handleRenameSubmit() - }} - className="h-6 text-sm flex-1" - /> - ) : ( - <span className="min-w-0 truncate">{baseName}</span> - )} - </div> - <div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums"> - {formatModified(node.stat?.mtimeMs)} - </div> - </button> - ) - return ( <ContextMenu> - <ContextMenuTrigger asChild>{row}</ContextMenuTrigger> + <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> <ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}> {isDir && ( <> From 373d1ee92be0523d99e36710163708d9b706ba35 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 00:33:38 +0530 Subject: [PATCH 10/35] search defaults to knowledge --- apps/x/apps/renderer/src/App.tsx | 9 ++++++--- .../x/apps/renderer/src/components/search-dialog.tsx | 12 ++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index dd671eda..690c6790 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -80,7 +80,7 @@ import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal' -import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog' +import { CommandPalette, type CommandPaletteMention, type SearchType } from '@/components/search-dialog' import { LiveNoteSidebar } from '@/components/live-note-sidebar' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { BrowserPane } from '@/components/browser-pane/BrowserPane' @@ -1253,6 +1253,8 @@ function App() { // Search state const [isSearchOpen, setIsSearchOpen] = useState(false) + // Optional scope override for the next time search opens (cleared on close). + const [searchDefaultScope, setSearchDefaultScope] = useState<SearchType | undefined>(undefined) // Background tasks state type BackgroundTaskItem = { @@ -5553,7 +5555,7 @@ function App() { }} onOpenNote={(path) => navigateToFile(path)} onOpenGraph={() => knowledgeActions.openGraph()} - onOpenSearch={() => setIsSearchOpen(true)} + onOpenSearch={() => { setSearchDefaultScope('knowledge'); setIsSearchOpen(true) }} onOpenBases={() => knowledgeActions.openBases()} onVoiceNoteCreated={handleVoiceNoteCreated} /> @@ -6016,7 +6018,8 @@ function App() { </div> <CommandPalette open={isSearchOpen} - onOpenChange={setIsSearchOpen} + onOpenChange={(o) => { setIsSearchOpen(o); if (!o) setSearchDefaultScope(undefined) }} + defaultScope={searchDefaultScope} onSelectFile={navigateToFile} onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }} /> diff --git a/apps/x/apps/renderer/src/components/search-dialog.tsx b/apps/x/apps/renderer/src/components/search-dialog.tsx index 56f0875a..4e50fa0b 100644 --- a/apps/x/apps/renderer/src/components/search-dialog.tsx +++ b/apps/x/apps/renderer/src/components/search-dialog.tsx @@ -21,7 +21,7 @@ interface SearchResult { path: string } -type SearchType = 'knowledge' | 'chat' +export type SearchType = 'knowledge' | 'chat' function activeTabToTypes(section: ActiveSection): SearchType[] { if (section === 'knowledge') return ['knowledge'] @@ -46,6 +46,9 @@ interface CommandPaletteProps { onOpenChange: (open: boolean) => void onSelectFile: (path: string) => void onSelectRun: (runId: string) => void + // Overrides the sidebar-section default for the initial scope (e.g. the + // knowledge view opens search scoped to knowledge). + defaultScope?: SearchType } export function CommandPalette({ @@ -53,6 +56,7 @@ export function CommandPalette({ onOpenChange, onSelectFile, onSelectRun, + defaultScope, }: CommandPaletteProps) { const { activeSection } = useSidebarSection() const searchInputRef = useRef<HTMLInputElement>(null) @@ -61,7 +65,7 @@ export function CommandPalette({ const [results, setResults] = useState<SearchResult[]>([]) const [isSearching, setIsSearching] = useState(false) const [activeTypes, setActiveTypes] = useState<Set<SearchType>>( - () => new Set(activeTabToTypes(activeSection)) + () => new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection)) ) const debouncedQuery = useDebounce(query, 250) @@ -69,9 +73,9 @@ export function CommandPalette({ useEffect(() => { if (open) { setQuery('') - setActiveTypes(new Set(activeTabToTypes(activeSection))) + setActiveTypes(new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection))) } - }, [open, activeSection]) + }, [open, activeSection, defaultScope]) useEffect(() => { if (!open) return From 78c5ad2e6f36d96afd548b8549c932ef2861a74d Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 00:41:42 +0530 Subject: [PATCH 11/35] elevate folder navigation to app view state --- apps/x/apps/renderer/src/App.tsx | 19 ++++++++++++++----- .../src/components/knowledge-view.tsx | 12 ++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 690c6790..3c653b1b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -587,7 +587,7 @@ type ViewState = | { type: 'live-notes' } | { type: 'email' } | { type: 'workspace'; path?: string } - | { type: 'knowledge-view' } + | { type: 'knowledge-view'; folderPath?: string } | { type: 'chat-history' } | { type: 'home' } @@ -597,6 +597,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type === 'file' && b.type === 'file') return a.path === b.path 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 ?? '') return true // both graph } @@ -644,8 +645,10 @@ function parseDeepLink(input: string): ViewState | null { const path = params.get('path') return { type: 'workspace', path: path ?? undefined } } - case 'knowledge-view': - return { type: 'knowledge-view' } + case 'knowledge-view': { + const folderPath = params.get('folderPath') + return { type: 'knowledge-view', folderPath: folderPath ?? undefined } + } case 'chat-history': return { type: 'chat-history' } case 'home': @@ -762,6 +765,9 @@ function App() { const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false) const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null) const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false) + // Folder being browsed inside the knowledge view (null = root overview). + // Lives in ViewState so folder drill-down participates in back/forward history. + const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState<string | null>(null) const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) // Default landing view: Home in the middle with the chat docked on the right. const [isHomeOpen, setIsHomeOpen] = useState(true) @@ -3463,13 +3469,13 @@ function App() { if (isLiveNotesOpen) return { type: 'live-notes' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined } - if (isKnowledgeViewOpen) return { type: 'knowledge-view' } + if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined } if (isChatHistoryOpen) return { type: 'chat-history' } if (isHomeOpen) return { type: 'home' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId]) + }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3809,6 +3815,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(true) + setKnowledgeViewFolderPath(view.folderPath ?? null) setIsChatHistoryOpen(false) setIsHomeOpen(false) ensureKnowledgeViewFileTab() @@ -5553,6 +5560,8 @@ function App() { revealInFileManager: knowledgeActions.revealInFileManager, onOpenInNewTab: knowledgeActions.onOpenInNewTab, }} + folderPath={knowledgeViewFolderPath} + onNavigateFolder={(path) => { void navigateToView({ type: 'knowledge-view', folderPath: path ?? undefined }) }} onOpenNote={(path) => navigateToFile(path)} onOpenGraph={() => knowledgeActions.openGraph()} onOpenSearch={() => { setSearchDefaultScope('knowledge'); setIsSearchOpen(true) }} diff --git a/apps/x/apps/renderer/src/components/knowledge-view.tsx b/apps/x/apps/renderer/src/components/knowledge-view.tsx index c7674fb4..e7ebe780 100644 --- a/apps/x/apps/renderer/src/components/knowledge-view.tsx +++ b/apps/x/apps/renderer/src/components/knowledge-view.tsx @@ -49,6 +49,10 @@ export type KnowledgeViewActions = { type KnowledgeViewProps = { tree: TreeNode[] actions: KnowledgeViewActions + // Folder currently being browsed (null = root overview). Controlled by the + // app so drill-down participates in the global back/forward history. + folderPath: string | null + onNavigateFolder: (path: string | null) => void onOpenNote: (path: string) => void onOpenGraph: () => void onOpenSearch: () => void @@ -141,14 +145,14 @@ function displayName(node: TreeNode): string { export function KnowledgeView({ tree, actions, + folderPath, + onNavigateFolder, onOpenNote, onOpenGraph, onOpenSearch, onOpenBases, onVoiceNoteCreated, }: KnowledgeViewProps) { - // null = root (folder overview); otherwise the path of the folder being browsed. - const [folderPath, setFolderPath] = useState<string | null>(null) const [renameTarget, setRenameTarget] = useState<string | null>(null) const topLevel = useMemo( @@ -170,7 +174,7 @@ export function KnowledgeView({ [topLevel], ) - const openFolder = useCallback((path: string) => setFolderPath(path), []) + const openFolder = useCallback((path: string) => onNavigateFolder(path), [onNavigateFolder]) // When the open folder no longer exists (deleted/renamed externally), fall // back to the root overview rather than holding a dangling drill-down. @@ -210,7 +214,7 @@ export function KnowledgeView({ renameTarget={renameTarget} onRequestRename={setRenameTarget} onClearRename={() => setRenameTarget(null)} - onNavigate={setFolderPath} + onNavigate={onNavigateFolder} onOpenFolder={openFolder} onOpenNote={onOpenNote} /> From daff21481affb5ef10ec3cee68e2dae0a5b618bb Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 01:07:12 +0530 Subject: [PATCH 12/35] show recording status in sidebar --- .../src/components/sidebar-content.tsx | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index dbb5edb5..0368b1da 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -691,10 +691,20 @@ export function SidebarContentPanel({ // Single preview shown as a sublabel on the Email / Meetings nav buttons. const previewEmail = emailThreads[0] const previewMeeting = meetings[0] - const meetingIsRecording = previewMeeting != null - && recordingMeetingSource === previewMeeting.source - && (meetingRecordingState === 'recording' || meetingRecordingState === 'connecting' || meetingRecordingState === 'stopping') - const meetingIsBusy = meetingIsRecording && (meetingRecordingState === 'connecting' || meetingRecordingState === 'stopping') + // Drive the recording indicator off the global recording state — there is only + // one active recording, so it must show even for ad-hoc recordings or meetings + // that aren't the upcoming one previewed here. + const meetingIsRecording = meetingRecordingState === 'recording' + || meetingRecordingState === 'connecting' + || meetingRecordingState === 'stopping' + const meetingIsBusy = meetingRecordingState === 'connecting' || meetingRecordingState === 'stopping' + // Title of the meeting being recorded, when it's the upcoming one we preview. + const recordingMeeting = previewMeeting != null && recordingMeetingSource === previewMeeting.source + ? previewMeeting + : null + const meetingSublabel = meetingIsRecording + ? (recordingMeeting?.summary ?? 'Recording…') + : (previewMeeting ? `${previewMeeting.summary} · ${formatMeetingTime(previewMeeting)}` : null) return ( <Sidebar className="rowboat-sidebar border-r-0" {...props}> @@ -750,19 +760,22 @@ export function SidebarContentPanel({ <SidebarMenuButton isActive={activeNav === 'meetings'} onClick={onOpenMeetings} - className={previewMeeting ? 'h-auto py-1.5' : undefined} + className={meetingSublabel ? 'h-auto py-1.5' : undefined} > - <Mic className="size-4 shrink-0" /> + <Mic className={cn('size-4 shrink-0', meetingIsRecording && 'text-red-500')} /> <div className="flex min-w-0 flex-1 flex-col"> <span className="truncate">Meetings</span> - {previewMeeting && ( - <span className="truncate text-[11px] text-muted-foreground"> - {meetingIsRecording ? previewMeeting.summary : `${previewMeeting.summary} · ${formatMeetingTime(previewMeeting)}`} + {meetingSublabel && ( + <span className={cn( + 'truncate text-[11px]', + meetingIsRecording ? 'text-red-500' : 'text-muted-foreground', + )}> + {meetingSublabel} </span> )} </div> </SidebarMenuButton> - {previewMeeting && (meetingIsRecording ? ( + {meetingIsRecording ? ( <div className="absolute inset-y-0 right-1 flex items-center gap-1.5"> <span className="relative flex size-2"> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" /> @@ -786,7 +799,7 @@ export function SidebarContentPanel({ </TooltipContent> </Tooltip> </div> - ) : ( + ) : previewMeeting ? ( <div className="absolute inset-y-0 right-1 flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100"> <Tooltip> <TooltipTrigger asChild> @@ -819,7 +832,7 @@ export function SidebarContentPanel({ </Tooltip> )} </div> - ))} + ) : null} </SidebarMenuItem> <SidebarMenuItem> <SidebarMenuButton From b89b91258e4fdca66251e1222cc90784c81f96d5 Mon Sep 17 00:00:00 2001 From: gagan <gaganp000999@gmail.com> Date: Thu, 28 May 2026 01:57:46 +0530 Subject: [PATCH 13/35] feat: redesign web search & tool-call cards (rolling reveal, shared surface, action summaries) (#579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: roll web search sources in one-by-one with settle animation * fix: keep web search toggle on for the rest of the chat session * feat: redesign collapsed web search card with favicon stack and source summary * style: tune web search card surface tints for light and dark mode * feat: rounder web search card with subtle expand/collapse animation * feat: apply web search card design to tool-call box with action summary Shared --card-surface token, rounded card, hover, collapse animation, and a state-driven lead icon (spinner/check/cross). Single tools and the group now match. Completed group shows 'Ran N tools · <up to 2 actions>, more...' with the action summary in lighter gray. * style: drop lead icon from tool group child rows and round them more --- apps/x/apps/renderer/src/App.css | 28 ++ .../src/components/ai-elements/tool.tsx | 101 ++++--- .../ai-elements/web-search-result.tsx | 249 +++++++++++++++--- .../components/chat-input-with-mentions.tsx | 3 +- .../renderer/src/lib/chat-conversation.ts | 57 ++++ 5 files changed, 343 insertions(+), 95 deletions(-) diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 46763d5c..86c6535d 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -35,6 +35,30 @@ } } +/* Radix Collapsible expand/collapse — animate height (via the radix CSS var) + plus a subtle fade. Used by the web search card. */ +@keyframes collapsible-down { + from { + height: 0; + opacity: 0; + } + to { + height: var(--radix-collapsible-content-height); + opacity: 1; + } +} + +@keyframes collapsible-up { + from { + height: var(--radix-collapsible-content-height); + opacity: 1; + } + to { + height: 0; + opacity: 0; + } +} + @media (prefers-reduced-motion: no-preference) { a:nth-of-type(2) .logo { animation: logo-spin infinite 20s linear; @@ -1176,6 +1200,10 @@ --scrollbar-track: oklch(0.95 0 0); --scrollbar-thumb: oklch(0.75 0 0); --scrollbar-thumb-hover: oklch(0.65 0 0); + /* Subtle raised-card surface: tints toward foreground, so it reads a hair + darker than the background in light mode and a hair lighter in dark mode. + Shared by the web search card and tool-call group. */ + --card-surface: color-mix(in oklab, var(--background) 98.5%, var(--foreground)); --rowboat-panel: oklch(0.97 0 0); --rowboat-raised: oklch(1 0 0); --rowboat-wash: color-mix(in oklab, var(--background) 88%, var(--primary) 12%); diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 5f65fa32..61ba6fbd 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -1,6 +1,5 @@ "use client"; -import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, @@ -9,17 +8,15 @@ import { import { cn } from "@/lib/utils"; import type { ToolUIPart } from "ai"; import { - CheckCircleIcon, ChevronDownIcon, - CircleIcon, - ClockIcon, - WrenchIcon, + CircleCheck, + LoaderIcon, XCircleIcon, } from "lucide-react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation"; -import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation"; +import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation"; const formatToolValue = (value: unknown) => { if (typeof value === "string") return value; @@ -52,7 +49,10 @@ export type ToolProps = ComponentProps<typeof Collapsible>; export const Tool = ({ className, ...props }: ToolProps) => ( <Collapsible - className={cn("not-prose mb-4 w-full rounded-md border", className)} + className={cn( + "not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30", + className + )} {...props} /> ); @@ -62,37 +62,17 @@ export type ToolHeaderProps = { type: ToolUIPart["type"]; state: ToolUIPart["state"]; className?: string; + /** Hide the leading status icon (used for child rows inside a tool group). */ + hideLeadIcon?: boolean; }; -const getStatusBadge = (status: ToolUIPart["state"]) => { - const labels: Record<ToolUIPart["state"], string> = { - "input-streaming": "Pending", - "input-available": "Running", - // @ts-expect-error state only available in AI SDK v6 - "approval-requested": "Awaiting Approval", - "approval-responded": "Responded", - "output-available": "Completed", - "output-error": "Error", - "output-denied": "Denied", - }; - - const icons: Record<ToolUIPart["state"], ReactNode> = { - "input-streaming": <CircleIcon className="size-4" />, - "input-available": <ClockIcon className="size-4 animate-pulse" />, - // @ts-expect-error state only available in AI SDK v6 - "approval-requested": <ClockIcon className="size-4 text-yellow-600" />, - "approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />, - "output-available": <CheckCircleIcon className="size-4 text-green-600" />, - "output-error": <XCircleIcon className="size-4 text-red-600" />, - "output-denied": <XCircleIcon className="size-4 text-orange-600" />, - }; - - return ( - <Badge className="gap-1.5 rounded-full text-xs" variant="secondary"> - {icons[status]} - {labels[status]} - </Badge> - ); +// Lead icon shown to the left of the tool label: spinner while running, a +// green check when done, a red cross on error. Shared by ToolHeader (single +// tools) and the tool-call group. +const getLeadIcon = (state: ToolUIPart["state"]): ReactNode => { + if (state === "output-available") return <CircleCheck className="size-4 shrink-0 text-green-600" />; + if (state === "output-error") return <XCircleIcon className="size-4 shrink-0 text-red-600" />; + return <LoaderIcon className="size-4 shrink-0 animate-spin text-muted-foreground" />; }; export const ToolHeader = ({ @@ -100,6 +80,7 @@ export const ToolHeader = ({ title, type, state, + hideLeadIcon, ...props }: ToolHeaderProps) => { const displayTitle = title ?? type.split("-").slice(1).join("-") @@ -107,13 +88,13 @@ export const ToolHeader = ({ return ( <CollapsibleTrigger className={cn( - "flex w-full items-center justify-between gap-4 p-3", + "group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5", className )} {...props} > <div className="flex min-w-0 flex-1 items-center gap-2"> - <WrenchIcon className="size-4 shrink-0 text-muted-foreground" /> + {!hideLeadIcon && getLeadIcon(state)} <span className="min-w-0 flex-1 truncate text-left font-medium text-sm" title={displayTitle} @@ -121,10 +102,7 @@ export const ToolHeader = ({ {displayTitle} </span> </div> - <div className="flex shrink-0 items-center gap-3"> - {getStatusBadge(state)} - <ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" /> - </div> + <ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" /> </CollapsibleTrigger> ) }; @@ -134,7 +112,7 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>; export const ToolContent = ({ className, ...props }: ToolContentProps) => ( <CollapsibleContent className={cn( - "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in", + "overflow-hidden text-popover-foreground outline-none data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]", className )} {...props} @@ -247,41 +225,48 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool const isCompleted = state === 'output-available' || state === 'output-error' const runningTool = group.items.find(t => t.status === 'running' || t.status === 'pending') const currentTool = runningTool ?? group.items[group.items.length - 1] - const summary = isCompleted - ? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}` + const toolCount = group.items.length + const ranLabel = `Ran ${toolCount} tool${toolCount !== 1 ? 's' : ''}` + const actions = isCompleted ? getToolActionsSummary(group.items) : '' + // Plain string used as the AnimatePresence key + tooltip; the rendered node + // shows the action summary in a lighter gray than the "Ran N tools" prefix. + const summaryText = isCompleted + ? `${ranLabel} · ${actions}` : currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items) + const summaryNode: ReactNode = isCompleted + ? <>{ranLabel} <span className="font-normal text-muted-foreground">{`· ${actions}`}</span></> + : summaryText + + const leadIcon = getLeadIcon(state) return ( <Collapsible open={open} onOpenChange={setOpen} - className="not-prose mb-4 w-full rounded-md border" + className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30" > - <CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3"> + <CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5"> <div className="flex min-w-0 flex-1 items-center gap-2"> - <WrenchIcon className="size-4 shrink-0 text-muted-foreground" /> + {leadIcon} <div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}> <AnimatePresence mode="popLayout" initial={false}> <motion.span - key={summary} + key={summaryText} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.18, ease: 'easeOut' }} className="absolute inset-0 truncate text-left font-medium text-sm leading-5" - title={summary} + title={summaryText} > - {summary} + {summaryNode} </motion.span> </AnimatePresence> </div> </div> - <div className="flex shrink-0 items-center gap-3"> - {getStatusBadge(state)} - <ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} /> - </div> + <ChevronDownIcon className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} /> </CollapsibleTrigger> - <CollapsibleContent className="border-t"> + <CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]"> <div className="flex flex-col gap-2 p-2"> {group.items.map((tool) => { const toolState = toToolState(tool.status) @@ -291,12 +276,14 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool key={tool.id} open={isOpen} onOpenChange={(o) => onToolOpenChange(tool.id, o)} - className="mb-0 border-border/60" + className="mb-0 rounded-[20px] border-border/60 bg-transparent hover:border-border/60" > <ToolHeader title={getToolDisplayName(tool)} type={`tool-${tool.name}`} state={toolState} + className="text-muted-foreground" + hideLeadIcon /> <ToolContent> <ToolTabbedContent diff --git a/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx b/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx index 30e5c002..f9fe311a 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx @@ -5,12 +5,14 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; import { - CheckCircleIcon, ChevronDownIcon, GlobeIcon, LoaderIcon, } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; interface WebSearchResultProps { query: string; @@ -19,39 +21,219 @@ interface WebSearchResultProps { title?: string; } +// How long each fetched website stays on the rolling header before the +// next one slides in. Kept slow enough to read the domain + title. +const ROLL_INTERVAL_MS = 700; + +// How many favicons to show in the settled stack before the rest collapse +// into a "+N" chip. The text names this many domains too, so the chip count +// (total - MAX_STACK) lines up with the "and N others" in the summary. +const MAX_STACK = 3; + function getDomain(url: string): string { try { - return new URL(url).hostname; + return new URL(url).hostname.replace(/^www\./, ""); } catch { return url; } } +function faviconUrl(domain: string, size = 32): string { + return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`; +} + +// Collapse the result list into unique domains, preserving order. +function uniqueDomains(results: WebSearchResultProps["results"]): string[] { + const seen = new Set<string>(); + const out: string[] = []; + for (const result of results) { + const domain = getDomain(result.url); + if (seen.has(domain)) continue; + seen.add(domain); + out.push(domain); + } + return out; +} + +// Summary with text hierarchy: "Searched" + "and N others" are secondary +// weight/color, the domain names are primary text at medium weight. +function buildSearchedSummary(domains: string[]): React.ReactNode { + const muted = "font-normal text-muted-foreground"; + const name = (d: string) => <span className="font-medium text-foreground">{d}</span>; + if (domains.length === 1) { + return ( + <> + <span className={muted}>Searched </span> + {name(domains[0])} + </> + ); + } + if (domains.length === 2) { + return ( + <> + <span className={muted}>Searched </span> + {name(domains[0])} + <span className={muted}> and </span> + {name(domains[1])} + </> + ); + } + const others = domains.length - 2; + return ( + <> + <span className={muted}>Searched </span> + {name(domains[0])} + <span className={muted}>, </span> + {name(domains[1])} + <span className={muted}>{` and ${others} other${others !== 1 ? "s" : ""}`}</span> + </> + ); +} + +type RollPhase = "searching" | "rolling" | "settled"; + export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) { const isRunning = status === "pending" || status === "running"; + const [open, setOpen] = useState(false); - return ( - <Collapsible defaultOpen className="not-prose mb-4 w-full rounded-md border"> - <CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3"> - <div className="flex items-center gap-2"> - <GlobeIcon className="size-4 text-muted-foreground" /> - <span className="font-medium text-sm">{title}</span> - </div> - <ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" /> - </CollapsibleTrigger> - <CollapsibleContent> - <div className="px-3 pb-3 space-y-3"> - {/* Query + result count */} - <div className="flex items-center justify-between gap-2"> - <div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0"> - <GlobeIcon className="size-3.5 shrink-0" /> - <span className="truncate">{query}</span> - </div> - {results.length > 0 && ( - <span className="text-xs text-muted-foreground whitespace-nowrap"> - {results.length} result{results.length !== 1 ? "s" : ""} + const domains = useMemo(() => uniqueDomains(results), [results]); + + // Drive the one-shot rolling reveal. Results arrive all at once, so we + // simulate "fetching one site at a time" by stepping through them with the + // same slide animation the tool group uses, then settle on a summary. + // `settled` is seeded from the initial status so a card loaded already- + // complete from history skips straight to the summary (no roll). + const [settled, setSettled] = useState(() => !isRunning); + const [rollIndex, setRollIndex] = useState(0); + + // Phase is fully derived: searching while the tool runs, rolling once + // results land, then settled. No setState-in-effect needed for transitions. + const phase: RollPhase = isRunning + ? "searching" + : !settled && results.length > 0 + ? "rolling" + : "settled"; + + // Warm the browser cache for every favicon the moment results arrive, so + // each icon is already loaded by the time its row rolls in (~700ms each). + // Without this the network fetch lags the text and rows flash icon-less. + useEffect(() => { + for (const result of results) { + const img = new Image(); + img.src = faviconUrl(getDomain(result.url)); + } + }, [results]); + + // Advance the roll, then settle after the last site has had its moment. + // setState only fires inside the timeout callback, never synchronously. + useEffect(() => { + if (phase !== "rolling") return; + const isLast = rollIndex >= results.length - 1; + const timer = setTimeout( + () => (isLast ? setSettled(true) : setRollIndex((i) => i + 1)), + ROLL_INTERVAL_MS, + ); + return () => clearTimeout(timer); + }, [phase, rollIndex, results.length]); + + // Build the content for the compact (collapsed) header line. Each distinct + // value gets a unique key so AnimatePresence runs the slide transition. + let headerKey: string; + let headerContent: React.ReactNode; + if (phase === "searching") { + headerKey = "searching"; + headerContent = ( + <span className="flex min-w-0 flex-1 items-center gap-2 text-muted-foreground"> + <LoaderIcon className="size-4 shrink-0 animate-spin" /> + <span className="truncate">Searching the web…</span> + </span> + ); + } else if (phase === "rolling") { + const result = results[rollIndex]; + const domain = getDomain(result.url); + headerKey = `roll-${rollIndex}`; + headerContent = ( + <span className="flex min-w-0 flex-1 items-center gap-2"> + <img src={faviconUrl(domain)} alt="" className="size-4 shrink-0 rounded-sm bg-muted/60" /> + <span className="truncate"> + <span className="text-muted-foreground">{domain}</span> + <span className="text-muted-foreground/50"> · </span> + <span>{result.title}</span> + </span> + </span> + ); + } else { + headerKey = "settled"; + const stack = domains.slice(0, MAX_STACK); + // Chip count matches the "and N others" in the text (total minus the 2 + // named domains), shown only when there are sites beyond the stack. + const overflow = domains.length > MAX_STACK ? domains.length - 2 : 0; + headerContent = ( + <span className="flex min-w-0 flex-1 items-center gap-2.5"> + {domains.length > 0 ? ( + <span className="flex shrink-0 items-center"> + {stack.map((domain, i) => ( + <img + key={domain} + src={faviconUrl(domain)} + alt="" + className="size-5 rounded-full bg-muted object-cover -ml-[5px] first:ml-0" + style={{ zIndex: stack.length - i }} + /> + ))} + {overflow > 0 && ( + <span className="ml-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-foreground/10 dark:bg-muted text-[10px] font-medium text-muted-foreground"> + +{overflow} </span> )} + </span> + ) : ( + <GlobeIcon className="size-4 shrink-0 text-muted-foreground" /> + )} + <span className="truncate text-sm"> + {domains.length > 0 ? buildSearchedSummary(domains) : title} + </span> + </span> + ); + } + + return ( + <Collapsible + open={open} + onOpenChange={setOpen} + className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30" + > + <CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5"> + {/* Rolling header: clipped, fixed height so sliding lines stay contained */} + <div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: "1.5rem" }}> + <AnimatePresence mode="popLayout" initial={false}> + <motion.span + key={headerKey} + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -10 }} + transition={{ duration: 0.18, ease: "easeOut" }} + className="absolute inset-0 flex items-center text-left font-medium text-sm" + > + {headerContent} + </motion.span> + </AnimatePresence> + </div> + <div className="flex shrink-0 items-center gap-2"> + {phase === "settled" && domains.length > 0 && ( + <span className="whitespace-nowrap text-xs text-muted-foreground"> + {domains.length} source{domains.length !== 1 ? "s" : ""} + </span> + )} + <ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} /> + </div> + </CollapsibleTrigger> + <CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]"> + <div className="px-4 pb-3 space-y-3"> + {/* Query */} + <div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0"> + <GlobeIcon className="size-3.5 shrink-0" /> + <span className="truncate">{query}</span> </div> {/* Results list */} @@ -73,7 +255,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the > <div className="flex items-center gap-2 min-w-0"> <img - src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`} + src={faviconUrl(domain)} alt="" className="size-4 shrink-0" /> @@ -88,20 +270,13 @@ export function WebSearchResult({ query, results, status, title = "Searched the </div> )} - {/* Status */} - <div className="flex items-center gap-1.5 text-xs text-muted-foreground"> - {isRunning ? ( - <> - <LoaderIcon className="size-3.5 animate-spin" /> - <span>Searching...</span> - </> - ) : ( - <> - <CheckCircleIcon className="size-3.5 text-green-600" /> - <span>Done</span> - </> - )} - </div> + {/* Status — only while the search is still running. */} + {isRunning && ( + <div className="flex items-center gap-1.5 text-xs text-muted-foreground"> + <LoaderIcon className="size-3.5 animate-spin" /> + <span>Searching...</span> + </div> + )} </div> </CollapsibleContent> </Collapsible> 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 013a68ad..d59b4047 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 @@ -382,7 +382,8 @@ function ChatInputInner({ controller.textInput.clear() controller.mentions.clearMentions() setAttachments([]) - setSearchEnabled(false) + // Web search toggle stays on for the rest of the chat session; the user + // turns it off explicitly. (Not persisted across app restarts.) }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 576997ad..41344107 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -653,6 +653,63 @@ export const getToolGroupSummary = (tools: ToolCall[]): string => { return names.join(' · ') } +// Past-tense action phrases for summarizing a finished tool group, e.g. +// "read 3 files, listed directory". Keyed by builtin tool name. +const TOOL_ACTION_VERBS: Record<string, { verb: string; one: string; many: string }> = { + 'file-readText': { verb: 'read', one: 'file', many: 'files' }, + 'file-writeText': { verb: 'wrote', one: 'file', many: 'files' }, + 'file-editText': { verb: 'edited', one: 'file', many: 'files' }, + 'file-list': { verb: 'listed', one: 'directory', many: 'directories' }, + 'file-exists': { verb: 'checked', one: 'path', many: 'paths' }, + 'file-stat': { verb: 'inspected', one: 'file', many: 'files' }, + 'file-glob': { verb: 'searched for', one: 'file', many: 'files' }, + 'file-grep': { verb: 'searched', one: 'file', many: 'files' }, + 'file-mkdir': { verb: 'created', one: 'directory', many: 'directories' }, + 'file-rename': { verb: 'renamed', one: 'file', many: 'files' }, + 'file-copy': { verb: 'copied', one: 'file', many: 'files' }, + 'file-remove': { verb: 'removed', one: 'file', many: 'files' }, + 'file-getRoot': { verb: 'resolved', one: 'file root', many: 'file roots' }, + 'executeCommand': { verb: 'ran', one: 'command', many: 'commands' }, + 'executeMcpTool': { verb: 'ran', one: 'MCP tool', many: 'MCP tools' }, + 'listMcpServers': { verb: 'listed', one: 'MCP server', many: 'MCP servers' }, + 'listMcpTools': { verb: 'listed', one: 'MCP tool', many: 'MCP tools' }, + 'save-to-memory': { verb: 'saved', one: 'memory', many: 'memories' }, + 'loadSkill': { verb: 'loaded', one: 'skill', many: 'skills' }, + 'parseFile': { verb: 'parsed', one: 'file', many: 'files' }, +} + +// Summarize what a group of tools actually did, grouping identical actions +// and counting them: "read 3 files, listed directory". Unmapped tools fall +// back to their lowercased display name. +export const getToolActionsSummary = (tools: ToolCall[]): string => { + const order: string[] = [] + const grouped = new Map<string, { phrase: typeof TOOL_ACTION_VERBS[string] | null; count: number; fallback: string }>() + for (const tool of tools) { + const phrase = TOOL_ACTION_VERBS[tool.name] ?? null + const key = phrase ? `${phrase.verb}|${phrase.one}` : tool.name + const existing = grouped.get(key) + if (existing) { + existing.count++ + } else { + grouped.set(key, { phrase, count: 1, fallback: getToolDisplayName(tool) }) + order.push(key) + } + } + const phrases = order.map((key) => { + const { phrase, count, fallback } = grouped.get(key)! + if (!phrase) return fallback.toLowerCase() + if (count > 1) return `${phrase.verb} ${count} ${phrase.many}` + const article = /^[aeiou]/i.test(phrase.one) ? 'an' : 'a' + return `${phrase.verb} ${article} ${phrase.one}` + }) + // Show at most two operations; collapse the rest into "more...". + const MAX_ACTIONS = 2 + if (phrases.length > MAX_ACTIONS) { + return `${phrases.slice(0, MAX_ACTIONS).join(', ')}, more...` + } + return phrases.join(', ') +} + export const inferRunTitleFromMessage = (content: string): string | undefined => { const { message } = parseAttachedFiles(content) const normalized = message.replace(/\s+/g, ' ').trim() From 537b6f66bbe14a37b557f6855228ec654e44915f Mon Sep 17 00:00:00 2001 From: gagan <gaganp000999@gmail.com> Date: Thu, 28 May 2026 14:52:09 +0530 Subject: [PATCH 14/35] Code Mode: in-chat toggle, settings tab, and permission/command UX (#572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add in-chat code mode toggle with claude/codex swap * feat: show agent and add swap-and-retry on acpx permission card * style: reorder permission card buttons (approve, deny, swap) * feat: add tooltips to composer plus and web search buttons * feat: add code mode settings tab with agent install/auth checks * feat: show sign-in command when agent installed but signed out * style: refine code-mode permission and command block UX - Render permission block before the command block - Collapse permission details after a response; click header to expand - Drop status icons/badge; use minimal green / bold red blocks - Auto-collapse the running command block once it completes * feat: rotating progress labels for code-mode commands; darker tool borders - Code-mode (acpx) command block shows status-aware labels: rotating 'Working on the task…' phrases (5s each, holding on the last) while running, then 'Completed the task' / "Couldn't complete the task" - Darken outer border on all tool blocks in light and dark modes * fix: detect Claude Code sign-in via macOS Keychain On macOS, Claude Code stores OAuth credentials in the login Keychain (service 'Claude Code-credentials'), not in ~/.claude/.credentials.json. Read the Keychain as a fallback so signed-in Mac users are detected. * feat: persistent per-chat sessions for code-mode coding agents - Use a named acpx session (rowboat-<runId>) per chat so follow-up coding requests resume the same agent and keep context - Create the session once at chat start (sessions new --name), then prompt with -s <name>; reuse on follow-ups (no re-create) - Drop the redundant in-chat 'reply yes' confirmation (the executeCommand permission card is the confirmation) - Code-mode output uses plain-text paths (overrides global filepath rule) - On not-installed/auth errors, point user to Settings -> Code Mode * fix: code-mode session creation uses idempotent ensure, run sequentially - Use 'sessions ensure --name' instead of 'sessions new' so reopening a chat resumes the existing session instead of erroring on a name clash - Create the session and run the prompt as separate sequential calls so the permission/command blocks render one at a time (not all at once) * fix: reliable Claude Code session resume on Windows (avoid claude.cmd EINVAL) Resuming a code-mode chat after restarting the app spawns a fresh ACP agent. On Windows + Node >=20.12 the bridge spawning claude.cmd throws EINVAL, so the session queue owner fails to start. Rowboat injects CLAUDE_CODE_EXECUTABLE=claude.exe to dodge this, but the override didn't reliably reach the spawn. Windows-only; no-op on macOS/Linux. - executeCommand now accepts an env override and the non-abortable fallback path passes it through (was silently dropped) - resolveClaudeExeOnWindows also scans known npm/pnpm/volta global bin dirs, not just PATH (Electron's runtime PATH can omit them) - add --timeout 600 to acpx prompt commands so a genuine stall fails cleanly instead of hanging on 'Running' forever --- apps/x/apps/main/src/ipc.ts | 19 +- apps/x/apps/renderer/src/App.tsx | 41 +++- .../ai-elements/ask-human-request.tsx | 79 ++++--- .../ai-elements/permission-request.tsx | 89 +++++--- .../components/chat-input-with-mentions.tsx | 187 ++++++++++++++-- .../renderer/src/components/chat-sidebar.tsx | 2 +- .../src/components/settings-dialog.tsx | 208 +++++++++++++++++- .../renderer/src/lib/chat-conversation.ts | 32 +++ apps/x/packages/core/src/agents/runtime.ts | 56 ++++- .../src/application/assistant/instructions.ts | 37 ++-- .../skills/code-with-agents/skill.ts | 160 +++++++++----- .../core/src/application/lib/builtin-tools.ts | 44 +++- .../src/application/lib/command-executor.ts | 2 + .../core/src/application/lib/message-queue.ts | 8 +- apps/x/packages/core/src/code-mode/index.ts | 3 + apps/x/packages/core/src/code-mode/repo.ts | 42 ++++ apps/x/packages/core/src/code-mode/status.ts | 199 +++++++++++++++++ apps/x/packages/core/src/code-mode/types.ts | 18 ++ apps/x/packages/core/src/di/container.ts | 2 + apps/x/packages/core/src/runs/runs.ts | 4 +- apps/x/packages/shared/src/ipc.ts | 22 ++ apps/x/packages/shared/src/runs.ts | 1 + 22 files changed, 1084 insertions(+), 171 deletions(-) create mode 100644 apps/x/packages/core/src/code-mode/index.ts create mode 100644 apps/x/packages/core/src/code-mode/repo.ts create mode 100644 apps/x/packages/core/src/code-mode/status.ts create mode 100644 apps/x/packages/core/src/code-mode/types.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ed8b1a7c..2f5730ce 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -31,6 +31,9 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js'; 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 { 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'; import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; @@ -526,7 +529,7 @@ export function setupIpcHandlers() { return runsCore.createRun(args); }, 'runs:createMessage': async (_event, args) => { - return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) }; }, 'runs:authorizePermission': async (_event, args) => { await runsCore.authorizePermission(args.runId, args.authorization); @@ -630,6 +633,20 @@ export function setupIpcHandlers() { const config = await repo.getConfig(); return { enabled: config.enabled }; }, + 'codeMode:getConfig': async () => { + const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo'); + const config = await repo.getConfig(); + return { enabled: config.enabled }; + }, + 'codeMode:setConfig': async (_event, args) => { + const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo'); + await repo.setConfig({ enabled: args.enabled }); + invalidateCopilotInstructionsCache(); + return { success: true }; + }, + 'codeMode:checkAgentStatus': async () => { + return await checkCodeModeAgentStatus(); + }, 'granola:setConfig': async (_event, args) => { const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 3c653b1b..2bf0c571 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -966,7 +966,7 @@ function App() { voice.start() }, [voice]) - const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise<void>) | null>(null) + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise<void>) | null>(null) const pendingVoiceInputRef = useRef(false) // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload @@ -2190,6 +2190,19 @@ 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) { @@ -2304,7 +2317,7 @@ function App() { return next }) - if (event.toolCallId && event.toolName !== 'executeCommand') { + if (event.toolCallId) { setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false) } @@ -2482,6 +2495,7 @@ function App() { mentions?: FileMention[], stagedAttachments: StagedAttachment[] = [], searchEnabled?: boolean, + codeMode?: 'claude' | 'codex', ) => { if (isProcessing) return @@ -2593,6 +2607,7 @@ function App() { voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, + codeMode: codeMode || undefined, middlePaneContext, }) analytics.chatMessageSent({ @@ -2608,6 +2623,7 @@ function App() { voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, + codeMode: codeMode || undefined, middlePaneContext, }) analytics.chatMessageSent({ @@ -5836,7 +5852,6 @@ function App() { const response = tabState.permissionResponses.get(item.id) || null return ( <React.Fragment key={item.id}> - {rendered} <PermissionRequest toolCall={permRequest.toolCall} permission={permRequest.permission} @@ -5844,9 +5859,28 @@ 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} /> + {rendered} </React.Fragment> ) } @@ -5858,6 +5892,7 @@ function App() { <AskHumanRequest key={request.toolCallId} query={request.query} + options={request.options} onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)} isProcessing={isActive && isProcessing} /> diff --git a/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx index 2e92e2ca..6571e54e 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx @@ -9,6 +9,7 @@ import { useState, useRef, useEffect } from "react"; export type AskHumanRequestProps = ComponentProps<"div"> & { query: string; + options?: string[]; onResponse: (response: string) => void; isProcessing?: boolean; }; @@ -16,17 +17,21 @@ export type AskHumanRequestProps = ComponentProps<"div"> & { export const AskHumanRequest = ({ className, query, + options, onResponse, isProcessing = false, ...props }: AskHumanRequestProps) => { const [response, setResponse] = useState(""); const textareaRef = useRef<HTMLTextAreaElement>(null); + const hasOptions = Array.isArray(options) && options.length > 0; useEffect(() => { - // Auto-focus the textarea when component mounts - textareaRef.current?.focus(); - }, []); + // Auto-focus the textarea when in free-text mode; nothing to focus for buttons. + if (!hasOptions) { + textareaRef.current?.focus(); + } + }, [hasOptions]); const handleSubmit = () => { const trimmed = response.trim(); @@ -36,6 +41,11 @@ export const AskHumanRequest = ({ } }; + const handleOptionClick = (option: string) => { + if (isProcessing) return; + onResponse(option); + }; + const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -65,30 +75,47 @@ export const AskHumanRequest = ({ {query} </p> </div> - <div className="space-y-2"> - <Textarea - ref={textareaRef} - value={response} - onChange={(e) => setResponse(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Type your response..." - disabled={isProcessing} - rows={3} - className="resize-none" - /> - <div className="flex justify-end"> - <Button - variant="default" - size="sm" - onClick={handleSubmit} - disabled={!canSubmit} - className="gap-2" - > - <ArrowUpIcon className="size-4" /> - Send Response - </Button> + {hasOptions ? ( + <div className="flex flex-wrap gap-2"> + {options!.map((option) => ( + <Button + key={option} + variant="outline" + size="sm" + onClick={() => handleOptionClick(option)} + disabled={isProcessing} + className="bg-background" + > + {option} + </Button> + ))} </div> - </div> + ) : ( + <div className="space-y-2"> + <Textarea + ref={textareaRef} + value={response} + onChange={(e) => setResponse(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Type your response..." + disabled={isProcessing} + rows={3} + className="resize-none" + /> + <div className="flex justify-end"> + <Button + variant="default" + size="sm" + onClick={handleSubmit} + disabled={!canSubmit} + className="gap-2" + > + <ArrowUpIcon className="size-4" /> + Send Response + </Button> + </div> + </div> + )} </div> </div> </div> diff --git a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx index c0369a9a..d99d2e8b 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx @@ -9,8 +9,8 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react"; -import type { ComponentProps } from "react"; +import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, RefreshCwIcon, Terminal, XIcon } from "lucide-react"; +import { useState, type ComponentProps } from "react"; import { ToolCallPart } from "@x/shared/dist/message.js"; import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; import z from "zod"; @@ -21,6 +21,7 @@ export type PermissionRequestProps = ComponentProps<"div"> & { onApproveSession?: () => void; onApproveAlways?: () => void; onDeny?: () => void; + onSwitchAgent?: (newAgent: 'claude' | 'codex') => void; isProcessing?: boolean; response?: 'approve' | 'deny' | null; permission?: z.infer<typeof ToolPermissionMetadata>; @@ -41,6 +42,7 @@ export const PermissionRequest = ({ onApproveSession, onApproveAlways, onDeny, + onSwitchAgent, isProcessing = false, response = null, permission, @@ -54,17 +56,33 @@ 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'; + // Once a response is chosen, collapse the details to just the header. + // Users can click the header to expand them again. + const [expanded, setExpanded] = useState(false); + const showDetails = !isResponded || expanded; + return ( <div className={cn( "not-prose mb-4 w-full rounded-md border", isResponded ? isApproved - ? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20" - : "border-red-500/50 bg-red-50/50 dark:bg-red-950/20" + ? "border-green-500/60 bg-green-200/80 dark:border-green-500/40 dark:bg-green-900/40" + : "border-[#fa2525]/70 bg-[#fa2525]/30 dark:border-[#fa2525]/60 dark:bg-[#fa2525]/30" : "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20", className )} @@ -72,50 +90,41 @@ export const PermissionRequest = ({ > <div className="p-4 space-y-4"> <div className="flex items-start gap-3"> - {isResponded ? ( - isApproved ? ( - <CheckCircleIcon className="size-5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" /> - ) : ( - <XCircleIcon className="size-5 text-red-600 dark:text-red-500 shrink-0 mt-0.5" /> - ) - ) : ( + {!isResponded && ( <AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" /> )} <div className="flex-1 space-y-2"> - <div className="flex items-center gap-2"> + <div + className={cn("flex items-center gap-2", isResponded && "cursor-pointer select-none")} + onClick={isResponded ? () => setExpanded((v) => !v) : undefined} + > <div className="flex-1"> <h3 className="font-semibold text-sm text-foreground"> {isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"} </h3> <p className="text-sm text-muted-foreground mt-1"> {isResponded ? "Requested:" : "The agent wants to execute:"} <span className="font-mono font-medium">{toolCall.toolName}</span> + {agentDisplay && ( + <Badge + variant="secondary" + className="ml-2 align-middle bg-secondary text-foreground" + > + <Terminal className="size-3 mr-1" /> + {agentDisplay} + </Badge> + )} </p> </div> {isResponded && ( - <Badge - variant="secondary" + <ChevronDownIcon className={cn( - "shrink-0", - isApproved - ? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400" - : "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400" + "size-4 shrink-0 text-muted-foreground transition-transform", + expanded ? "rotate-180" : "rotate-0" )} - > - {isApproved ? ( - <> - <CheckIcon className="size-3 mr-1" /> - Approved - </> - ) : ( - <> - <XIcon className="size-3 mr-1" /> - Denied - </> - )} - </Badge> + /> )} </div> - {command && ( + {showDetails && command && ( <div className="rounded-md border bg-background/50 p-3 mt-3"> <p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide"> Command @@ -125,7 +134,7 @@ export const PermissionRequest = ({ </pre> </div> )} - {filePermission && ( + {showDetails && filePermission && ( <div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3"> <div> <p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide"> @@ -153,7 +162,7 @@ export const PermissionRequest = ({ </div> </div> )} - {!command && !filePermission && toolCall.arguments && ( + {showDetails && !command && !filePermission && toolCall.arguments && ( <div className="rounded-md border bg-background/50 p-3 mt-3"> <p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide"> Arguments @@ -211,6 +220,18 @@ export const PermissionRequest = ({ <XIcon className="size-4" /> Deny </Button> + {otherAgent && otherDisplay && onSwitchAgent && ( + <Button + variant="secondary" + size="sm" + onClick={() => onSwitchAgent(otherAgent)} + disabled={isProcessing} + className="flex-1" + > + <RefreshCwIcon className="size-4" /> + Use {otherDisplay} instead + </Button> + )} </div> )} </div> 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 d59b4047..360a8657 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 @@ -18,6 +18,7 @@ import { Mic, Plus, Square, + Terminal, X, } from 'lucide-react' @@ -108,7 +109,7 @@ function getAttachmentIcon(kind: AttachmentIconKind) { } interface ChatInputInnerProps { - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void onStop?: () => void isProcessing: boolean isStopping?: boolean @@ -178,6 +179,9 @@ function ChatInputInner({ const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) const [isRowboatConnected, setIsRowboatConnected] = useState(false) + const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude') + const [codeModeEnabled, setCodeModeEnabled] = useState(false) + const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { @@ -260,8 +264,89 @@ function ChatInputInner({ return () => window.removeEventListener('models-config-changed', handler) }, [loadModelConfig]) + // Load the global code-mode feature flag (from settings) and stay in sync. + useEffect(() => { + const load = () => { + window.ipc.invoke('codeMode:getConfig', null) + .then((r) => setCodeModeFeatureEnabled(r.enabled)) + .catch(() => setCodeModeFeatureEnabled(false)) + } + load() + window.addEventListener('code-mode-config-changed', load) + return () => window.removeEventListener('code-mode-config-changed', load) + }, []) + + // If the feature is turned off in settings, also turn off any per-conversation chip. + useEffect(() => { + if (!codeModeFeatureEnabled && codeModeEnabled) { + setCodeModeEnabled(false) + } + }, [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 => { + const trimmed = p.replace(/[\\/]+$/, '') + const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) + return idx >= 0 ? trimmed.slice(idx + 1) : trimmed + }, []) + + // 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'> => { + if (!dir) return 'claude' + try { + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' }) + const parsed = JSON.parse(result.data) as Record<string, unknown> + const value = parsed?.[dir] + if (value === 'codex' || value === 'claude') return value + } catch { + /* file missing or invalid — fall through to default */ + } + return 'claude' + }, []) + + const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => { + let existing: Record<string, 'claude' | 'codex'> = {} + try { + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' }) + const parsed = JSON.parse(result.data) as Record<string, unknown> + for (const [k, v] of Object.entries(parsed ?? {})) { + if (v === 'claude' || v === 'codex') existing[k] = v + } + } catch { /* start fresh */ } + existing[dir] = agent + await window.ipc.invoke('workspace:writeFile', { + path: 'config/coding-agents.json', + data: JSON.stringify(existing, null, 2), + }) + }, []) + // Work directory is owned per-chat by the parent (App). This component only - // drives the picker dialog and reports changes up via onWorkDirChange. + // drives the picker dialog and reports changes up via onWorkDirChange. Whenever + // the work directory changes, load its persisted coding-agent preference. + useEffect(() => { + let cancelled = false + loadCodingAgentFor(workDir).then((agent) => { + if (!cancelled) setCodingAgent(agent) + }) + return () => { cancelled = true } + }, [workDir, loadCodingAgentFor]) + const handleSetWorkDir = useCallback(async () => { try { let defaultPath: string | undefined = workDir ?? undefined @@ -282,18 +367,35 @@ function ChatInputInner({ }) if (!chosen) return onWorkDirChange?.(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]) + }, [workDir, onWorkDirChange, loadCodingAgentFor]) const handleClearWorkDir = useCallback(() => { onWorkDirChange?.(null) + setCodingAgent('claude') toast.success('Work directory cleared') }, [onWorkDirChange]) + const handleToggleCodingAgent = useCallback(async () => { + const next: 'claude' | 'codex' = codingAgent === 'claude' ? 'codex' : 'claude' + setCodingAgent(next) + // Persist only when scoped to a workdir; without one there's nothing to key on. + if (!workDir) return + try { + await persistCodingAgent(workDir, next) + } catch (err) { + console.error('Failed to save coding agent', err) + toast.error('Failed to save coding agent') + // revert on failure + setCodingAgent(codingAgent) + } + }, [workDir, codingAgent, persistCodingAgent]) + // Check search tool availability (exa or signed-in via gateway) useEffect(() => { const checkSearch = async () => { @@ -378,13 +480,15 @@ function ChatInputInner({ const handleSubmit = useCallback(() => { if (!canSubmit) return - onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined) + // codeMode is sticky per conversation — don't reset after send. + const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined + onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode) controller.textInput.clear() controller.mentions.clearMentions() setAttachments([]) // Web search toggle stays on for the rest of the chat session; the user // turns it off explicitly. (Not persisted across app restarts.) - }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled]) + }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, workDir]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -529,15 +633,20 @@ function ChatInputInner({ </div> <div className="flex items-center gap-2 px-4 pb-3"> <DropdownMenu> - <DropdownMenuTrigger asChild> - <button - type="button" - className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" - aria-label="Add" - > - <Plus className="h-4 w-4" /> - </button> - </DropdownMenuTrigger> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <button + type="button" + className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + aria-label="Add" + > + <Plus className="h-4 w-4" /> + </button> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="top">Add files or set work directory</TooltipContent> + </Tooltip> <DropdownMenuContent align="start" className="min-w-56"> <DropdownMenuItem onSelect={() => fileInputRef.current?.click()}> <ImagePlus className="size-4" /> @@ -559,7 +668,7 @@ function ChatInputInner({ className="flex min-w-0 items-center gap-1.5" > <FolderCog className="h-3.5 w-3.5 shrink-0" /> - <span className="truncate">{workDir.split('/').pop() || workDir}</span> + <span className="truncate">{basename(workDir) || workDir}</span> </button> <button type="button" @@ -600,6 +709,52 @@ function ChatInputInner({ </span> </button> )} + {codeModeFeatureEnabled && (codeModeEnabled ? ( + <div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground"> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={() => setCodeModeEnabled(false)} + className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70" + > + <Terminal className="h-3.5 w-3.5" /> + <span>Code</span> + </button> + </TooltipTrigger> + <TooltipContent side="top">Code mode on — click to disable</TooltipContent> + </Tooltip> + <span className="text-foreground/30">·</span> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={handleToggleCodingAgent} + className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70" + > + <span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span> + </button> + </TooltipTrigger> + <TooltipContent side="top"> + Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap + </TooltipContent> + </Tooltip> + </div> + ) : ( + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={() => setCodeModeEnabled(true)} + className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + aria-label="Code mode" + > + <Terminal className="h-4 w-4" /> + </button> + </TooltipTrigger> + <TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent> + </Tooltip> + ))} <div className="flex-1" /> {lockedModel ? ( <span @@ -760,7 +915,7 @@ export interface ChatInputWithMentionsProps { knowledgeFiles: string[] recentFiles: string[] visibleFiles: string[] - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void onStop?: () => void isProcessing: boolean isStopping?: boolean diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index ba7b364e..7987e6dd 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -645,7 +645,6 @@ export function ChatSidebar({ const response = tabState.permissionResponses.get(item.id) || null return ( <React.Fragment key={item.id}> - {rendered} <PermissionRequest toolCall={permRequest.toolCall} onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} @@ -655,6 +654,7 @@ export function ChatSidebar({ isProcessing={isActive && isProcessing} response={response} /> + {rendered} </React.Fragment> ) } diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index c7750d6c..37e0a930 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw } from "lucide-react" import { Dialog, @@ -26,7 +26,7 @@ import { toast } from "sonner" import { AccountSettings } from "@/components/settings/account-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" -type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "appearance" | "note-tagging" | "help" +type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help" interface TabConfig { id: ConfigTab @@ -70,6 +70,12 @@ const tabs: TabConfig[] = [ path: "config/security.json", description: "Configure allowed shell commands", }, + { + id: "code-mode", + label: "Code Mode", + icon: Terminal, + description: "Delegate coding tasks to Claude Code or Codex", + }, { id: "appearance", label: "Appearance", @@ -1648,6 +1654,198 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { ) } +// --- Code Mode Settings --- + +type AgentStatus = { installed: boolean; signedIn: boolean } +type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus } + +function AgentStatusRow({ + name, + installLink, + signInCommand, + status, +}: { + name: string + installLink: string + signInCommand: string + status: AgentStatus | null +}) { + const ready = status?.installed && status?.signedIn + const needsSignInOnly = status?.installed && !status?.signedIn + return ( + <div className="rounded-md border px-3 py-2.5 flex items-center gap-3"> + <Terminal className="size-4 text-muted-foreground shrink-0" /> + <div className="flex-1 min-w-0"> + <div className="text-sm font-medium">{name}</div> + <div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3"> + <span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}> + {status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />} + Installed + </span> + <span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}> + {status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />} + Signed in + </span> + </div> + </div> + {ready ? ( + <span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600"> + Ready + </span> + ) : needsSignInOnly ? ( + <span className="text-xs text-muted-foreground shrink-0"> + Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code> + </span> + ) : ( + <a + href={installLink} + target="_blank" + rel="noopener noreferrer" + className="text-xs text-primary hover:underline shrink-0" + > + Install & sign in + </a> + )} + </div> + ) +} + +function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { + const [enabled, setEnabled] = useState(false) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [status, setStatus] = useState<CodeModeAgentStatus | null>(null) + const [statusLoading, setStatusLoading] = useState(false) + + const loadStatus = useCallback(async () => { + setStatusLoading(true) + try { + const result = await window.ipc.invoke("codeMode:checkAgentStatus", null) + setStatus(result) + } catch { + setStatus(null) + } finally { + setStatusLoading(false) + } + }, []) + + useEffect(() => { + if (!dialogOpen) return + let cancelled = false + async function load() { + setLoading(true) + try { + const result = await window.ipc.invoke("codeMode:getConfig", null) + if (!cancelled) setEnabled(result.enabled) + } catch { + if (!cancelled) setEnabled(false) + } finally { + if (!cancelled) setLoading(false) + } + } + load() + loadStatus() + return () => { cancelled = true } + }, [dialogOpen, loadStatus]) + + const handleToggle = useCallback(async (next: boolean) => { + setSaving(true) + setEnabled(next) + try { + await window.ipc.invoke("codeMode:setConfig", { enabled: next }) + window.dispatchEvent(new Event("code-mode-config-changed")) + toast.success(next ? "Code mode enabled" : "Code mode disabled") + } catch { + setEnabled(!next) + toast.error("Failed to update code mode") + } finally { + setSaving(false) + } + }, []) + + const anyReady = status?.claude.installed && status?.claude.signedIn + || status?.codex.installed && status?.codex.signedIn + + if (loading) { + return ( + <div className="h-full flex items-center justify-center text-muted-foreground text-sm"> + <Loader2 className="size-4 animate-spin mr-2" /> + Loading... + </div> + ) + } + + return ( + <div className="space-y-5"> + <div className="space-y-2 text-sm text-muted-foreground leading-relaxed"> + <p> + <strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks + to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running + on your machine. Pick the agent inline from the composer; the assistant calls it via + <code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">acpx</code> + and streams results back into chat. + </p> + <p> + Requires an active <strong className="text-foreground">Claude Code</strong> subscription or + a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both. + </p> + </div> + + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent status</span> + <button + onClick={() => { void loadStatus() }} + disabled={statusLoading} + className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" + > + {statusLoading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} + Re-check + </button> + </div> + <div className="space-y-2"> + <AgentStatusRow + name="Claude Code" + installLink="https://claude.ai/code" + signInCommand="claude login" + status={status?.claude ?? null} + /> + <AgentStatusRow + name="Codex" + installLink="https://developers.openai.com/codex/cli" + signInCommand="codex login" + status={status?.codex ?? null} + /> + </div> + </div> + + <div className="rounded-md border px-3 py-3 flex items-start gap-3"> + <div className="flex-1 min-w-0"> + <div className="text-sm font-medium">Enable code mode</div> + <div className="text-xs text-muted-foreground mt-0.5"> + Shows the code mode chip in the composer and lets the assistant delegate to your installed agents. + </div> + </div> + <Switch + checked={enabled} + onCheckedChange={handleToggle} + disabled={saving} + /> + </div> + + {enabled && status && !anyReady && ( + <div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs"> + <AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" /> + <div className="text-amber-900 dark:text-amber-200"> + Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription + account, then click Re-check. + </div> + </div> + )} + </div> + ) +} + // --- Main Settings Dialog --- export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) { @@ -1695,7 +1893,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control } const loadConfig = useCallback(async (tab: ConfigTab) => { - if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help") return + if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return const tabConfig = tabs.find((t) => t.id === tab)! if (!tabConfig.path) return setLoading(true) @@ -1803,7 +2001,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control </div> {/* Content */} - <div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}> + <div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}> {activeTab === "account" ? ( <AccountSettings dialogOpen={open} /> ) : activeTab === "connections" ? ( @@ -1828,6 +2026,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control <AppearanceSettings /> ) : activeTab === "help" ? ( <HelpSettings /> + ) : activeTab === "code-mode" ? ( + <CodeModeSettings dialogOpen={open} /> ) : loading ? ( <div className="h-full flex items-center justify-center text-muted-foreground text-sm"> Loading... diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 41344107..bbf1cde2 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -517,9 +517,41 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = { * For builtin tools, returns a static friendly name (e.g., "Reading file"). * Falls back to the raw tool name if no mapping exists. */ +// Phrases shown while a code-mode task is running. They advance over time (5s +// each) to read as progress, then hold on the last one until the task finishes. +const CODE_MODE_RUNNING_LABELS = [ + 'Working on the task…', + 'Inspecting the project…', + 'Digging into the code…', + 'Figuring it out…', + 'Making the changes…', + 'Wiring things up…', + 'Putting it together…', +] +const CODE_MODE_LABEL_INTERVAL_MS = 5000 + +// Detect acpx coding-agent invocations (code mode) and produce a status-aware +// label, e.g. "Working on the task…" → "Completed the task". +export const getCodeModeCommandLabel = (tool: ToolCall): string | null => { + if (tool.name !== 'executeCommand') return null + const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined + const command = typeof input?.command === 'string' ? input.command : '' + const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/) + if (!match) return null + if (tool.status === 'error') return `Couldn't complete the task` + if (tool.status === 'completed') return `Completed the task` + // Advance through the phrases from the tool's start, holding on the last. + const elapsed = Math.max(0, Date.now() - tool.timestamp) + const step = Math.floor(elapsed / CODE_MODE_LABEL_INTERVAL_MS) + const idx = Math.min(step, CODE_MODE_RUNNING_LABELS.length - 1) + return CODE_MODE_RUNNING_LABELS[idx] +} + export const getToolDisplayName = (tool: ToolCall): string => { const browserLabel = getBrowserControlLabel(tool) if (browserLabel) return browserLabel + const codeModeLabel = getCodeModeCommandLabel(tool) + if (codeModeLabel) return codeModeLabel const composioData = getComposioActionCardData(tool) if (composioData) return composioData.label return TOOL_DISPLAY_NAMES[tool.name] || tool.name diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index f0d867bd..3146101e 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -392,9 +392,10 @@ export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<T case "builtin": { if (t.name === "ask-human") { return tool({ - description: "Ask a human before proceeding", + description: "Ask a human before proceeding. Optionally pass `options` (an array of short button labels) to render the question as a one-click choice; the user's response will be the chosen label verbatim.", inputSchema: z.object({ question: z.string().describe("The question to ask the human"), + options: z.array(z.string()).optional().describe("Optional short button labels (2-4 recommended). If provided, the user picks one with a single click instead of typing. The response you receive will be the chosen label."), }), }); } @@ -1065,6 +1066,7 @@ export async function* streamAgent({ let voiceInput = false; let voiceOutput: 'summary' | 'full' | null = null; let searchEnabled = false; + let codeMode: 'claude' | 'codex' | null = null; let middlePaneContext: | { kind: 'note'; path: string; content: string } | { kind: 'browser'; url: string; title: string } @@ -1213,6 +1215,9 @@ export async function* streamAgent({ if (msg.searchEnabled) { searchEnabled = true; } + // Code mode is per-message: latest message decides whether the assistant + // should route coding work through the code-with-agents skill / chosen agent. + codeMode = msg.codeMode ?? null; if (msg.voiceOutput) { voiceOutput = msg.voiceOutput; } @@ -1316,6 +1321,50 @@ Do not announce the work directory unless it's relevant. Just use it.`; loopLogger.log('search enabled, injecting search prompt'); instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`; } + if (codeMode) { + loopLogger.log('code mode enabled, injecting coding-agent context', codeMode); + const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex'; + const otherAgent = codeMode === 'claude' ? 'codex' : 'claude'; + const otherDisplay = codeMode === 'claude' ? 'Codex' : 'Claude Code'; + // Deterministic, per-chat session name so the coding agent keeps + // context across the user's requests within this chat. Reusing the + // same -s <name> resumes the session; the first call creates it. + const sessionName = `rowboat-${runId}`; + instructionsWithDateTime += `\n\n# Code Mode (Active) — Default agent: ${agentDisplay} +The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). Use this as the **default** agent for coding tasks in this turn. + +**The user can override the agent at any time, two ways:** +1. By toggling the chip in the composer (preferred). +2. By asking you directly in chat ("use codex", "switch to claude", "do this with ${otherDisplay}", etc.). When the user explicitly asks to use a different agent in the current message, honor that — use \`${otherAgent}\` instead of \`${codeMode}\` for this turn, and briefly mention they can also toggle it via the chip for stickiness. + +**Persistent session for this chat — session name: \`${sessionName}\`.** This chat uses one named agent session so the agent keeps context across your requests. The session must exist before it can be prompted (\`-s\` only resumes; it does not create). + +**1. First coding action in this chat — ensure the session exists:** + +\`\`\` +npx acpx@latest --approve-all --cwd <workdir> <agent> sessions ensure --name ${sessionName} +\`\`\` + +(\`ensure\` creates the session if missing and reuses it if it already exists — safe to call when reopening this chat later.) + +**2. Then run the prompt:** + +\`\`\` +npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>" +\`\`\` + +**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):** + +\`\`\` +npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>" +\`\`\` + +Run these as **separate, sequential** \`executeCommand\` calls — issue the \`sessions ensure\` call first and WAIT for it to finish, then issue the prompt call. Do NOT fire both in the same turn / batch. + +Where \`<agent>\` is either \`claude\` or \`codex\` — pick based on (in priority order): an explicit in-chat override → the chip setting (\`${codeMode}\`). Use \`${sessionName}\` exactly — do NOT invent a different name, and do NOT use \`exec\` (it is one-shot and forgets). + +If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`; + } let streamError: string | null = null; for await (const event of streamLlm( model, @@ -1371,11 +1420,16 @@ Do not announce the work directory unless it's relevant. Just use it.`; const underlyingTool = agent.tools![part.toolName]; if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") { loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId); + const rawOptions = (part.arguments as { options?: unknown }).options; + const options = Array.isArray(rawOptions) + ? rawOptions.filter((o): o is string => typeof o === 'string' && o.trim().length > 0) + : undefined; yield* processEvent({ runId, type: "ask-human-request", toolCallId: part.toolCallId, query: part.arguments.question, + ...(options && options.length > 0 ? { options } : {}), subflow: [], }); } diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index b0d1bcbb..b3611fe4 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -3,6 +3,8 @@ import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js import { composioAccountsRepo } from "../../composio/repo.js"; import { isConfigured as isComposioConfigured } from "../../composio/client.js"; import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js"; +import container from "../../di/container.js"; +import type { ICodeModeConfigRepo } from "../../code-mode/repo.js"; const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); @@ -29,7 +31,7 @@ Load the \`composio-integration\` skill when the user asks to interact with any `; } -function buildStaticInstructions(composioEnabled: boolean, catalog: string): string { +function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): string { // Conditionally include Composio-related instruction sections const emailDraftSuffix = composioEnabled ? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.` @@ -80,7 +82,9 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, **Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. **This applies even for small one-off edits** — the skill carries the canonical *terse-and-scannable* writing style for the knowledge base, and that style applies whether you're authoring a fresh note or fixing a single section. Load it before writing anything into a note. -**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task, load the \`code-with-agents\` skill first. It provides guidance for delegating coding work to Claude Code or Codex via acpx. +${codeModeEnabled + ? `**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task — **including simple things like "create a .c file" or "write a hello-world in Python"** — your FIRST action MUST be \`loadSkill('code-with-agents')\`. Do NOT reach for \`executeCommand\` (PowerShell / bash / shell) or any workspace file tool to do code work yourself before loading this skill. The skill decides whether to delegate to Claude Code / Codex (via acpx) or hand control back to you, and it presents the user a one-click choice when needed. Paths outside the Rowboat workspace root (e.g. \`G:/...\`, \`~/projects/...\`) are NORMAL for coding tasks — do NOT raise "outside workspace" concerns or fall back to your own tools.` + : `**Code with Agents (disabled):** Code mode is currently OFF in the user's settings. Do NOT load \`code-with-agents\` and do NOT call acpx. Handle coding requests yourself with your normal tools if you can. After answering, add a final line letting the user know they can delegate coding to Claude Code or Codex by enabling Code Mode in Settings → Code Mode.`} **App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view. @@ -312,30 +316,29 @@ Never output raw file paths in plain text when they could be wrapped in a filepa /** Keep backward-compatible export for any external consumers */ export const CopilotInstructions = buildStaticInstructions(true, skillCatalog); -/** - * Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache(). - */ let cachedInstructions: string | null = null; -/** - * Invalidate the cached instructions so the next buildCopilotInstructions() call - * regenerates the Composio section. Call this after connecting/disconnecting a toolkit. - */ export function invalidateCopilotInstructionsCache(): void { cachedInstructions = null; } -/** - * Build full copilot instructions with dynamic Composio tools section. - * Results are cached and reused until invalidated via invalidateCopilotInstructionsCache(). - */ export async function buildCopilotInstructions(): Promise<string> { if (cachedInstructions !== null) return cachedInstructions; const composioEnabled = await isComposioConfigured(); - const catalog = composioEnabled - ? skillCatalog - : buildSkillCatalog({ excludeIds: ['composio-integration'] }); - const baseInstructions = buildStaticInstructions(composioEnabled, catalog); + let codeModeEnabled = false; + try { + const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo'); + codeModeEnabled = (await repo.getConfig()).enabled; + } catch { + // repo unavailable — default to disabled + } + const excludeIds: string[] = []; + if (!composioEnabled) excludeIds.push('composio-integration'); + if (!codeModeEnabled) excludeIds.push('code-with-agents'); + const catalog = excludeIds.length > 0 + ? buildSkillCatalog({ excludeIds }) + : skillCatalog; + const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled); const composioPrompt = await getComposioToolsPrompt(); cachedInstructions = composioPrompt ? baseInstructions + '\n' + composioPrompt diff --git a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts index c2879228..d8e81a58 100644 --- a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts @@ -1,90 +1,140 @@ export const skill = String.raw` # Code with Agents Skill -Use this skill when the user asks you to write code, build a project, create scripts, fix bugs, or do any software development task that should be delegated to a coding agent (Claude Code or Codex). +Use this skill whenever the user asks you to write code, build a project, create scripts, fix bugs, read/explain code, or do any software development task — even simple file creations like "make a .c file". -## Important: delegate ALL coding work +Coding agents operate on **arbitrary file paths** (including paths outside the Rowboat workspace root, like \`G:/4th sem/CN\` or \`~/projects/foo\`). Do NOT raise "outside workspace" concerns, and do NOT fall back to your own \`executeCommand\` (PowerShell / bash) or workspace file tools to do code work yourself. -Once the user has chosen to use Claude Code or Codex, you MUST delegate ALL code-related tasks to the coding agent. This includes: -- Writing, editing, or refactoring code -- Reading, summarizing, or explaining code -- Debugging and fixing bugs -- Running tests or build commands -- Exploring project structure -- Any other task that involves interacting with a codebase +--- -Do NOT attempt to do any of these yourself — no reading files, no running commands, no writing code. You are the coordinator; the coding agent does the work. Your job is to translate the user's request into a clear prompt and pass it to the agent. +## STEP 1 — MANDATORY FIRST ACTION -## Prerequisites +Look in your **system context** for a section titled **"# Code Mode (Active)"**. -The user must have one of the following installed on their machine: -- **Claude Code** — https://claude.ai/code -- **Codex** — https://codex.openai.com +### Case A — "# Code Mode (Active)" IS present -These are external tools that you cannot install for the user. +Code mode is on and the user has selected an agent. Skip directly to Step 2. Do NOT call ask-human. -## Workflow +### Case B — "# Code Mode (Active)" is NOT present -### Step 1: Gather requirements +Your **very next tool call MUST be \`ask-human\`** with options. Do not write any explanation text first. Do not describe a plan. Do not check the workspace boundary. Just call: -Before running anything, confirm the following with the user: +\`\`\` +ask-human({ + question: "How should I handle this coding request?", + options: [ + "Use code mode (Claude Code)", + "Use code mode (Codex)", + "Continue with default Rowboat" + ] +}) +\`\`\` -1. **Working directory** — Ask which folder the code should be written in, unless the user has already specified it. Example: "Which folder should I work in?" -2. **Agent choice** — Ask whether to use **Claude Code** or **Codex**. Mention that the chosen agent must already be installed on their machine. +This is non-negotiable. The user gets clickable buttons. Free-text "which agent?" questions are forbidden here. -### Step 2: Confirm execution plan +**Branch on the response:** +- "Use code mode (Claude Code)" → proceed to Step 2 with agent = \`claude\`. +- "Use code mode (Codex)" → proceed to Step 2 with agent = \`codex\`. +- "Continue with default Rowboat" → ABANDON this skill. Handle the request yourself using your own tools (workspace file tools, \`executeCommand\` shell, etc.). The rest of this skill does not apply for this turn. -Once you know the folder and agent, tell the user: +--- -> I'll use [Claude Code / Codex] to [description of the task] in \`[folder]\`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything. +## STEP 2 — Resolve workdir, confirm, execute -### Step 3: Execute with acpx +**Resolve the workdir** (in this priority order): +1. A path the user named in their original message (e.g. \`G:/4th sem/CN\`). +2. The path from a "# User Work Directory" block in your context. +3. Ask once in plain text: "Which folder should I work in?" -Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is: +**State your intent in one line, then execute immediately — do NOT wait for a "yes".** The \`executeCommand\` call surfaces a permission card that is itself the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like: -**For Claude Code:** -` + "`" + ` -npx acpx@latest --approve-all --cwd <folder> claude exec "<prompt>" -` + "`" + ` +> Using [Claude Code / Codex] to [task description] in \`[folder]\`. -**For Codex:** -` + "`" + ` -npx acpx@latest --approve-all --cwd <folder> codex exec "<prompt>" -` + "`" + ` +…and then immediately make the \`executeCommand\` call in the same turn. + +**Execute** with the chosen agent using a **persistent named session** so follow-up coding requests in this chat resume the same agent and keep context. + +Pick \`<agent>\` (\`claude\` or \`codex\`) by, in priority order: +- An explicit in-chat override from the user this turn ("use codex", "switch to claude") — honor it. +- The agent chosen in Step 1 / the "# Code Mode (Active)" block. + +Pick \`<session-name>\` — **stable for this whole chat**: +- If the "# Code Mode (Active)" block gives a session name (e.g. \`rowboat-<runId>\`), use that exact name. +- Otherwise pick one short, kebab-case name and **reuse it for every coding call this turn and in follow-ups** — never a new name each time. + +**\`-s\` resumes an existing session; it does NOT create one.** So ensure the session exists once at the start, then prompt: + +**1. First coding action in this chat — ensure the session exists:** + +\`\`\` +npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name> +\`\`\` + +(\`ensure\` creates the session if missing and reuses it if it already exists — so reopening this chat later just resumes the same session instead of erroring.) + +**2. Then run the prompt:** + +\`\`\` +npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>" +\`\`\` + +**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):** + +\`\`\` +npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>" +\`\`\` + +**Run steps 1 and 2 as separate, sequential \`executeCommand\` calls.** Issue the \`sessions ensure\` call FIRST, wait for it to finish, and only THEN issue the prompt call. Do NOT fire both in the same turn / batch — each must surface and complete its own permission + command block before the next begins. + +Do NOT use \`exec\` — it is one-shot and forgets everything. + +Concrete example: + +\`\`\` +# First coding message in the chat — ensure the session, then prompt: +npx acpx@latest --approve-all --cwd "G:\\Blogging\\myblog" claude sessions ensure --name diskspace-check +npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Check the system disk space and report total, used, and free space." + +# Follow-up in the same chat — reuse the session, no create: +npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Summarize what we did and the final findings." +\`\`\` ### Critical: flag order -The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order: +\`--approve-all\`, \`--timeout\`, and \`--cwd\` are GLOBAL flags and MUST appear BEFORE the agent name. \`sessions ensure --name <name>\` and \`-s <session-name>\` come AFTER the agent name: -` + "`" + ` -npx acpx@latest [global flags] <agent> exec "<prompt>" -` + "`" + ` +- ✓ Correct: \`npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"\` +- ✗ Wrong: \`npx acpx@latest <agent> --approve-all -s <name> "..."\` (will fail) -**Correct:** -` + "`" + ` -npx acpx@latest --approve-all --cwd ~/projects/myapp claude exec "fix the bug" -` + "`" + ` +### Writing good prompts for the agent -**Wrong (will fail):** -` + "`" + ` -npx acpx@latest claude --approve-all exec "fix the bug" -` + "`" + ` +- Be specific: file names, function signatures, expected behavior. +- Mention constraints (language, framework, style). +- Expand short user requests into clear, actionable prompts. -### Writing good prompts +--- -When constructing the prompt for the coding agent: -- Be specific and detailed about what to build or fix -- Include file names, function signatures, and expected behavior -- Mention any constraints (language, framework, style) -- If the user gave you a short request, expand it into a clear, actionable prompt for the agent +## STEP 3 — Report results -### Step 4: Report results +After the command finishes: +- Pass through the coding agent's summary as-is. Do not rewrite. +- Refer to file paths as plain text. Do NOT use \`\`\`file:path\`\`\` reference blocks. (This overrides the global "always wrap paths in filepath blocks" rule — for code-mode output, plain text.) +- Only add your own explanation if the command failed (non-zero exit): + - Exit code 5 — permissions were denied (shouldn't happen with \`--approve-all\`; flag it). + - Exit code 4 / "No acpx session found" — the \`-s <session-name>\` session doesn't exist yet. Create it once with \`npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name>\`, then retry the prompt. (\`-s\` only resumes; it never creates.) + - "command not found" / agent not installed, or an auth/sign-in error — the agent isn't set up. Tell the user to install or sign in to the agent via **Settings → Code Mode**, where Rowboat shows the install and sign-in status. -After the command finishes, look for the summary that the coding agent produced at the end of its output and pass that along to the user as-is. Do not rewrite or add to it. Only add your own explanation if the command failed or the exit code is non-zero. +--- -Do NOT use file reference blocks (e.g. \`\`\`file:path/to/file\`\`\`) when mentioning code files — they may not open correctly. Just refer to file paths as plain text. +## Once delegating: delegate fully -- If the exit code is 5, it means permissions were denied — this should not happen with \`--approve-all\`, but if it does, let the user know +After Step 2 fires, delegate ALL related coding tasks for this turn to the coding agent — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work. + +## Prerequisites (informational) + +The user must have one of these installed locally — these are external tools you cannot install: +- Claude Code — https://claude.ai/code +- Codex — https://codex.openai.com `; export default skill; 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 e3622b01..9bfb4250 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -95,21 +95,47 @@ const LLMPARSE_MIME_TYPES: Record<string, string> = { // When the LLM invokes acpx via executeCommand, pre-resolve claude's real .exe // from the npm-shim layout and inject it via env so the bridge can spawn it. function resolveClaudeExeOnWindows(): string | undefined { - const pathDirs = (process.env.PATH ?? '').split(';'); - for (const dir of pathDirs) { - const trimmed = dir.trim(); - if (!trimmed) continue; - const cmdPath = path.join(trimmed, 'claude.cmd'); - if (!existsSync(cmdPath)) continue; - const exeFromLayout = path.join(trimmed, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'); + // Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global + // bin dirs. Electron's runtime PATH can omit these even when the user's shell + // includes them, which would otherwise leave us unable to find claude.exe and + // force a fallback to claude.cmd (which Node refuses to spawn — EINVAL). + const home = process.env.USERPROFILE ?? ''; + const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming')); + const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local')); + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const knownDirs = [ + appData && path.join(appData, 'npm'), + localAppData && path.join(localAppData, 'npm'), + appData && path.join(appData, 'pnpm'), + localAppData && path.join(localAppData, 'pnpm'), + home && path.join(home, '.volta', 'bin'), + path.join(programFiles, 'nodejs'), + ].filter(Boolean) as string[]; + + const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean); + const seen = new Set<string>(); + const candidates = [...pathDirs, ...knownDirs].filter((d) => { + const key = d.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + for (const dir of candidates) { + // Direct npm-shim layout: <dir>\node_modules\@anthropic-ai\claude-code\bin\claude.exe + const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'); if (existsSync(exeFromLayout)) return exeFromLayout; + + // Otherwise parse the claude.cmd shim for the real exe path. + const cmdPath = path.join(dir, 'claude.cmd'); + if (!existsSync(cmdPath)) continue; try { const content = readFileSync(cmdPath, 'utf-8'); const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i); if (absMatch && existsSync(absMatch[0])) return absMatch[0]; const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i); if (relMatch) { - const resolved = path.join(trimmed, relMatch[1]); + const resolved = path.join(dir, relMatch[1]); if (existsSync(resolved)) return resolved; } } catch { @@ -825,7 +851,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = { } // Fallback to original for backward compatibility - const result = await executeCommand(command, { cwd: workingDir }); + const result = await executeCommand(command, { cwd: workingDir, env: envOverride }); return { success: result.exitCode === 0, diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 150c8f72..6be3c0e6 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -80,6 +80,7 @@ export async function executeCommand( cwd?: string; timeout?: number; // timeout in milliseconds maxBuffer?: number; // max buffer size in bytes + env?: NodeJS.ProcessEnv; // override environment (e.g. CLAUDE_CODE_EXECUTABLE for acpx) } ): Promise<CommandResult> { try { @@ -89,6 +90,7 @@ export async function executeCommand( timeout: options?.timeout, maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB shell, + env: options?.env, }); return { diff --git a/apps/x/packages/core/src/application/lib/message-queue.ts b/apps/x/packages/core/src/application/lib/message-queue.ts index b3d2affa..a513b3ac 100644 --- a/apps/x/packages/core/src/application/lib/message-queue.ts +++ b/apps/x/packages/core/src/application/lib/message-queue.ts @@ -8,17 +8,20 @@ export type MiddlePaneContext = | { kind: 'note'; path: string; content: string } | { kind: 'browser'; url: string; title: string }; +export type CodeMode = 'claude' | 'codex'; + type EnqueuedMessage = { messageId: string; message: UserMessageContentType; voiceInput?: boolean; voiceOutput?: VoiceOutputMode; searchEnabled?: boolean; + codeMode?: CodeMode; middlePaneContext?: MiddlePaneContext; }; export interface IMessageQueue { - enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string>; + enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string>; dequeue(runId: string): Promise<EnqueuedMessage | null>; } @@ -34,7 +37,7 @@ export class InMemoryMessageQueue implements IMessageQueue { this.idGenerator = idGenerator; } - async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> { + async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string> { if (!this.store[runId]) { this.store[runId] = []; } @@ -45,6 +48,7 @@ export class InMemoryMessageQueue implements IMessageQueue { voiceInput, voiceOutput, searchEnabled, + codeMode, middlePaneContext, }); return id; diff --git a/apps/x/packages/core/src/code-mode/index.ts b/apps/x/packages/core/src/code-mode/index.ts new file mode 100644 index 00000000..bdf2eecb --- /dev/null +++ b/apps/x/packages/core/src/code-mode/index.ts @@ -0,0 +1,3 @@ +export { CodeModeConfig, CodeModeAgentStatus, AgentStatus } from './types.js'; +export { FSCodeModeConfigRepo, type ICodeModeConfigRepo } from './repo.js'; +export { checkCodeModeAgentStatus } from './status.js'; diff --git a/apps/x/packages/core/src/code-mode/repo.ts b/apps/x/packages/core/src/code-mode/repo.ts new file mode 100644 index 00000000..dd318b34 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/repo.ts @@ -0,0 +1,42 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; +import { CodeModeConfig } from './types.js'; + +export interface ICodeModeConfigRepo { + getConfig(): Promise<CodeModeConfig>; + setConfig(config: CodeModeConfig): Promise<void>; +} + +export class FSCodeModeConfigRepo implements ICodeModeConfigRepo { + private readonly configPath = path.join(WorkDir, 'config', 'code-mode.json'); + private readonly defaultConfig: CodeModeConfig = { enabled: false }; + + constructor() { + this.ensureConfigFile(); + } + + private async ensureConfigFile(): Promise<void> { + try { + await fs.access(this.configPath); + } catch { + await fs.mkdir(path.dirname(this.configPath), { recursive: true }); + await fs.writeFile(this.configPath, JSON.stringify(this.defaultConfig, null, 2)); + } + } + + async getConfig(): Promise<CodeModeConfig> { + try { + const content = await fs.readFile(this.configPath, 'utf8'); + return CodeModeConfig.parse(JSON.parse(content)); + } catch { + return this.defaultConfig; + } + } + + async setConfig(config: CodeModeConfig): Promise<void> { + const validated = CodeModeConfig.parse(config); + await fs.mkdir(path.dirname(this.configPath), { recursive: true }); + await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2)); + } +} diff --git a/apps/x/packages/core/src/code-mode/status.ts b/apps/x/packages/core/src/code-mode/status.ts new file mode 100644 index 00000000..3858708b --- /dev/null +++ b/apps/x/packages/core/src/code-mode/status.ts @@ -0,0 +1,199 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; +import { existsSync } from 'fs'; +import { CodeModeAgentStatus } from './types.js'; + +const execAsync = promisify(exec); + +// Where claude.cmd / codex.cmd typically live when installed via npm/pnpm/yarn. +// We scan these directly because Electron's spawned shell sometimes doesn't +// inherit the user's full PATH (especially on macOS GUI launches, and even on +// Windows when global npm prefix isn't propagated to system PATH). +function commonInstallPaths(binary: string): string[] { + const home = os.homedir(); + if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + return [ + path.join(appData, 'npm', `${binary}.cmd`), + path.join(appData, 'npm', `${binary}.exe`), + path.join(localAppData, 'npm', `${binary}.cmd`), + path.join(localAppData, 'pnpm', `${binary}.cmd`), + path.join(home, 'AppData', 'Roaming', 'pnpm', `${binary}.cmd`), + path.join(programFiles, 'nodejs', `${binary}.cmd`), + path.join(home, '.volta', 'bin', `${binary}.cmd`), + ]; + } + return [ + '/usr/local/bin', + '/opt/homebrew/bin', // Apple Silicon Homebrew + '/usr/bin', + path.join(home, '.npm-global', 'bin'), + path.join(home, '.local', 'bin'), + path.join(home, '.volta', 'bin'), + path.join(home, '.nvm', 'versions', 'node'), // partial; nvm has versioned subdirs + path.join(home, 'bin'), + ].map(dir => path.join(dir, binary)); +} + +async function probeShell(binary: string): Promise<boolean> { + try { + if (process.platform === 'win32') { + const { stdout } = await execAsync(`where ${binary}`, { timeout: 5000 }); + return stdout.trim().length > 0; + } + // Login shell so ~/.zprofile / ~/.bashrc PATH additions are visible — + // essential for Homebrew, nvm, asdf, volta installs on macOS GUI launches. + const { stdout } = await execAsync(`/bin/sh -lc 'command -v ${binary}'`, { timeout: 5000 }); + return stdout.trim().length > 0; + } catch { + return false; + } +} + +async function isInstalled(binary: string): Promise<boolean> { + if (await probeShell(binary)) return true; + // Fallback: scan well-known install locations directly. + for (const candidate of commonInstallPaths(binary)) { + if (existsSync(candidate)) return true; + } + return false; +} + +function decodeJwtPayload(token: string): Record<string, unknown> | null { + try { + const parts = token.split('.'); + if (parts.length < 2) return null; + const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4)); + const json = Buffer.from(padded + pad, 'base64').toString('utf-8'); + const parsed = JSON.parse(json); + return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null; + } catch { + return null; + } +} + +// Given the raw credentials JSON (from a file or the macOS Keychain), decide +// whether it represents a usable signed-in state: a valid API key, an unexpired +// access token, or a refresh token (which can mint a new access token). +function isClaudeCredentialSignedIn(raw: string): boolean { + try { + const parsed = JSON.parse(raw) as Record<string, unknown>; + + const oauth = parsed.claudeAiOauth as Record<string, unknown> | undefined; + if (oauth) { + const access = typeof oauth.accessToken === 'string' ? oauth.accessToken : ''; + const refresh = typeof oauth.refreshToken === 'string' ? oauth.refreshToken : ''; + if (refresh.length > 0) return true; + if (access.length > 0) { + if (typeof oauth.expiresAt === 'number' && oauth.expiresAt > 0 && oauth.expiresAt < Date.now()) { + return false; + } + return true; + } + } + + if (typeof parsed.apiKey === 'string' && parsed.apiKey.length > 10) return true; + if (typeof parsed.accessToken === 'string' && parsed.accessToken.length > 10) return true; + } catch { + // malformed JSON + } + return false; +} + +// Reads Claude Code's credentials from the macOS login Keychain, where the +// CLI stores them on macOS (service "Claude Code-credentials"). On Linux/Windows +// it uses the ~/.claude/.credentials.json file instead, so this is a no-op there. +// +// Caveats: +// - The first read by this app (a different binary than the `claude` CLI that +// created the item) triggers a one-time macOS authorization dialog; the user +// must "Always Allow". Headless/SSH sessions can't show it and will fail. +// - If CLAUDE_CONFIG_DIR is set, Claude appends a SHA-256 suffix to the service +// name, which this lookup won't match — such setups usually keep the file too. +async function readClaudeKeychainCredential(): Promise<string | null> { + if (process.platform !== 'darwin') return null; + try { + const { stdout } = await execAsync( + `security find-generic-password -s "Claude Code-credentials" -w`, + { timeout: 5000 }, + ); + const out = stdout.trim(); + return out.length > 0 ? out : null; + } catch { + // not present in keychain + return null; + } +} + +// Validates Claude Code auth. On macOS the credentials live in the login +// Keychain; on Linux/Windows in ~/.claude/.credentials.json (or ~/.config +// fallback). We check both so detection works across platforms. +async function checkClaudeSignedIn(): Promise<boolean> { + const home = os.homedir(); + const candidates = [ + path.join(home, '.claude', '.credentials.json'), + path.join(home, '.config', 'claude', '.credentials.json'), + ]; + for (const full of candidates) { + try { + const raw = await fs.readFile(full, 'utf-8'); + if (isClaudeCredentialSignedIn(raw)) return true; + } catch { + // try next candidate + } + } + + // macOS: credentials are stored in the Keychain rather than on disk. + const keychainRaw = await readClaudeKeychainCredential(); + if (keychainRaw && isClaudeCredentialSignedIn(keychainRaw)) return true; + + return false; +} + +// Validates Codex auth at ~/.codex/auth.json on all platforms. +// Considered signed in if API key set, or a refresh_token / access_token +// exists. id_token expiry is intentionally NOT used as a rejection signal — +// id_tokens are short-lived (~1h) but refresh_tokens persist for weeks. +async function checkCodexSignedIn(): Promise<boolean> { + const home = os.homedir(); + const full = path.join(home, '.codex', 'auth.json'); + try { + const raw = await fs.readFile(full, 'utf-8'); + const parsed = JSON.parse(raw) as Record<string, unknown>; + + if (typeof parsed.OPENAI_API_KEY === 'string' && parsed.OPENAI_API_KEY.length > 10) return true; + + const tokens = parsed.tokens as Record<string, unknown> | undefined; + if (tokens) { + const refresh = typeof tokens.refresh_token === 'string' ? tokens.refresh_token : ''; + const access = typeof tokens.access_token === 'string' ? tokens.access_token : ''; + const id = typeof tokens.id_token === 'string' ? tokens.id_token : ''; + if (refresh.length > 0 || access.length > 0 || id.length > 0) return true; + } + } catch { + // file missing or unreadable + } + return false; +} + +// Exported for diagnostics — silenced unused-var warning by re-export only. +export { decodeJwtPayload }; + +export async function checkCodeModeAgentStatus(): Promise<CodeModeAgentStatus> { + const [claudeInstalled, codexInstalled, claudeSignedIn, codexSignedIn] = await Promise.all([ + isInstalled('claude'), + isInstalled('codex'), + checkClaudeSignedIn(), + checkCodexSignedIn(), + ]); + return { + claude: { installed: claudeInstalled, signedIn: claudeSignedIn }, + codex: { installed: codexInstalled, signedIn: codexSignedIn }, + }; +} diff --git a/apps/x/packages/core/src/code-mode/types.ts b/apps/x/packages/core/src/code-mode/types.ts new file mode 100644 index 00000000..57a3158f --- /dev/null +++ b/apps/x/packages/core/src/code-mode/types.ts @@ -0,0 +1,18 @@ +import z from "zod"; + +export const CodeModeConfig = z.object({ + enabled: z.boolean(), +}); +export type CodeModeConfig = z.infer<typeof CodeModeConfig>; + +export const AgentStatus = z.object({ + installed: z.boolean(), + signedIn: z.boolean(), +}); +export type AgentStatus = z.infer<typeof AgentStatus>; + +export const CodeModeAgentStatus = z.object({ + claude: AgentStatus, + codex: AgentStatus, +}); +export type CodeModeAgentStatus = z.infer<typeof CodeModeAgentStatus>; diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 9382de8b..f452105a 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -11,6 +11,7 @@ import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js"; import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js"; import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js"; import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js"; +import { FSCodeModeConfigRepo, ICodeModeConfigRepo } from "../code-mode/repo.js"; import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js"; import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js"; import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; @@ -38,6 +39,7 @@ container.register({ oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(), clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(), granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(), + codeModeConfigRepo: asClass<ICodeModeConfigRepo>(FSCodeModeConfigRepo).singleton(), agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(), agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(), slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(), diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index e10bf575..c316cd3b 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -39,9 +39,9 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise return run; } -export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> { +export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex'): Promise<string> { const queue = container.resolve<IMessageQueue>('messageQueue'); - const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext); + const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode); const runtime = container.resolve<IAgentRuntime>('agentRuntime'); runtime.trigger(runId); return id; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index d0cee9ca..d42e9935 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -228,6 +228,7 @@ const ipcSchemas = { voiceInput: z.boolean().optional(), voiceOutput: z.enum(['summary', 'full']).optional(), searchEnabled: z.boolean().optional(), + codeMode: z.enum(['claude', 'codex']).optional(), middlePaneContext: z.discriminatedUnion('kind', [ z.object({ kind: z.literal('note'), @@ -424,6 +425,27 @@ const ipcSchemas = { enabled: z.boolean(), }), }, + 'codeMode:getConfig': { + req: z.null(), + res: z.object({ + enabled: z.boolean(), + }), + }, + 'codeMode:setConfig': { + req: z.object({ + enabled: z.boolean(), + }), + res: z.object({ + success: z.literal(true), + }), + }, + 'codeMode:checkAgentStatus': { + req: z.null(), + res: z.object({ + claude: z.object({ installed: z.boolean(), signedIn: z.boolean() }), + codex: z.object({ installed: z.boolean(), signedIn: z.boolean() }), + }), + }, 'granola:setConfig': { req: z.object({ enabled: z.boolean(), diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index 5ea0d667..a977db0b 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -75,6 +75,7 @@ export const AskHumanRequestEvent = BaseRunEvent.extend({ type: z.literal("ask-human-request"), toolCallId: z.string(), query: z.string(), + options: z.array(z.string()).optional(), }); export const AskHumanResponseEvent = BaseRunEvent.extend({ From f378c7c604eb4913b94a6a9b7b7d8a75f5daf549 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 19:07:02 +0530 Subject: [PATCH 15/35] Oauth migration (#584) * oauth migration for new scopes * trigger google reconnect popover --- apps/x/apps/main/src/main.ts | 6 +++ apps/x/apps/main/src/oauth-handler.ts | 77 ++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index ab026fff..81d43553 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -51,6 +51,7 @@ import { extractDeepLinkFromArgv, setMainWindowForDeepLinks, } from "./deeplink.js"; +import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; const execAsync = promisify(exec); @@ -351,6 +352,11 @@ app.whenReady().then(async () => { registerConsumer(backgroundTaskEventConsumer); initEventProcessor(); + // If the stored Google grant predates a scope change (only old scopes), + // disconnect it now so the user re-connects with the current scopes before + // any Google sync runs against the stale grant. + await disconnectGoogleIfScopesStale(); + // start gmail sync initGmailSync(); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index ab00ab8c..1048d9b8 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -508,7 +508,7 @@ export async function disconnectProvider(provider: string): Promise<{ success: b if (connection.mode === 'rowboat' && connection.tokens?.access_token) { try { const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`; - const res = await fetch(revokeUrl, { method: 'POST' }); + const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) }); if (!res.ok) { console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`); } @@ -532,6 +532,81 @@ export async function disconnectProvider(provider: string): Promise<{ success: b } } +/** + * Startup migration for Google scope changes. When a connected Google grant was + * issued before a scope was added (e.g. old installs on gmail.readonly that + * never received gmail.modify), invalidate it so the user is prompted to + * reconnect and re-grant with the current scopes. The currently-requested + * scopes in the provider config are the source of truth: a grant missing any + * of them is treated as stale. + * + * We revoke + clear the stale token but DELIBERATELY keep the provider entry + * with an `error` set rather than calling disconnectProvider (which deletes the + * whole entry). The renderer's reconnect prompts — the sidebar "Reconnect your + * accounts" alert and the connectors "Reconnect" row — key off this `error` + * field, not off the connected flag. A fully deleted entry has no error and is + * indistinguishable from "never connected", so no prompt would ever appear. + * + * Tokens with no recorded scopes (very old installs that never persisted them) + * are also treated as stale. Safe to call on every startup — it's a no-op once + * the grant covers all current scopes, and once invalidated the early return on + * the missing token keeps it from re-running until the user reconnects. + */ +export async function disconnectGoogleIfScopesStale(): Promise<void> { + try { + const oauthRepo = getOAuthRepo(); + const connection = await oauthRepo.read('google'); + + // Not connected (or already invalidated) — nothing to migrate. + if (!connection.tokens) { + return; + } + + const providerConfig = await getProviderConfig('google'); + const requiredScopes = providerConfig.scopes ?? []; + if (requiredScopes.length === 0) { + return; + } + + const granted = new Set(connection.tokens.scopes ?? []); + const missingScopes = requiredScopes.filter((scope) => !granted.has(scope)); + if (missingScopes.length === 0) { + return; + } + + console.log( + `[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` + + 'invalidating it so the user is prompted to reconnect with the new scopes.' + ); + + // Best-effort revoke at Google for rowboat-mode grants (mirrors disconnectProvider). + if (connection.mode === 'rowboat' && connection.tokens.access_token) { + try { + const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`; + const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) }); + if (!res.ok) { + console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local invalidation`); + } + } catch (error) { + console.warn('[OAuth] Google revoke failed; continuing with local invalidation:', error); + } + } + + // Drop the stale token but keep the entry with an error so the reconnect + // prompt fires (see the note above). + await oauthRepo.upsert('google', { + tokens: null, + error: 'Google permissions changed. Please reconnect to continue.', + }); + + // Nudge any already-open window to re-read state. The renderer's initial + // mount also re-reads, so the prompt shows even if no window is up yet. + emitOAuthEvent({ provider: 'google', success: false }); + } catch (error) { + console.error('[OAuth] Google scope migration check failed:', error); + } +} + /** * Get access token for a provider (internal use only) * Refreshes token if expired From e7c7d0e90f52819e63e1c859676b03b3e9110f63 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 21:20:57 +0530 Subject: [PATCH 16/35] remove chat options from middle pane --- apps/x/apps/renderer/src/App.tsx | 54 +------------------------------- 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 2bf0c571..5c072b2a 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 { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, Plus, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, 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'; @@ -65,12 +65,6 @@ import { } from "@/components/ui/sidebar" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" import { BillingErrorDialog } from "@/components/billing-error-dialog" @@ -5215,25 +5209,6 @@ function App() { if (tabId === activeChatTabId) return activeChatTabState return chatViewStateByTab[tabId] ?? emptyChatTabState }, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState]) - const activeRunIdForDownload = activeChatTabState.runId - const handleDownloadActiveChatLog = useCallback(async () => { - if (!activeRunIdForDownload) { - toast.error('No chat log available yet') - return - } - - try { - const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunIdForDownload }) - if (result.success) { - toast.success('Chat log saved') - } else if (result.error) { - toast.error(result.error) - } - } catch (err) { - console.error('Download chat log failed:', err) - toast.error('Failed to download chat log') - } - }, [activeRunIdForDownload]) const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null @@ -5421,33 +5396,6 @@ function App() { <TooltipContent side="bottom">New chat</TooltipContent> </Tooltip> )} - <DropdownMenu> - <Tooltip> - <TooltipTrigger asChild> - <DropdownMenuTrigger asChild> - <button - type="button" - className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0" - aria-label="Chat options" - > - <MoreHorizontal className="size-5" /> - </button> - </DropdownMenuTrigger> - </TooltipTrigger> - <TooltipContent side="bottom">Chat options</TooltipContent> - </Tooltip> - <DropdownMenuContent align="end" className="min-w-48"> - <DropdownMenuItem - disabled={!activeRunIdForDownload} - onSelect={() => { - void handleDownloadActiveChatLog() - }} - > - <Bug className="size-4" /> - Download chat log - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> {/* Trailing layout control. Always mounted (just toggled invisible when inactive) so its -webkit-app-region:no-drag rect is stable — a freshly-mounted no-drag button inside the drag-region header From c213274723ec622ca1dcdc1cedbd66513e709322 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 22:33:32 +0530 Subject: [PATCH 17/35] enable coding agents if they are available by default --- apps/x/packages/core/src/code-mode/repo.ts | 29 +++++++++++++--------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/x/packages/core/src/code-mode/repo.ts b/apps/x/packages/core/src/code-mode/repo.ts index dd318b34..78092db8 100644 --- a/apps/x/packages/core/src/code-mode/repo.ts +++ b/apps/x/packages/core/src/code-mode/repo.ts @@ -2,6 +2,7 @@ import fs from 'fs/promises'; import path from 'path'; import { WorkDir } from '../config/config.js'; import { CodeModeConfig } from './types.js'; +import { checkCodeModeAgentStatus } from './status.js'; export interface ICodeModeConfigRepo { getConfig(): Promise<CodeModeConfig>; @@ -10,27 +11,31 @@ export interface ICodeModeConfigRepo { export class FSCodeModeConfigRepo implements ICodeModeConfigRepo { private readonly configPath = path.join(WorkDir, 'config', 'code-mode.json'); - private readonly defaultConfig: CodeModeConfig = { enabled: false }; + private agentReadyPromise: Promise<boolean> | null = null; - constructor() { - this.ensureConfigFile(); - } - - private async ensureConfigFile(): Promise<void> { - try { - await fs.access(this.configPath); - } catch { - await fs.mkdir(path.dirname(this.configPath), { recursive: true }); - await fs.writeFile(this.configPath, JSON.stringify(this.defaultConfig, null, 2)); + // Reuse the existing agent check (Claude Code / Codex installed + signed in), + // cached for the process lifetime so we probe (shell + keychain) at most once + // per session rather than on every getConfig call. + private agentReady(): Promise<boolean> { + if (!this.agentReadyPromise) { + this.agentReadyPromise = checkCodeModeAgentStatus() + .then((s) => + (s.claude.installed && s.claude.signedIn) + || (s.codex.installed && s.codex.signedIn)) + .catch(() => false); } + return this.agentReadyPromise; } async getConfig(): Promise<CodeModeConfig> { try { + // The file only exists once the user has explicitly toggled code mode + // in settings — always honor that choice. const content = await fs.readFile(this.configPath, 'utf8'); return CodeModeConfig.parse(JSON.parse(content)); } catch { - return this.defaultConfig; + // No explicit choice yet: enable automatically when a coding agent is ready. + return { enabled: await this.agentReady() }; } } From 129d91dc8dce96755140f9cc26567cdb236a9395 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 28 May 2026 23:00:19 +0530 Subject: [PATCH 18/35] Persist per-message context for prompt caching Move volatile current time and middle-pane data out of the system prompt and into a hidden userMessageContext stored on each user message. Reconstruct the LLM-facing message from this persisted context so older conversation turns remain stable across later requests while UI-facing content stays unchanged. Keep finite branch instructions such as voice, search, code mode, agent notes, and workdir behavior in the system prompt so each conversation can still benefit from reusable prompt-cache prefixes. --- apps/x/packages/core/src/agents/runtime.ts | 138 ++++++++++++++++----- apps/x/packages/shared/src/message.ts | 22 +++- 2 files changed, 129 insertions(+), 31 deletions(-) diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 3146101e..84aa4092 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -3,7 +3,7 @@ import fs from "fs"; import path from "path"; import { WorkDir } from "../config/config.js"; import { Agent, ToolAttachment } from "@x/shared/dist/agent.js"; -import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js"; +import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessageContext } from "@x/shared/dist/message.js"; import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"; import { z } from "zod"; import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js"; @@ -23,7 +23,7 @@ import { resolveProviderConfig } from "../models/defaults.js"; import { IAgentsRepo } from "./repo.js"; import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; import { IBus } from "../application/lib/bus.js"; -import { IMessageQueue } from "../application/lib/message-queue.js"; +import { IMessageQueue, type MiddlePaneContext } from "../application/lib/message-queue.js"; import { IRunsRepo } from "../runs/repo.js"; import { IRunsLock } from "../runs/lock.js"; import { IAbortRegistry } from "../runs/abort-registry.js"; @@ -235,6 +235,96 @@ function loadAgentNotesContext(): string | null { return `# Agent Memory\n\n${sections.join('\n\n')}`; } +function isCopilotLikeAgent(agentName: string | null | undefined): boolean { + return agentName === 'copilot' || agentName === 'rowboatx'; +} + +function formatCurrentDateTime(now: Date): string { + return now.toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }); +} + +function toUserMessageContextMiddlePane(middlePaneContext: MiddlePaneContext | null): z.infer<typeof UserMessageContext>['middlePane'] { + if (!middlePaneContext) { + return { kind: 'empty' }; + } + if (middlePaneContext.kind === 'note') { + return { + kind: 'note', + path: middlePaneContext.path, + content: middlePaneContext.content, + }; + } + return { + kind: 'browser', + url: middlePaneContext.url, + title: middlePaneContext.title, + }; +} + +function buildUserMessageContext({ + agentName, + middlePaneContext, +}: { + agentName: string | null | undefined; + middlePaneContext: MiddlePaneContext | null; +}): z.infer<typeof UserMessageContext> { + return { + currentDateTime: formatCurrentDateTime(new Date()), + ...(isCopilotLikeAgent(agentName) + ? { middlePane: toUserMessageContextMiddlePane(middlePaneContext) } + : {}), + }; +} + +function formatUserMessageContextForLlm(userMessageContext: z.infer<typeof UserMessageContext>): string { + const sections: string[] = []; + + if (userMessageContext.currentDateTime) { + sections.push(`Current date and time: ${userMessageContext.currentDateTime}`); + } + + if (userMessageContext.middlePane) { + if (userMessageContext.middlePane.kind === 'empty') { + sections.push(`Middle pane:\nState: empty`); + } else if (userMessageContext.middlePane.kind === 'note') { + sections.push(`Middle pane:\nState: note\nPath: ${userMessageContext.middlePane.path}\n\nContent:\n\`\`\`\n${userMessageContext.middlePane.content}\n\`\`\``); + } else { + sections.push(`Middle pane:\nState: browser\nURL: ${userMessageContext.middlePane.url}\nTitle: ${userMessageContext.middlePane.title}`); + } + } + + if (sections.length === 0) { + return ''; + } + + return `# User Context +${sections.join('\n\n')} + +# User Message +`; +} + +const USER_CONTEXT_SYSTEM_INSTRUCTIONS = `# Hidden User Context +User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". + +Use "Current date and time" for temporal reasoning. + +If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. + +If Middle pane state is empty, the user was not looking at any relevant note or web page at that point. Answer the user's message on its own merits. + +If Middle pane state is note, the supplied path and content are available so you can reference the note when relevant. The user may or may not be talking about this note. Do NOT assume every message is about it. Only reference or act on this note when the user's message clearly relates to it, such as "this note", "what I'm looking at", "here", "above", "below", or questions whose subject is plainly the note's content. For unrelated questions, ignore this note entirely and answer normally. Do not mention that you can see this note unless it is relevant to the answer. + +If Middle pane state is browser, only the URL and page title are supplied; the page content itself is NOT included. If you need the page content to answer, use the browser tools available to you to read the page. The user may or may not be talking about this page. Only reference or act on this page when the user's message clearly relates to it, such as "this page", "this article", "what I'm looking at", "this site", or "summarize this". For unrelated questions, ignore this page entirely and answer normally. Do not mention that you can see the browser unless it is relevant to the answer.`; + export interface IAgentRuntime { trigger(runId: string): Promise<void>; } @@ -722,17 +812,18 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM providerOptions, }); break; - case "user": + case "user": { + const userMessageContextPrefix = msg.userMessageContext ? formatUserMessageContextForLlm(msg.userMessageContext) : ''; if (typeof msg.content === 'string') { // Legacy string — pass through unchanged result.push({ role: "user", - content: msg.content, + content: `${userMessageContextPrefix}${msg.content}`, providerOptions, }); } else { // New content parts array — collapse to text for LLM - const textSegments: string[] = []; + const textSegments: string[] = userMessageContextPrefix ? [userMessageContextPrefix] : []; const attachmentLines: string[] = []; for (const part of msg.content) { @@ -746,7 +837,11 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM } if (attachmentLines.length > 0) { - textSegments.unshift("User has attached the following files:", ...attachmentLines, ""); + if (userMessageContextPrefix) { + textSegments.push("User has attached the following files:", ...attachmentLines, ""); + } else { + textSegments.unshift("User has attached the following files:", ...attachmentLines, ""); + } } result.push({ @@ -756,6 +851,7 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM }); } break; + } case "tool": result.push({ role: "tool", @@ -1225,6 +1321,10 @@ export async function* streamAgent({ // latest user message. If the user closed the pane between messages, clear it. middlePaneContext = msg.middlePaneContext ?? null; loopLogger.log('dequeued user message', msg.messageId); + const userMessageContext = buildUserMessageContext({ + agentName: state.agentName, + middlePaneContext, + }); yield* processEvent({ runId, type: "message", @@ -1232,6 +1332,7 @@ export async function* streamAgent({ message: { role: "user", content: msg.message, + userMessageContext, }, subflow: [], }); @@ -1253,17 +1354,7 @@ export async function* streamAgent({ loopLogger.log('running llm turn'); // stream agent response and build message const messageBuilder = new StreamStepMessageBuilder(); - const now = new Date(); - const currentDateTime = now.toLocaleString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - timeZoneName: 'short' - }); - let instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`; + let instructionsWithDateTime = `${agent.instructions}\n\n${USER_CONTEXT_SYSTEM_INSTRUCTIONS}`; // Inject Agent Notes context for copilot if (state.agentName === 'copilot' || state.agentName === 'rowboatx') { const agentNotesContext = loadAgentNotesContext(); @@ -1292,19 +1383,6 @@ Use absolute paths rooted at this directory with the \`file-*\` tools. For examp Do not announce the work directory unless it's relevant. Just use it.`; } - // Always inject a Middle Pane section so the LLM has a clear, up-to-date signal - // that supersedes any earlier middle-pane mention in the conversation history. - const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`; - if (!middlePaneContext) { - loopLogger.log('injecting middle pane context (empty)'); - instructionsWithDateTime += `${middlePaneHeader}**Nothing relevant is open in the middle pane right now.** The user is not looking at any note or web page. If earlier in this conversation you referenced a note or browser page as "what the user is viewing", that is no longer accurate — do not refer to it as currently open. Answer the user's latest message on its own merits.`; - } else if (middlePaneContext.kind === 'note') { - loopLogger.log('injecting middle pane context (note)', middlePaneContext.path); - instructionsWithDateTime += `${middlePaneHeader}The user has a note open. Its path and full content are provided below so you can reference it when relevant.\n\n**How to use this context:**\n- The user may or may not be talking about this note. Do NOT assume every message is about it.\n- Only reference or act on this note when the user's message clearly relates to it (e.g. "this note", "what I'm looking at", "here", "above", "below", or questions whose subject is plainly this note's content).\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see this note unless it is relevant to the answer.\n\n## Open note path\n${middlePaneContext.path}\n\n## Open note content\n\`\`\`\n${middlePaneContext.content}\n\`\`\``; - } else if (middlePaneContext.kind === 'browser') { - loopLogger.log('injecting middle pane context (browser)', middlePaneContext.url); - instructionsWithDateTime += `${middlePaneHeader}The user has the embedded browser open and is viewing a web page. Only the URL and page title are shown below — the page content itself is NOT included here. If you need the page content to answer, use the browser tools available to you to read the page.\n\n**How to use this context:**\n- The user may or may not be talking about this page. Do NOT assume every message is about it.\n- Only reference or act on this page when the user's message clearly relates to it (e.g. "this page", "this article", "what I'm looking at", "this site", "summarize this").\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see the browser unless it is relevant to the answer.\n\n## Current page\nURL: ${middlePaneContext.url}\nTitle: ${middlePaneContext.title}`; - } } if (voiceInput) { loopLogger.log('voice input enabled, injecting voice input prompt'); diff --git a/apps/x/packages/shared/src/message.ts b/apps/x/packages/shared/src/message.ts index 2aefe3f3..cdf5d983 100644 --- a/apps/x/packages/shared/src/message.ts +++ b/apps/x/packages/shared/src/message.ts @@ -50,9 +50,29 @@ export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]); // Named type for user message content — used everywhere instead of repeating the union export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]); +export const UserMessageContext = z.object({ + currentDateTime: z.string().optional(), + middlePane: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("empty"), + }), + z.object({ + kind: z.literal("note"), + path: z.string(), + content: z.string(), + }), + z.object({ + kind: z.literal("browser"), + url: z.string(), + title: z.string(), + }), + ]).optional(), +}); + export const UserMessage = z.object({ role: z.literal("user"), content: UserMessageContent, + userMessageContext: UserMessageContext.optional(), providerOptions: ProviderOptions.optional(), }); @@ -86,4 +106,4 @@ export const Message = z.discriminatedUnion("role", [ UserMessage, ]); -export const MessageList = z.array(Message); \ No newline at end of file +export const MessageList = z.array(Message); From cc034c76889fe8bc5ab60d252fac209f276002eb Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 28 May 2026 23:40:46 +0530 Subject: [PATCH 19/35] fix(ci): make electron release artifacts deterministic Pin Electron release builds to Node 24.15.0, the last known-good runner version for Windows/Linux packaging, and fail artifact upload when out/make is empty so successful jobs cannot hide missing release assets. --- .github/workflows/electron-build.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 6566f105..ec60096f 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.15.0 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -111,6 +111,7 @@ jobs: with: name: distributables path: apps/x/apps/main/out/make/* + if-no-files-found: error retention-days: 30 build-linux: @@ -128,7 +129,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.15.0 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -175,6 +176,7 @@ jobs: with: name: distributables-linux path: apps/x/apps/main/out/make/* + if-no-files-found: error retention-days: 30 build-windows: @@ -192,7 +194,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.15.0 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -241,4 +243,5 @@ jobs: with: name: distributables-windows path: apps/x/apps/main/out/make/* + if-no-files-found: error retention-days: 30 From 78d51ccbf63a788593fb0bdf895b556117931542 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 23:57:43 +0530 Subject: [PATCH 20/35] fix navigation and other minor issue in workspace view (#587) --- apps/x/apps/renderer/src/App.tsx | 4 + .../src/components/workspace-view.tsx | 135 ++++++++++++------ 2 files changed, 94 insertions(+), 45 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 5c072b2a..a77aaeb4 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5506,7 +5506,11 @@ function App() { remove: knowledgeActions.remove, copyPath: knowledgeActions.copyPath, revealInFileManager: knowledgeActions.revealInFileManager, + createNote: knowledgeActions.createNote, + createFolder: knowledgeActions.createFolder, + onOpenInNewTab: knowledgeActions.onOpenInNewTab, }} + onNavigate={(path) => { void navigateToView({ type: 'workspace', path: path === WORKSPACE_ROOT ? undefined : path }) }} onOpenNote={(path) => navigateToFile(path)} onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }} /> diff --git a/apps/x/apps/renderer/src/components/workspace-view.tsx b/apps/x/apps/renderer/src/components/workspace-view.tsx index 6cbd1075..6923ac1c 100644 --- a/apps/x/apps/renderer/src/components/workspace-view.tsx +++ b/apps/x/apps/renderer/src/components/workspace-view.tsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { ChevronRight, Copy, + ExternalLink, File as FileIcon, FilePlus, Folder as FolderIcon, @@ -53,12 +54,18 @@ type WorkspaceActions = { remove: (path: string) => Promise<void> copyPath: (path: string) => void revealInFileManager: (path: string, isDir: boolean) => void + createNote: (parentPath?: string) => void + createFolder: (parentPath?: string) => Promise<string> + onOpenInNewTab?: (path: string) => void } type WorkspaceViewProps = { tree: TreeNode[] initialPath?: string | null actions: WorkspaceActions + // Folder currently being browsed. Controlled by the app so drill-down + // participates in the global back/forward history. + onNavigate: (path: string) => void onOpenNote: (path: string) => void onCreateWorkspace: (name: string) => Promise<void> } @@ -71,6 +78,12 @@ function getFileManagerName(): string { return 'File Manager' } +function fileExtensionLabel(name: string): string { + const dot = name.lastIndexOf('.') + if (dot <= 0 || dot === name.length - 1) return 'File' + return `${name.slice(dot + 1).toUpperCase()} file` +} + function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null { if (!nodes) return null for (const node of nodes) { @@ -113,8 +126,8 @@ function readFileAsBase64(file: File): Promise<string> { }) } -export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) { - const [currentPath, setCurrentPath] = useState<string>(initialPath || WORKSPACE_ROOT) +export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) { + const currentPath = initialPath || WORKSPACE_ROOT const [addOpen, setAddOpen] = useState(false) const [newName, setNewName] = useState('') const [creating, setCreating] = useState(false) @@ -127,10 +140,6 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate const filesInputRef = useRef<HTMLInputElement | null>(null) const folderInputRef = useRef<HTMLInputElement | null>(null) - useEffect(() => { - if (initialPath) setCurrentPath(initialPath) - }, [initialPath]) - const isRoot = currentPath === WORKSPACE_ROOT const fileManagerName = getFileManagerName() @@ -160,12 +169,12 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate (item: TreeNode) => { if (renameTarget) return if (item.kind === 'dir') { - setCurrentPath(item.path) + onNavigate(item.path) } else { onOpenNote(item.path) } }, - [onOpenNote, renameTarget], + [onNavigate, onOpenNote, renameTarget], ) const beginRename = useCallback((item: TreeNode) => { @@ -295,7 +304,7 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate <div className="flex min-w-0 items-center gap-1 text-sm"> <button type="button" - onClick={() => setCurrentPath(WORKSPACE_ROOT)} + onClick={() => onNavigate(WORKSPACE_ROOT)} className={cn( 'inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors', isRoot ? 'text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent', @@ -316,7 +325,7 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate ) : ( <button type="button" - onClick={() => setCurrentPath(crumb.path)} + onClick={() => onNavigate(crumb.path)} className="rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate" > {crumb.name} @@ -326,31 +335,42 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate ) })} </div> - {isRoot ? ( - <Button size="sm" onClick={() => setAddOpen(true)}> - <Plus className="size-4" /> - Add workspace + <div className="grid shrink-0 grid-cols-2 items-center gap-2"> + <Button + size="sm" + variant="outline" + className="w-full" + onClick={() => actions.revealInFileManager(currentPath, true)} + > + <FolderOpen className="size-4" /> + Open in {fileManagerName} </Button> - ) : ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button size="sm"> - <Plus className="size-4" /> - Add - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={() => filesInputRef.current?.click()}> - <FilePlus className="mr-2 size-4" /> - Add files… - </DropdownMenuItem> - <DropdownMenuItem onClick={() => folderInputRef.current?.click()}> - <FolderPlus className="mr-2 size-4" /> - Add folder… - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - )} + {isRoot ? ( + <Button size="sm" className="w-full" onClick={() => setAddOpen(true)}> + <Plus className="size-4" /> + Add workspace + </Button> + ) : ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button size="sm" className="w-full"> + <Plus className="size-4" /> + Add + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => filesInputRef.current?.click()}> + <FilePlus className="mr-2 size-4" /> + Add files… + </DropdownMenuItem> + <DropdownMenuItem onClick={() => folderInputRef.current?.click()}> + <FolderPlus className="mr-2 size-4" /> + Add folder… + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + )} + </div> </div> <input ref={filesInputRef} @@ -429,31 +449,56 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate ) : ( <div className="truncate text-sm font-medium">{item.name}</div> )} - {item.kind === 'dir' && !isRenaming && ( - <div className="text-xs text-muted-foreground"> - {childCount} {childCount === 1 ? 'item' : 'items'} + {!isRenaming && ( + <div className="truncate text-xs text-muted-foreground"> + {item.kind === 'dir' + ? `${childCount} ${childCount === 1 ? 'item' : 'items'}` + : fileExtensionLabel(item.name)} </div> )} </div> </button> ) + const isDir = item.kind === 'dir' return ( <ContextMenu key={item.path}> <ContextMenuTrigger asChild>{card}</ContextMenuTrigger> - <ContextMenuContent className="w-48"> - <ContextMenuItem onClick={() => beginRename(item)}> - <Pencil className="mr-2 size-4" /> - Rename - </ContextMenuItem> + <ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}> + {isDir && ( + <> + <ContextMenuItem onClick={() => actions.createNote(item.path)}> + <FilePlus className="mr-2 size-4" /> + New Note + </ContextMenuItem> + <ContextMenuItem onClick={() => void actions.createFolder(item.path)}> + <FolderPlus className="mr-2 size-4" /> + New Folder + </ContextMenuItem> + <ContextMenuSeparator /> + </> + )} + {!isDir && actions.onOpenInNewTab && ( + <> + <ContextMenuItem onClick={() => actions.onOpenInNewTab!(item.path)}> + <ExternalLink className="mr-2 size-4" /> + Open in new tab + </ContextMenuItem> + <ContextMenuSeparator /> + </> + )} <ContextMenuItem onClick={() => { actions.copyPath(item.path); toast('Path copied', 'success') }}> <Copy className="mr-2 size-4" /> Copy Path </ContextMenuItem> - <ContextMenuItem onClick={() => actions.revealInFileManager(item.path, item.kind === 'dir')}> + <ContextMenuItem onClick={() => actions.revealInFileManager(item.path, isDir)}> <FolderOpen className="mr-2 size-4" /> - Show in {fileManagerName} + Open in {fileManagerName} </ContextMenuItem> <ContextMenuSeparator /> + <ContextMenuItem onClick={() => beginRename(item)}> + <Pencil className="mr-2 size-4" /> + Rename + </ContextMenuItem> <ContextMenuItem variant="destructive" onClick={() => void handleDelete(item)}> <Trash2 className="mr-2 size-4" /> Delete From 5ae853e15cabb135c572c06b5a6a16d49876f8ad Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Fri, 29 May 2026 10:58:57 +0530 Subject: [PATCH 21/35] fix thread boundary in email reply drafts (#588) --- .../renderer/src/components/email-view.tsx | 30 +++- .../core/src/knowledge/sync_gmail.test.ts | 42 ++++++ .../packages/core/src/knowledge/sync_gmail.ts | 129 +++++++++++++++++- 3 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 apps/x/packages/core/src/knowledge/sync_gmail.test.ts diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index deed545c..dea0561e 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -69,6 +69,31 @@ function snippet(text?: string): string { return (text || '').replace(/\s+/g, ' ').trim().slice(0, 180) } +function isReplyQuoteBoundary(lines: string[], index: number): boolean { + const line = lines[index]?.trim() || '' + if (/^On\b.+\bwrote:\s*$/i.test(line)) return true + if (/^-{2,}\s*(Original Message|Forwarded message)\s*-{2,}$/i.test(line)) return true + if (/^From:\s+\S/i.test(line)) { + const next = lines.slice(index + 1, index + 6).map((value) => value.trim()) + return next.some((value) => /^(Sent|Date):\s+\S/i.test(value)) + && next.some((value) => /^To:\s+\S/i.test(value)) + && next.some((value) => /^Subject:\s+\S/i.test(value)) + } + return false +} + +function stripQuotedReplyText(text: string): string { + const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n') + const boundary = lines.findIndex((line, index) => { + if (isReplyQuoteBoundary(lines, index)) return true + return index > 0 + && line.trim().startsWith('>') + && (lines[index - 1]?.trim() === '' || lines[index - 1]?.trim().startsWith('>')) + }) + const visible = boundary >= 0 ? lines.slice(0, boundary) : lines + return visible.join('\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() +} + function getInitial(from?: string): string { return (extractName(from)[0] || '?').toUpperCase() } @@ -692,7 +717,7 @@ function ComposeBox({ const initialContent = useMemo(() => { if (mode === 'forward') return buildForwardedContent(thread) // Gmail-side draft (user's own work) wins over the AI-generated draft. - const source = thread.gmail_draft || thread.draft_response + const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '') if (!source) return '' return source .split(/\n{2,}/) @@ -1048,8 +1073,7 @@ function ThreadDetail({ const MAX_KEPT_OPEN = 5 const PAGE_SIZE = 25 -const SECTIONS = ['important', 'other'] as const -type InboxSection = (typeof SECTIONS)[number] +type InboxSection = 'important' | 'other' interface SectionState { threads: GmailThread[] diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.test.ts b/apps/x/packages/core/src/knowledge/sync_gmail.test.ts new file mode 100644 index 00000000..5da55bbc --- /dev/null +++ b/apps/x/packages/core/src/knowledge/sync_gmail.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + sanitizeReplyBodyForGmailReply, + stripGmailQuotedReplyHtml, + stripGmailQuotedReplyText, +} from './sync_gmail.js'; + +describe('Gmail reply body sanitization', () => { + it('strips Gmail quote attribution and older quoted text from plain text replies', () => { + const body = [ + 'Sounds good, thanks. I will send it over today.', + '', + 'On Thu, 28 May 2026 at 23:45, PRAKHAR <prakhar9999pandey@gmail.com> wrote:', + '> Can you share the final file?', + '> Thanks', + ].join('\n'); + + expect(stripGmailQuotedReplyText(body)).toBe('Sounds good, thanks. I will send it over today.'); + }); + + it('strips Gmail quote blocks from html replies', () => { + const html = [ + '<p>Sounds good, thanks.</p>', + '<div class="gmail_quote">', + '<div dir="ltr" class="gmail_attr">On Thu, 28 May 2026 at 23:45, PRAKHAR wrote:<br></div>', + '<blockquote>Older thread text</blockquote>', + '</div>', + ].join(''); + + expect(stripGmailQuotedReplyHtml(html)).toBe('<p>Sounds good, thanks.</p>'); + }); + + it('regenerates html from clean text if only the text boundary is detected', () => { + const result = sanitizeReplyBodyForGmailReply( + '<p>Sounds good, thanks.</p><p>Older thread text</p>', + 'Sounds good, thanks.\n\nOn Thu, 28 May 2026 at 23:45, PRAKHAR <prakhar9999pandey@gmail.com> wrote:\nOlder thread text', + ); + + expect(result.bodyText).toBe('Sounds good, thanks.'); + expect(result.bodyHtml).toBe('<p>Sounds good, thanks.</p>'); + }); +}); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 6b131a5d..5c32c7bf 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -35,7 +35,7 @@ const nhm = new NodeHtmlMarkdown(); // previously cached snapshots (e.g. attachment / recipient parsing fixes). The // short-circuit in buildAndCacheSnapshot only reuses a cache whose version matches, // so stale entries are transparently rebuilt on the next sync. -const SNAPSHOT_PARSER_VERSION = 2; +const SNAPSHOT_PARSER_VERSION = 3; interface SnapshotCacheEntry { historyId: string; @@ -405,6 +405,112 @@ function normalizeBody(body: string): string { return body.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); } +function isGmailQuoteAttribution(line: string): boolean { + const trimmed = line.trim(); + return /^On\b.+\bwrote:\s*$/i.test(trimmed); +} + +function isOriginalMessageBoundary(line: string): boolean { + return /^-{2,}\s*Original Message\s*-{2,}$/i.test(line.trim()); +} + +function isForwardedMessageBoundary(line: string): boolean { + return /^-{2,}\s*Forwarded message\s*-{2,}$/i.test(line.trim()); +} + +function isOutlookHeaderBoundary(lines: string[], index: number): boolean { + if (!/^From:\s+\S/i.test(lines[index]?.trim() || '')) return false; + const next = lines.slice(index + 1, index + 6).map((line) => line.trim()); + return next.some((line) => /^(Sent|Date):\s+\S/i.test(line)) + && next.some((line) => /^To:\s+\S/i.test(line)) + && next.some((line) => /^Subject:\s+\S/i.test(line)); +} + +function findQuotedReplyBoundary(lines: string[]): number { + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] || ''; + if ( + isGmailQuoteAttribution(line) + || isOriginalMessageBoundary(line) + || isForwardedMessageBoundary(line) + || isOutlookHeaderBoundary(lines, i) + ) { + return i; + } + + // Gmail plain text drafts often carry older messages as a quoted block. + // Treat a trailing blockquote as history, but avoid stripping an inline + // quote the user is actively writing at the top of the reply. + if (i > 0 && line.trim().startsWith('>') && (lines[i - 1]?.trim() === '' || lines[i - 1]?.trim().startsWith('>'))) { + return i; + } + } + return -1; +} + +export function stripGmailQuotedReplyText(text: string): string { + const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = normalized.split('\n'); + const boundary = findQuotedReplyBoundary(lines); + const visible = boundary >= 0 ? lines.slice(0, boundary) : lines; + return visible + .join('\n') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function htmlQuoteBoundaryIndex(html: string): number { + const candidates: number[] = []; + const patterns = [ + /<[^>]+\bclass\s*=\s*["'][^"']*\bgmail_(?:quote|attr)\b[^"']*["'][^>]*>/i, + /<blockquote\b[^>]*(?:type\s*=\s*["']cite["']|class\s*=\s*["'][^"']*\bgmail_quote\b[^"']*["'])[^>]*>/i, + /<(p|div|li)\b[^>]*>\s*(?:<(?:span|b|strong|i|em)\b[^>]*>\s*)*On\b[\s\S]{0,800}?\bwrote:\s*(?:<br\s*\/?>\s*)?(?:<\/(?:span|b|strong|i|em)>\s*)*<\/\1>/i, + /<(p|div|li)\b[^>]*>\s*-{2,}\s*(?:Original Message|Forwarded message)\s*-{2,}\s*<\/\1>/i, + ]; + + for (const pattern of patterns) { + const match = pattern.exec(html); + if (match?.index !== undefined) candidates.push(match.index); + } + + return candidates.length > 0 ? Math.min(...candidates) : -1; +} + +export function stripGmailQuotedReplyHtml(html: string): string { + const boundary = htmlQuoteBoundaryIndex(html); + const visible = boundary >= 0 ? html.slice(0, boundary) : html; + return visible.trim(); +} + +function textToHtml(text: string): string { + return text + .split(/\n{2,}/) + .map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`) + .join(''); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function sanitizeReplyBodyForGmailReply(bodyHtml: string, bodyText: string): { bodyHtml: string; bodyText: string } { + const cleanText = stripGmailQuotedReplyText(bodyText); + const cleanHtml = stripGmailQuotedReplyHtml(bodyHtml); + const textWasStripped = cleanText !== bodyText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); + const htmlWasStripped = cleanHtml !== bodyHtml.trim(); + + return { + bodyText: cleanText, + bodyHtml: textWasStripped && !htmlWasStripped ? textToHtml(cleanText) : cleanHtml, + }; +} + function headerValue(headers: gmail.Schema$MessagePartHeader[] | undefined, name: string): string | undefined { return headers?.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || undefined; } @@ -636,9 +742,13 @@ async function buildAndCacheSnapshot( const sentMessages = parsed.filter((m) => !m.isDraft); const draftMessages = parsed.filter((m) => m.isDraft); - const visibleMessages = sentMessages.map(({ isDraft: _isDraft, ...rest }) => rest); + const visibleMessages = sentMessages.map((msg) => { + const rest: Partial<typeof msg> = { ...msg }; + delete rest.isDraft; + return rest as Omit<typeof msg, 'isDraft'>; + }); const latestDraftBody = draftMessages.length > 0 - ? draftMessages[draftMessages.length - 1]!.body.trim() + ? stripGmailQuotedReplyText(draftMessages[draftMessages.length - 1]!.body) : ''; if (visibleMessages.length === 0) return null; @@ -674,7 +784,10 @@ async function buildAndCacheSnapshot( const classification = await classifyThread(snapshot, userEmail, { skipDraft }); snapshot.importance = classification.importance; if (classification.summary) snapshot.summary = classification.summary; - if (classification.draftResponse) snapshot.draft_response = classification.draftResponse; + if (classification.draftResponse) { + const draftResponse = stripGmailQuotedReplyText(classification.draftResponse); + if (draftResponse) snapshot.draft_response = draftResponse; + } } catch (err) { console.warn(`[Gmail] classify failed for ${threadId}:`, err); } @@ -1330,6 +1443,10 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined; const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined; const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined; + const replyBody = opts.threadId + ? sanitizeReplyBodyForGmailReply(opts.bodyHtml, opts.bodyText) + : { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() }; + if (!replyBody.bodyText.trim()) return { error: 'Draft is empty.' }; const boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; const headers: string[] = []; @@ -1348,13 +1465,13 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply parts.push('Content-Type: text/plain; charset="UTF-8"'); parts.push('Content-Transfer-Encoding: base64'); parts.push(''); - parts.push(encodeMimeBase64(opts.bodyText)); + parts.push(encodeMimeBase64(replyBody.bodyText)); parts.push(''); parts.push(`--${boundary}`); parts.push('Content-Type: text/html; charset="UTF-8"'); parts.push('Content-Transfer-Encoding: base64'); parts.push(''); - parts.push(encodeMimeBase64(opts.bodyHtml)); + parts.push(encodeMimeBase64(replyBody.bodyHtml)); parts.push(''); parts.push(`--${boundary}--`); From 732401f72edc71ae51853e4c8a861ba9ebcf1542 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 29 May 2026 17:02:01 +0530 Subject: [PATCH 22/35] Add app version to analytics events --- apps/x/ANALYTICS.md | 3 ++ apps/x/apps/main/bundle.mjs | 3 ++ apps/x/apps/main/src/ipc.ts | 3 +- .../src/hooks/useAnalyticsIdentity.ts | 7 ++-- apps/x/apps/renderer/src/lib/analytics.ts | 37 +++++++++++++++++++ apps/x/apps/renderer/src/main.tsx | 30 +++++++++++---- apps/x/packages/core/src/analytics/posthog.ts | 13 ++++++- apps/x/packages/shared/src/ipc.ts | 1 + 8 files changed, 83 insertions(+), 14 deletions(-) diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 572e9a6f..2d9816d0 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -16,6 +16,8 @@ ## Event catalog +All PostHog events include `app_version` automatically. Main-process events add it in `packages/core/src/analytics/posthog.ts`; renderer events get it from the `analytics:bootstrap` IPC payload and an initialization-time `before_send` hook. + ### `llm_usage` Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run). @@ -101,6 +103,7 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as | `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations | | `plan`, `status` | main on identify | Subscription state | | `api_url` | both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. `https://api.x.rowboatlabs.com` = production | +| `app_version` | both processes (init + identify) | Electron app version; also included automatically on every event | | `signed_in` | renderer | `true` while rowboat OAuth is connected | | `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` | | `total_notes` | renderer (init) | Workspace size signal | diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 9ae77e0e..976e8db3 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -10,11 +10,13 @@ */ import * as esbuild from 'esbuild'; +import { readFile } from 'node:fs/promises'; // In CommonJS, import.meta.url doesn't exist. We need to polyfill it. // The banner defines __import_meta_url at the top of the bundle, // and we use define to replace all import.meta.url references with it. const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`; +const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8')); await esbuild.build({ entryPoints: ['./dist/main.js'], @@ -36,6 +38,7 @@ await esbuild.build({ // Empty strings disable analytics gracefully. 'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''), 'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'), + 'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''), }, }); diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 2f5730ce..ec2803aa 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron'; +import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } from 'electron'; import { ipc } from '@x/shared'; import path from 'node:path'; import os from 'node:os'; @@ -455,6 +455,7 @@ export function setupIpcHandlers() { return { installationId: getInstallationId(), apiUrl: API_URL, + appVersion: app.getVersion(), }; }, 'workspace:getRoot': async () => { diff --git a/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts index 82220782..5bc5cec0 100644 --- a/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts +++ b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' import posthog from 'posthog-js' +import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics' /** * Identifies the user in PostHog when signed into Rowboat, @@ -17,7 +18,7 @@ export function useAnalyticsIdentity() { // Identify if Rowboat account is connected const rowboat = config.rowboat if (rowboat?.connected && rowboat?.userId) { - posthog.identify(rowboat.userId) + identifyUser(rowboat.userId) } // Set provider connection flags @@ -69,7 +70,7 @@ export function useAnalyticsIdentity() { // Rowboat sign-in if (event.success) { if (event.userId) { - posthog.identify(event.userId) + identifyUser(event.userId) } posthog.people.set({ signed_in: true, rowboat_connected: true }) posthog.capture('user_signed_in') @@ -80,7 +81,7 @@ export function useAnalyticsIdentity() { // future events on this device don't get attributed to the prior user. posthog.people.set({ signed_in: false, rowboat_connected: false }) posthog.capture('user_signed_out') - posthog.reset() + resetAnalyticsIdentity() }) return cleanup diff --git a/apps/x/apps/renderer/src/lib/analytics.ts b/apps/x/apps/renderer/src/lib/analytics.ts index 672ea0c3..de837bab 100644 --- a/apps/x/apps/renderer/src/lib/analytics.ts +++ b/apps/x/apps/renderer/src/lib/analytics.ts @@ -1,5 +1,42 @@ import posthog from 'posthog-js' +let appVersion: string | undefined +let apiUrl: string | undefined + +function appVersionProperties(): Record<string, string> { + return appVersion ? { app_version: appVersion } : {} +} + +export function configureAnalyticsContext(props: { appVersion?: string; apiUrl?: string }) { + appVersion = props.appVersion?.trim() || undefined + apiUrl = props.apiUrl?.trim() || undefined + + const eventProperties = appVersionProperties() + if (Object.keys(eventProperties).length > 0) { + posthog.register(eventProperties) + } + + const personProperties = { + ...(apiUrl ? { api_url: apiUrl } : {}), + ...eventProperties, + } + if (Object.keys(personProperties).length > 0) { + posthog.people.set(personProperties) + } +} + +export function identifyUser(userId: string, properties?: Record<string, unknown>) { + posthog.identify(userId, { + ...properties, + ...appVersionProperties(), + }) +} + +export function resetAnalyticsIdentity() { + posthog.reset() + configureAnalyticsContext({ appVersion, apiUrl }) +} + export function chatSessionCreated(runId: string) { posthog.capture('chat_session_created', { run_id: runId }) } diff --git a/apps/x/apps/renderer/src/main.tsx b/apps/x/apps/renderer/src/main.tsx index fedc029c..7999061d 100644 --- a/apps/x/apps/renderer/src/main.tsx +++ b/apps/x/apps/renderer/src/main.tsx @@ -2,9 +2,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' -import posthog from 'posthog-js' import { PostHogProvider } from 'posthog-js/react' +import type { CaptureResult } from 'posthog-js' import { ThemeProvider } from '@/contexts/theme-context' +import { configureAnalyticsContext } from './lib/analytics' // Fetch the stable installation ID from main so renderer + main share one // PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID @@ -12,19 +13,36 @@ import { ThemeProvider } from '@/contexts/theme-context' async function bootstrap() { let installationId: string | undefined let apiUrl: string | undefined + let appVersion: string | undefined try { const result = await window.ipc.invoke('analytics:bootstrap', null) installationId = result.installationId apiUrl = result.apiUrl + appVersion = result.appVersion } catch (err) { console.error('[Analytics] Failed to bootstrap from main:', err) } + configureAnalyticsContext({ apiUrl, appVersion }) + const options = { api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, - defaults: '2025-11-30', + defaults: '2025-11-30' as const, ...(installationId ? { bootstrap: { distinctID: installationId } } : {}), - } as const + before_send: (event: CaptureResult | null) => { + if (!event) return event + if (appVersion) { + event.properties = { + ...event.properties, + app_version: appVersion, + } + } + return event + }, + loaded: () => { + configureAnalyticsContext({ apiUrl, appVersion }) + }, + } createRoot(document.getElementById('root')!).render( <StrictMode> @@ -36,11 +54,7 @@ async function bootstrap() { </StrictMode>, ) - // Tag the active person record with api_url so anonymous users are also - // segmentable by environment. - if (apiUrl) { - posthog.people.set({ api_url: apiUrl }) - } + // The loaded callback applies api_url/app_version once PostHog has initialized. } bootstrap() diff --git a/apps/x/packages/core/src/analytics/posthog.ts b/apps/x/packages/core/src/analytics/posthog.ts index 156194d9..d3d1e55c 100644 --- a/apps/x/packages/core/src/analytics/posthog.ts +++ b/apps/x/packages/core/src/analytics/posthog.ts @@ -6,6 +6,7 @@ import { API_URL } from '../config/env.js'; // In dev/tsc, fall back to process.env so local runs work too. const POSTHOG_KEY = process.env.POSTHOG_KEY ?? process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''; const POSTHOG_HOST = process.env.POSTHOG_HOST ?? process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'; +const APP_VERSION = (process.env.ROWBOAT_APP_VERSION ?? process.env.npm_package_version ?? '').trim(); let client: PostHog | null = null; let initAttempted = false; @@ -29,7 +30,7 @@ function getClient(): PostHog | null { // distinguishes prod / staging / custom — meaning is assigned in PostHog). client.identify({ distinctId: getInstallationId(), - properties: { api_url: API_URL }, + properties: { api_url: API_URL, ...appVersionProperties() }, }); } catch (err) { console.error('[Analytics] Failed to init PostHog:', err); @@ -42,6 +43,10 @@ function activeDistinctId(): string { return identifiedUserId ?? getInstallationId(); } +function appVersionProperties(): Record<string, string> { + return APP_VERSION ? { app_version: APP_VERSION } : {}; +} + export function capture(event: string, properties?: Record<string, unknown>): void { const ph = getClient(); if (!ph) return; @@ -49,7 +54,10 @@ export function capture(event: string, properties?: Record<string, unknown>): vo ph.capture({ distinctId: activeDistinctId(), event, - properties, + properties: { + ...properties, + ...appVersionProperties(), + }, }); } catch (err) { console.error('[Analytics] capture failed:', err); @@ -68,6 +76,7 @@ export function identify(userId: string, properties?: Record<string, unknown>): properties: { ...properties, api_url: API_URL, + ...appVersionProperties(), }, }); identifiedUserId = userId; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index d42e9935..092a4b29 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -38,6 +38,7 @@ const ipcSchemas = { res: z.object({ installationId: z.string(), apiUrl: z.string(), + appVersion: z.string(), }), }, 'workspace:getRoot': { From 5368751f61e1b61b56ea0ef297276e929bad38c8 Mon Sep 17 00:00:00 2001 From: gagan <gaganp000999@gmail.com> Date: Fri, 29 May 2026 18:04:04 +0530 Subject: [PATCH 23/35] feat: render and edit docx files in-app (#589) Add a DocxFileViewer (via @eigenpal/docx-editor-react) wired into the file-type viewer switch, reading/saving bytes through the existing base64 workspace IPC with debounced autosave. --- apps/x/apps/renderer/package.json | 10 + apps/x/apps/renderer/src/App.tsx | 5 + .../src/components/docx-file-viewer.tsx | 196 +++++++++++ apps/x/apps/renderer/src/lib/file-types.ts | 3 +- apps/x/pnpm-lock.yaml | 315 +++++++++++++++--- 5 files changed, 486 insertions(+), 43 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/docx-file-viewer.tsx diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index a193b3f1..67876189 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@eigenpal/docx-editor-react": "^1.0.3", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", @@ -46,6 +47,15 @@ "motion": "^12.23.26", "nanoid": "^5.1.6", "posthog-js": "^1.332.0", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-history": "^1.5.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.7", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.8", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a77aaeb4..57a03727 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -18,6 +18,7 @@ import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/ba import { ImageFileViewer } from '@/components/image-file-viewer'; import { VideoFileViewer } from '@/components/video-file-viewer'; import { AudioFileViewer } from '@/components/audio-file-viewer'; +import { DocxFileViewer } from '@/components/docx-file-viewer'; import { PersistentViewerCache } from '@/components/persistent-viewer-cache'; import { UnsupportedFileViewer } from '@/components/unsupported-file-viewer'; import { getViewerType, isCacheableViewerPath } from '@/lib/file-types'; @@ -5722,6 +5723,10 @@ function App() { <div className="flex-1 min-h-0 overflow-hidden"> <AudioFileViewer path={selectedPath} /> </div> + ) : selectedPath && getViewerType(selectedPath) === 'docx' ? ( + <div className="flex-1 min-h-0 overflow-hidden"> + <DocxFileViewer path={selectedPath} /> + </div> ) : ( <div className="flex-1 min-h-0 overflow-hidden"> <UnsupportedFileViewer path={selectedPath} /> diff --git a/apps/x/apps/renderer/src/components/docx-file-viewer.tsx b/apps/x/apps/renderer/src/components/docx-file-viewer.tsx new file mode 100644 index 00000000..415ae4a0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/docx-file-viewer.tsx @@ -0,0 +1,196 @@ +import { Suspense, lazy, useEffect, useRef, useState } from 'react' +import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react' +import type { DocxEditorRef } from '@eigenpal/docx-editor-react' + +// The editor (and its CSS) is heavy and only needed when a .docx is open, so it +// loads in its own chunk the first time a Word document is viewed. +const LazyDocxEditor = lazy(async () => { + const [mod] = await Promise.all([ + import('@eigenpal/docx-editor-react'), + import('@eigenpal/docx-editor-react/styles.css'), + ]) + return { default: mod.DocxEditor } +}) + +interface DocxFileViewerProps { + path: string +} + +type LoadState = 'loading' | 'ready' | 'error' +type SaveState = 'idle' | 'saving' | 'saved' | 'error' + +const SAVE_DEBOUNCE_MS = 800 +// onChange fires for the editor's own load-time normalization. Ignore changes +// until shortly after the document settles so opening a file never rewrites it. +const ARM_DELAY_MS = 500 + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64) + const len = binary.length + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i) + return bytes.buffer +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + const chunk = 0x8000 + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)) + } + return btoa(binary) +} + +function baseName(path: string): string { + const segs = path.split('/') + return segs[segs.length - 1] || path +} + +export function DocxFileViewer({ path }: DocxFileViewerProps) { + const [loadState, setLoadState] = useState<LoadState>('loading') + const [buffer, setBuffer] = useState<ArrayBuffer | null>(null) + const [saveState, setSaveState] = useState<SaveState>('idle') + + const editorRef = useRef<DocxEditorRef>(null) + const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) + const armTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) + const armedRef = useRef(false) + const dirtyRef = useRef(false) + const savingRef = useRef(false) + + // Load the .docx bytes whenever the path changes. + useEffect(() => { + let cancelled = false + setLoadState('loading') + setBuffer(null) + setSaveState('idle') + armedRef.current = false + dirtyRef.current = false + savingRef.current = false + + ;(async () => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path, encoding: 'base64' }) + if (cancelled) return + setBuffer(base64ToArrayBuffer(result.data)) + setLoadState('ready') + if (armTimerRef.current) clearTimeout(armTimerRef.current) + armTimerRef.current = setTimeout(() => { armedRef.current = true }, ARM_DELAY_MS) + } catch (err) { + console.error('Failed to load docx:', err) + if (!cancelled) setLoadState('error') + } + })() + + return () => { + cancelled = true + if (armTimerRef.current) clearTimeout(armTimerRef.current) + } + }, [path]) + + // Serialize the current document and write it back to disk. + const persist = async () => { + const editor = editorRef.current + if (!editor || savingRef.current) return + savingRef.current = true + dirtyRef.current = false + setSaveState('saving') + try { + const out = await editor.save() + if (out) { + await window.ipc.invoke('workspace:writeFile', { + path, + data: arrayBufferToBase64(out), + opts: { encoding: 'base64' }, + }) + } + setSaveState('saved') + } catch (err) { + console.error('Failed to save docx:', err) + dirtyRef.current = true + setSaveState('error') + } finally { + savingRef.current = false + // A change landed while we were saving — flush it. + if (dirtyRef.current) scheduleSave() + } + } + + const scheduleSave = () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(() => { void persist() }, SAVE_DEBOUNCE_MS) + } + + const handleChange = () => { + if (!armedRef.current) return + dirtyRef.current = true + scheduleSave() + } + + // Flush a pending save when navigating away or unmounting. + useEffect(() => { + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + if (dirtyRef.current) void persist() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [path]) + + if (loadState === 'error') { + return ( + <div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground"> + <FileTextIcon className="size-6" /> + <p className="text-sm font-medium text-foreground">Cannot open this document</p> + <p className="max-w-md text-xs">The file may be corrupted or not a valid Word document.</p> + <button + type="button" + onClick={() => { void window.ipc.invoke('shell:openPath', { path }) }} + className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent" + > + <ExternalLinkIcon className="size-3.5" /> + Open in system + </button> + </div> + ) + } + + if (loadState === 'loading' || !buffer) { + return ( + <div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground"> + <Loader2Icon className="size-6 animate-spin" /> + <p className="text-sm">Loading document…</p> + </div> + ) + } + + return ( + <div className="relative flex h-full w-full flex-col overflow-hidden"> + <Suspense + fallback={ + <div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground"> + <Loader2Icon className="size-6 animate-spin" /> + <p className="text-sm">Loading editor…</p> + </div> + } + > + <LazyDocxEditor + key={path} + ref={editorRef} + documentBuffer={buffer} + mode="editing" + documentName={baseName(path)} + documentNameEditable={false} + onChange={handleChange} + onError={(err) => { console.error('docx editor error:', err) }} + className="flex-1 min-h-0" + /> + </Suspense> + {saveState !== 'idle' && ( + <div className="pointer-events-none absolute bottom-3 right-4 z-10 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur"> + {saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : 'Save failed'} + </div> + )} + </div> + ) +} diff --git a/apps/x/apps/renderer/src/lib/file-types.ts b/apps/x/apps/renderer/src/lib/file-types.ts index d4477f7a..c293ac6f 100644 --- a/apps/x/apps/renderer/src/lib/file-types.ts +++ b/apps/x/apps/renderer/src/lib/file-types.ts @@ -6,7 +6,7 @@ * also uses it to decide what to keep mounted. */ -export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf' +export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf' | 'docx' const VIEWER_BY_EXT: Record<string, ViewerType> = { html: 'html', @@ -31,6 +31,7 @@ const VIEWER_BY_EXT: Record<string, ViewerType> = { flac: 'audio', aac: 'audio', pdf: 'pdf', + docx: 'docx', } function extensionOf(path: string): string { diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 0605adaf..6c78cdce 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -4,6 +4,12 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + vitest: + specifier: 4.1.7 + version: 4.1.7 + importers: .: @@ -127,6 +133,9 @@ importers: apps/renderer: dependencies: + '@eigenpal/docx-editor-react': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(ai@5.0.117(zod@4.2.1))(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-avatar': specifier: ^1.1.11 version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -186,16 +195,16 @@ importers: version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-placeholder': specifier: 3.22.4 - version: 3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + version: 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) '@tiptap/extension-table': specifier: 3.22.4 version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-task-item': specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) '@tiptap/extension-task-list': specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) '@tiptap/pm': specifier: 3.22.4 version: 3.22.4 @@ -238,6 +247,33 @@ importers: posthog-js: specifier: ^1.332.0 version: 1.332.0 + prosemirror-commands: + specifier: ^1.7.1 + version: 1.7.1 + prosemirror-dropcursor: + specifier: ^1.8.2 + version: 1.8.2 + prosemirror-history: + specifier: ^1.5.0 + version: 1.5.0 + prosemirror-keymap: + specifier: ^1.2.3 + version: 1.2.3 + prosemirror-model: + specifier: ^1.25.7 + version: 1.25.7 + prosemirror-state: + specifier: ^1.4.4 + version: 1.4.4 + prosemirror-tables: + specifier: ^1.8.5 + version: 1.8.5 + prosemirror-transform: + specifier: ^1.12.0 + version: 1.12.0 + prosemirror-view: + specifier: ^1.41.8 + version: 1.41.8 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -795,6 +831,60 @@ packages: peerDependencies: zod: '>=3.25.76 <5' + '@eigenpal/docx-editor-agents@1.0.3': + resolution: {integrity: sha512-Bk/J9/PBnMCOxb6w4cHQiCTuN/1C4FtZM9evC9EXXcLP13yFMdqoEqsYs+Lh3HyaRRAaCZTrkfgOZyTqqyjtwQ==} + peerDependencies: + '@ai-sdk/vue': ^2.0.0 + ai: ^5.0.0 || ^6.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@ai-sdk/vue': + optional: true + ai: + optional: true + react: + optional: true + vue: + optional: true + + '@eigenpal/docx-editor-core@1.0.3': + resolution: {integrity: sha512-etpupuln9ZlHLW4DgS7877WBdMEChsAG0D1bEZLjF70isYbyxrd2ARWax745P7XMm4GqqkAfByzxE2GGWQmJaA==} + hasBin: true + peerDependencies: + prosemirror-commands: ^1.5.2 + prosemirror-dropcursor: ^1.8.2 + prosemirror-history: ^1.4.0 + prosemirror-keymap: ^1.2.2 + prosemirror-model: ^1.19.4 + prosemirror-state: ^1.4.3 + prosemirror-tables: ^1.8.5 + prosemirror-transform: ^1.12.0 + prosemirror-view: ^1.32.7 + + '@eigenpal/docx-editor-i18n@1.0.3': + resolution: {integrity: sha512-zwz/S+duPOnzg/kh4bs28T3UqI8mKMzHdmFgbWgMxwtTfUkAxaUAnAVbuZgrysl1aD2scv4Hfy4EgOZcFy9NnA==} + + '@eigenpal/docx-editor-react@1.0.3': + resolution: {integrity: sha512-KupDVHo6KC4KUs48bM1pMYFFbDJqkW8XyIhgsnLx+BWk2yOPU4bx2HfWB6H+JEVROA1h1AmhTAyE39gk75wg5w==} + peerDependencies: + prosemirror-commands: ^1.5.2 + prosemirror-dropcursor: ^1.8.2 + prosemirror-history: ^1.4.0 + prosemirror-keymap: ^1.2.2 + prosemirror-model: ^1.19.4 + prosemirror-state: ^1.4.3 + prosemirror-tables: ^1.8.5 + prosemirror-transform: ^1.12.0 + prosemirror-view: ^1.41.6 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + '@electron-forge/cli@7.11.1': resolution: {integrity: sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==} engines: {node: '>= 16.4.0'} @@ -1488,30 +1578,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.80': resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.80': resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.80': resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-x64-msvc@0.1.80': resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} @@ -2634,56 +2729,67 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -3002,24 +3108,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -3155,6 +3265,12 @@ packages: peerDependencies: '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list@3.22.4': + resolution: {integrity: sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==} + peerDependencies: + '@tiptap/core': 3.22.4 + '@tiptap/pm': 3.22.4 + '@tiptap/extension-list@3.22.5': resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==} peerDependencies: @@ -3207,6 +3323,12 @@ packages: peerDependencies: '@tiptap/core': 3.22.5 + '@tiptap/extensions@3.22.4': + resolution: {integrity: sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==} + peerDependencies: + '@tiptap/core': 3.22.4 + '@tiptap/pm': 3.22.4 + '@tiptap/extensions@3.22.5': resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==} peerDependencies: @@ -3663,6 +3785,10 @@ packages: engines: {node: '>=10.0.0'} deprecated: this version has critical issues, please update to the latest version + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -4472,6 +4598,10 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + docxtemplater@3.68.7: + resolution: {integrity: sha512-FwgeAKqY2vc9eVm2V2XGg8bq25B0OQjtSDITGi9zNnvu5GbtR4WvGjM5QNld/ALB6ZbsSuHskBPK9SvPpKhsbA==} + engines: {node: '>=0.10'} + dom-serializer@0.2.2: resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} @@ -5655,24 +5785,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -6386,6 +6520,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + papaparse@5.5.3: resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} @@ -6499,6 +6636,9 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pizzip@3.2.0: + resolution: {integrity: sha512-X4NPNICxCfIK8VYhF6wbksn81vTiziyLbvKuORVAmolvnUzl1A1xmz9DAWKxPRq9lZg84pJOOAMq3OE61bD8IQ==} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -6605,8 +6745,8 @@ packages: prosemirror-markdown@1.13.2: resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==} - prosemirror-model@1.25.4: - resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + prosemirror-model@1.25.7: + resolution: {integrity: sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==} prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} @@ -6617,11 +6757,11 @@ packages: prosemirror-tables@1.8.5: resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} - prosemirror-transform@1.10.5: - resolution: {integrity: sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==} + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} - prosemirror-view@1.41.4: - resolution: {integrity: sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==} + prosemirror-view@1.41.8: + resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} @@ -6979,6 +7119,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + scheduler@0.25.0-rc-603e6108-20241029: resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==} @@ -7800,6 +7944,10 @@ packages: engines: {node: '>=0.8'} hasBin: true + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + xmlbuilder2@2.1.2: resolution: {integrity: sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==} engines: {node: '>=8.0'} @@ -8588,6 +8736,61 @@ snapshots: dependencies: zod: 4.2.1 + '@eigenpal/docx-editor-agents@1.0.3(ai@5.0.117(zod@4.2.1))(react@19.2.3)': + dependencies: + docxtemplater: 3.68.7 + jszip: 3.10.1 + pizzip: 3.2.0 + xml-js: 1.6.11 + optionalDependencies: + ai: 5.0.117(zod@4.2.1) + react: 19.2.3 + + '@eigenpal/docx-editor-core@1.0.3(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8)': + dependencies: + docxtemplater: 3.68.7 + jszip: 3.10.1 + pizzip: 3.2.0 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-history: 1.5.0 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + xml-js: 1.6.11 + + '@eigenpal/docx-editor-i18n@1.0.3': {} + + '@eigenpal/docx-editor-react@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(ai@5.0.117(zod@4.2.1))(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@eigenpal/docx-editor-agents': 1.0.3(ai@5.0.117(zod@4.2.1))(react@19.2.3) + '@eigenpal/docx-editor-core': 1.0.3(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) + '@eigenpal/docx-editor-i18n': 1.0.3 + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + clsx: 2.1.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-history: 1.5.0 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + sonner: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@ai-sdk/vue' + - '@types/react' + - '@types/react-dom' + - ai + - vue + '@electron-forge/cli@7.11.1(encoding@0.1.13)(esbuild@0.24.2)': dependencies: '@electron-forge/core': 7.11.1(encoding@0.1.13)(esbuild@0.24.2) @@ -11264,6 +11467,11 @@ snapshots: dependencies: '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + dependencies: + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 + '@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) @@ -11277,9 +11485,9 @@ snapshots: dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: @@ -11290,13 +11498,13 @@ snapshots: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) '@tiptap/pm': 3.22.4 - '@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-text@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: @@ -11306,6 +11514,11 @@ snapshots: dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + dependencies: + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 + '@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) @@ -11319,12 +11532,12 @@ snapshots: prosemirror-gapcursor: 1.4.0 prosemirror-history: 1.5.0 prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 '@tiptap/react@3.22.4(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -11939,6 +12152,8 @@ snapshots: '@xmldom/xmldom@0.8.11': {} + '@xmldom/xmldom@0.9.10': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -12763,6 +12978,10 @@ snapshots: minimatch: 3.1.2 p-limit: 3.1.0 + docxtemplater@3.68.7: + dependencies: + '@xmldom/xmldom': 0.9.10 + dom-serializer@0.2.2: dependencies: domelementtype: 2.3.0 @@ -15227,6 +15446,8 @@ snapshots: pako@1.0.11: {} + pako@2.1.0: {} + papaparse@5.5.3: {} parent-module@1.0.1: @@ -15324,6 +15545,10 @@ snapshots: pify@4.0.1: {} + pizzip@3.2.0: + dependencies: + pako: 2.1.0 + pkce-challenge@5.0.1: {} pkg-types@1.3.1: @@ -15412,32 +15637,32 @@ snapshots: prosemirror-changeset@2.3.1: dependencies: - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 prosemirror-commands@1.7.1: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 prosemirror-dropcursor@1.8.2: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 prosemirror-gapcursor@1.4.0: dependencies: prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.4 + prosemirror-view: 1.41.8 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 rope-sequence: 1.3.4 prosemirror-keymap@1.2.3: @@ -15449,41 +15674,41 @@ snapshots: dependencies: '@types/markdown-it': 14.1.2 markdown-it: 14.1.0 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 - prosemirror-model@1.25.4: + prosemirror-model@1.25.7: dependencies: orderedmap: 2.1.1 prosemirror-schema-list@1.5.1: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 prosemirror-state@1.4.4: dependencies: - prosemirror-model: 1.25.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-model: 1.25.7 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 prosemirror-tables@1.8.5: dependencies: prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 - prosemirror-transform@1.10.5: + prosemirror-transform@1.12.0: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 - prosemirror-view@1.41.4: + prosemirror-view@1.41.8: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 protobufjs@7.5.4: dependencies: @@ -15986,6 +16211,8 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.6.0: {} + scheduler@0.25.0-rc-603e6108-20241029: {} scheduler@0.27.0: {} @@ -16884,6 +17111,10 @@ snapshots: wmf: 1.0.2 word: 0.3.0 + xml-js@1.6.11: + dependencies: + sax: 1.6.0 + xmlbuilder2@2.1.2: dependencies: '@oozcitak/dom': 1.15.5 From caea83aecf2fa7e432254d7a14573cdeb62a1f70 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Fri, 29 May 2026 22:23:21 +0530 Subject: [PATCH 24/35] clamp sync to 7 days even after long hiatus (#590) --- .../packages/core/src/knowledge/sync_gmail.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 5c32c7bf..77055f37 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -1060,16 +1060,20 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str // If the state file holds a last_sync timestamp (e.g. left over from a // prior Composio sync, or from a previous successful native sync that // we're falling back to after a history.list 404), use that as the - // floor instead of the default lookback. Carries forward Composio's - // last_sync on first migration so we don't refetch the last 7 days. + // floor — but never reach back further than lookbackDays. This caps the + // window at "1 week at most": if last_sync is within the lookback window + // we resume from it (a smaller window), otherwise we clamp to lookbackDays + // ago. Mail older than the cap that arrived during a long offline gap is + // intentionally skipped rather than backfilled. const state = loadState(stateFile); + const lookbackFloor = new Date(); + lookbackFloor.setDate(lookbackFloor.getDate() - lookbackDays); let pastDate: Date; - if (state.last_sync) { + if (state.last_sync && new Date(state.last_sync) > lookbackFloor) { pastDate = new Date(state.last_sync); console.log(`Performing full sync from last_sync=${state.last_sync}...`); } else { - pastDate = new Date(); - pastDate.setDate(pastDate.getDate() - lookbackDays); + pastDate = lookbackFloor; console.log(`Performing full sync of last ${lookbackDays} days...`); } @@ -1335,12 +1339,22 @@ async function performSync() { // this runs once, the cache directory is populated and we fall back to // partial-sync on subsequent calls. const cacheMissing = !fs.existsSync(CACHE_DIR) || fs.readdirSync(CACHE_DIR).length === 0; + // partialSync replays *every* messageAdded since the stored historyId, + // regardless of date — so after a long offline gap a still-valid + // historyId would pull the entire gap (e.g. 3 weeks). To honor the + // "1 week at most" cap, bypass it when last_sync is older than the + // lookback window and run a (date-clamped) fullSync instead. + const gapMs = state.last_sync ? Date.now() - new Date(state.last_sync).getTime() : 0; + const gapTooLarge = gapMs > LOOKBACK_DAYS * 24 * 60 * 60 * 1000; if (!state.historyId) { console.log("No history ID found, starting full sync..."); await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); } else if (cacheMissing) { console.log("History ID present but inbox cache empty — running full sync to backfill snapshots..."); await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); + } else if (gapTooLarge) { + console.log(`Last sync older than ${LOOKBACK_DAYS} days — running full sync clamped to the lookback window instead of partial sync...`); + await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); } else { console.log("History ID found, starting partial sync..."); await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); From 8a8b78071dfa9d652d3911bb758ea1ca16e0f8fa Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:25:05 +0530 Subject: [PATCH 25/35] more recents in side bar --- .../renderer/src/components/sidebar-content.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 0368b1da..f5870ec4 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -512,7 +512,7 @@ export function SidebarContentPanel({ const out: TreeNode[] = [] const walk = (nodes: TreeNode[]) => { for (const n of nodes) { - if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace') continue + if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace' || n.path === 'knowledge/Agent Notes') continue if (n.kind === 'file') out.push(n) else if (n.children?.length) walk(n.children) } @@ -521,11 +521,11 @@ export function SidebarContentPanel({ return out .filter((n) => n.stat?.mtimeMs) .sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0)) - .slice(0, 5) + .slice(0, 10) }, [tree]) // Recents: most recently touched notes / agents / chats, interleaved by - // recency. Capped per type (3 notes, 2 agents, 1 chat) and 5 overall. + // recency. Capped per type (4 notes, 4 agents, 4 chats) and 12 overall. type QuickAccessItem = { key: string label: string @@ -536,7 +536,7 @@ export function SidebarContentPanel({ const quickAccessItems = React.useMemo<QuickAccessItem[]>(() => { const items: QuickAccessItem[] = [] - for (const note of recentNotes.slice(0, 3)) { + for (const note of recentNotes.slice(0, 4)) { items.push({ key: `note:${note.path}`, label: displayNoteName(note), @@ -551,7 +551,7 @@ export function SidebarContentPanel({ const ms = ts ? new Date(ts).getTime() : 0 return Number.isFinite(ms) ? ms : 0 } - for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 2)) { + for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 4)) { items.push({ key: `agent:${t.slug}`, label: t.name, @@ -565,7 +565,7 @@ export function SidebarContentPanel({ const ms = new Date(r.createdAt).getTime() return Number.isFinite(ms) ? ms : 0 } - for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 1)) { + for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 4)) { items.push({ key: `chat:${r.id}`, label: r.title || '(Untitled chat)', @@ -575,7 +575,7 @@ export function SidebarContentPanel({ }) } - return items.sort((a, b) => b.recency - a.recency).slice(0, 5) + return items.sort((a, b) => b.recency - a.recency).slice(0, 12) }, [recentNotes, bgTaskSummaries, recentRuns, onSelectFile, onOpenAgent, onOpenRun]) // Workspace count for the Workspaces sublabel — top-level dir children of From d47cab6a0f6ecf1e26a438abf0dadfa0f89e1d80 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:57:50 +0530 Subject: [PATCH 26/35] Add run-level auto permission mode - add LLM-based auto permission classifier for permission-gated tool calls - store run-level permission mode and auto permission decision events - auto-approve low-risk calls, and bubble auto-denied calls to manual approval - show auto-denied reasons in chat and auto-approved labels below tool cards - add BYOK setting for the auto-permission decision model --- apps/x/apps/renderer/src/App.tsx | 119 +++++++++++++----- .../ai-elements/auto-permission-decision.tsx | 100 +++++++++++++++ .../src/components/ai-elements/tool.tsx | 60 +++++++-- .../components/chat-input-with-mentions.tsx | 44 ++++++- .../renderer/src/components/chat-sidebar.tsx | 63 +++++++--- .../src/components/settings-dialog.tsx | 69 ++++++++-- .../renderer/src/lib/chat-conversation.ts | 4 +- apps/x/packages/core/src/agents/runtime.ts | 117 +++++++++++++++-- apps/x/packages/core/src/models/defaults.ts | 12 ++ apps/x/packages/core/src/models/repo.ts | 1 + apps/x/packages/core/src/runs/repo.ts | 6 +- apps/x/packages/core/src/runs/runs.ts | 1 + .../security/auto-permission-classifier.ts | 112 +++++++++++++++++ apps/x/packages/shared/src/models.ts | 5 + apps/x/packages/shared/src/runs.ts | 13 ++ 15 files changed, 641 insertions(+), 85 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/ai-elements/auto-permission-decision.tsx create mode 100644 apps/x/packages/core/src/security/auto-permission-classifier.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 57a03727..56445821 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -11,7 +11,7 @@ import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown import { ChatSidebar } from './components/chat-sidebar'; import { ChatHeader } from './components/chat-header'; import { ChatEmptyState } from './components/chat-empty-state'; -import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; +import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; @@ -56,9 +56,10 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'; import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; -import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; +import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, SidebarProvider, @@ -961,7 +962,7 @@ function App() { voice.start() }, [voice]) - const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise<void>) | null>(null) + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise<void>) | null>(null) const pendingVoiceInputRef = useRef(false) // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload @@ -1180,6 +1181,7 @@ function App() { const [allPermissionRequests, setAllPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map()) // Track permission responses (toolCallId -> response) const [permissionResponses, setPermissionResponses] = useState<Map<string, 'approve' | 'deny'>>(new Map()) + const [autoPermissionDecisions, setAutoPermissionDecisions] = useState<Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>>(new Map()) useEffect(() => { chatViewStateByTabRef.current = chatViewStateByTab @@ -1193,6 +1195,7 @@ function App() { pendingAskHumanRequests: new Map(pendingAskHumanRequests), allPermissionRequests: new Map(allPermissionRequests), permissionResponses: new Map(permissionResponses), + autoPermissionDecisions: new Map(autoPermissionDecisions), } setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot })) }, [ @@ -1203,6 +1206,7 @@ function App() { pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) useEffect(() => { @@ -2026,6 +2030,7 @@ function App() { // Track permission requests and responses from history const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>() const permResponseMap = new Map<string, 'approve' | 'deny'>() + const autoPermissionDecisions = new Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>() const askHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>() const respondedAskHumanIds = new Set<string>() @@ -2034,6 +2039,8 @@ function App() { allPermissionRequests.set(event.toolCall.toolCallId, event) } else if (event.type === 'tool-permission-response') { permResponseMap.set(event.toolCallId, event.response) + } else if (event.type === 'tool-permission-auto-decision') { + autoPermissionDecisions.set(event.toolCallId, event) } else if (event.type === 'ask-human-request') { askHumanRequests.set(event.toolCallId, event) } else if (event.type === 'ask-human-response') { @@ -2066,6 +2073,7 @@ function App() { setPendingAskHumanRequests(pendingAsks) setAllPermissionRequests(allPermissionRequests) setPermissionResponses(permResponseMap) + setAutoPermissionDecisions(autoPermissionDecisions) // Restore the run's per-chat work directory into the tab it was loaded into. const tabId = activeChatTabIdRef.current @@ -2375,6 +2383,16 @@ function App() { break } + case 'tool-permission-auto-decision': { + if (!isActiveRun) return + setAutoPermissionDecisions(prev => { + const next = new Map(prev) + next.set(event.toolCallId, event) + return next + }) + break + } + case 'ask-human-request': { if (!isActiveRun) return const key = event.toolCallId @@ -2491,6 +2509,7 @@ function App() { stagedAttachments: StagedAttachment[] = [], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', + permissionMode?: PermissionMode, ) => { if (isProcessing) return @@ -2530,6 +2549,7 @@ function App() { const run = await window.ipc.invoke('runs:create', { agentId, ...(selected ? { model: selected.model, provider: selected.provider } : {}), + permissionMode: permissionMode ?? 'manual', }) currentRunId = run.id newRunCreatedAt = run.createdAt @@ -2734,6 +2754,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setSelectedBackgroundTask(null) setChatViewportAnchor(activeChatTabIdRef.current, null) setChatViewStateByTab(prev => ({ @@ -2760,6 +2781,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setChatViewportAnchor(tab.id, null) } }, [loadRun, setChatViewportAnchor]) @@ -2785,6 +2807,7 @@ function App() { setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests)) setAllPermissionRequests(new Map(cached.allPermissionRequests)) setPermissionResponses(new Map(cached.permissionResponses)) + setAutoPermissionDecisions(new Map(cached.autoPermissionDecisions)) setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId))) return true }, []) @@ -5057,7 +5080,11 @@ function App() { } }, [isGraphOpen, knowledgeFilePaths]) - const renderConversationItem = (item: ConversationItem, tabId: string) => { + const renderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, + ) => { if (isChatMessage(item)) { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { @@ -5155,6 +5182,7 @@ function App() { key={item.id} open={isToolOpenForTab(tabId, item.id)} onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} + autoPermissionDetail={options?.autoPermissionDetail} > <ToolHeader title={toolTitle} @@ -5197,6 +5225,7 @@ function App() { pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, }), [ runId, conversation, @@ -5204,6 +5233,7 @@ function App() { pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) const emptyChatTabState = React.useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), []) const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => { @@ -5790,7 +5820,7 @@ function App() { <> {groupConversationItems( tabState.conversation, - (id) => !!tabState.allPermissionRequests.get(id) + (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) ).map(item => { if (isToolGroup(item)) { return ( @@ -5802,41 +5832,61 @@ function App() { /> ) } - const rendered = renderConversationItem(item, tab.id) + const autoDecision = isToolCall(item) + ? tabState.autoPermissionDecisions.get(item.id) + : undefined + const rendered = renderConversationItem( + item, + tab.id, + autoDecision?.decision === 'allow' + ? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } } + : undefined, + ) if (isToolCall(item)) { + const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null const permRequest = tabState.allPermissionRequests.get(item.id) - if (permRequest) { + if (deniedAutoDecision || permRequest) { const response = tabState.permissionResponses.get(item.id) || null return ( <React.Fragment key={item.id}> - <PermissionRequest - toolCall={permRequest.toolCall} - permission={permRequest.permission} - onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - 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) + {deniedAutoDecision && ( + <AutoPermissionDecision + toolCall={deniedAutoDecision.toolCall} + permission={deniedAutoDecision.permission} + decision={deniedAutoDecision.decision} + reason={deniedAutoDecision.reason} + /> + )} + {permRequest && ( + <PermissionRequest + toolCall={permRequest.toolCall} + permission={permRequest.permission} + onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + 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} - /> + }} + isProcessing={isActive && isProcessing} + response={response} + /> + )} {rendered} </React.Fragment> ) @@ -5989,6 +6039,7 @@ function App() { pendingAskHumanRequests={pendingAskHumanRequests} allPermissionRequests={allPermissionRequests} permissionResponses={permissionResponses} + autoPermissionDecisions={autoPermissionDecisions} onPermissionResponse={handlePermissionResponse} onAskHumanResponse={handleAskHumanResponse} isToolOpenForTab={isToolOpenForTab} diff --git a/apps/x/apps/renderer/src/components/ai-elements/auto-permission-decision.tsx b/apps/x/apps/renderer/src/components/ai-elements/auto-permission-decision.tsx new file mode 100644 index 00000000..3c34aaec --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/auto-permission-decision.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { CheckCircle2Icon, ShieldAlertIcon, Terminal } from "lucide-react"; +import type { ComponentProps } from "react"; +import { ToolCallPart } from "@x/shared/dist/message.js"; +import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; +import z from "zod"; + +export type AutoPermissionDecisionProps = ComponentProps<"div"> & { + toolCall: z.infer<typeof ToolCallPart>; + decision: "allow" | "deny"; + reason: string; + permission?: z.infer<typeof ToolPermissionMetadata>; +}; + +const fileActionLabels: Record<string, string> = { + read: "Read file", + list: "List folder", + search: "Search files", + write: "Write files", + delete: "Delete path", +}; + +export function AutoPermissionDecision({ + className, + toolCall, + decision, + reason, + permission, + ...props +}: AutoPermissionDecisionProps) { + const command = permission?.kind === "command" || toolCall.toolName === "executeCommand" + ? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments + ? String(toolCall.arguments.command) + : JSON.stringify(toolCall.arguments)) + : null; + const filePermission = permission?.kind === "file" ? permission : null; + const allowed = decision === "allow"; + + return ( + <div + className={cn( + "not-prose mb-4 w-full rounded-md border", + allowed + ? "border-green-500/50 bg-green-50/80 dark:border-green-500/35 dark:bg-green-950/30" + : "border-[#fa2525]/60 bg-[#fa2525]/15 dark:border-[#fa2525]/50 dark:bg-[#fa2525]/20", + className, + )} + {...props} + > + <div className="space-y-3 p-4"> + <div className="flex items-start gap-3"> + {allowed ? ( + <CheckCircle2Icon className="mt-0.5 size-5 shrink-0 text-green-600 dark:text-green-400" /> + ) : ( + <ShieldAlertIcon className="mt-0.5 size-5 shrink-0 text-destructive" /> + )} + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-center gap-2"> + <h3 className="text-sm font-semibold text-foreground"> + {allowed ? "Auto Allowed" : "Auto Denied"} + </h3> + <Badge variant="secondary" className="bg-secondary text-foreground"> + <Terminal className="mr-1 size-3" /> + {toolCall.toolName} + </Badge> + </div> + <p className="mt-1 text-sm text-muted-foreground">{reason}</p> + </div> + </div> + {command && ( + <div className="rounded-md border bg-background/50 p-3"> + <p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Command</p> + <pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">{command}</pre> + </div> + )} + {filePermission && ( + <div className="space-y-3 rounded-md border bg-background/50 p-3"> + <div> + <p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Action</p> + <p className="text-xs font-medium text-foreground"> + {fileActionLabels[filePermission.operation] ?? filePermission.operation} + </p> + </div> + <div> + <p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Path{filePermission.paths.length === 1 ? "" : "s"} + </p> + <pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground"> + {filePermission.paths.join("\n")} + </pre> + </div> + </div> + )} + </div> + </div> + ); +} diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 61ba6fbd..9635b244 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -5,12 +5,18 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { ToolUIPart } from "ai"; import { ChevronDownIcon, CircleCheck, LoaderIcon, + ShieldCheckIcon, XCircleIcon, } from "lucide-react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; @@ -45,17 +51,51 @@ const ToolCode = ({ </pre> ); -export type ToolProps = ComponentProps<typeof Collapsible>; +export type ToolAutoPermissionDetail = { + decision: "allow"; + reason: string; +}; -export const Tool = ({ className, ...props }: ToolProps) => ( - <Collapsible - className={cn( - "not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30", - className - )} - {...props} - /> -); +export type ToolProps = ComponentProps<typeof Collapsible> & { + autoPermissionDetail?: ToolAutoPermissionDetail; +}; + +export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => { + const toolCard = ( + <Collapsible + className={cn( + autoPermissionDetail + ? "w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30" + : "not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30", + className + )} + {...props} + > + {children} + </Collapsible> + ); + + if (!autoPermissionDetail) return toolCard; + + return ( + <div className="not-prose mb-4 w-full"> + {toolCard} + <div className="mt-1 flex justify-end px-3"> + <Tooltip> + <TooltipTrigger asChild> + <span className="inline-flex cursor-help items-center gap-1 text-[11px] text-muted-foreground/70"> + <ShieldCheckIcon className="size-3 text-muted-foreground/70" /> + Auto-approved + </span> + </TooltipTrigger> + <TooltipContent side="bottom" align="end" className="max-w-sm"> + {autoPermissionDetail.reason} + </TooltipContent> + </Tooltip> + </div> + </div> + ); +}; export type ToolHeaderProps = { title?: string; 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 360a8657..8c62054c 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 @@ -17,6 +17,7 @@ import { LoaderIcon, Mic, Plus, + ShieldCheck, Square, Terminal, X, @@ -85,6 +86,8 @@ export interface SelectedModel { model: string } +export type PermissionMode = 'manual' | 'auto' + function getSelectedModelDisplayName(model: string) { return model.split('/').pop() || model } @@ -109,7 +112,7 @@ function getAttachmentIcon(kind: AttachmentIconKind) { } interface ChatInputInnerProps { - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void onStop?: () => void isProcessing: boolean isStopping?: boolean @@ -182,11 +185,13 @@ function ChatInputInner({ const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude') const [codeModeEnabled, setCodeModeEnabled] = useState(false) const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) + const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto') // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { if (!runId) { setLockedModel(null) + setPermissionMode('auto') return } let cancelled = false @@ -195,6 +200,7 @@ function ChatInputInner({ if (run.provider && run.model) { setLockedModel({ provider: run.provider, model: run.model }) } + setPermissionMode(run.permissionMode ?? 'manual') }).catch(() => { /* legacy run or fetch failure — leave unlocked */ }) return () => { cancelled = true } }, [runId]) @@ -482,13 +488,13 @@ function ChatInputInner({ if (!canSubmit) return // codeMode is sticky per conversation — don't reset after send. const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined - onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode) + onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode) controller.textInput.clear() controller.mentions.clearMentions() setAttachments([]) // Web search toggle stays on for the rest of the chat session; the user // turns it off explicitly. (Not persisted across app restarts.) - }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, workDir]) + }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -709,6 +715,36 @@ function ChatInputInner({ </span> </button> )} + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={() => { + if (runId) return + setPermissionMode((mode) => mode === 'auto' ? 'manual' : 'auto') + }} + disabled={Boolean(runId)} + className={cn( + "flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors", + permissionMode === 'auto' + ? "bg-secondary text-foreground hover:bg-secondary/70" + : "text-muted-foreground hover:bg-muted hover:text-foreground", + runId && "cursor-not-allowed opacity-70 hover:bg-secondary" + )} + aria-label="Permission mode" + > + <ShieldCheck className="h-3.5 w-3.5" /> + <span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span> + </button> + </TooltipTrigger> + <TooltipContent side="top"> + {runId + ? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}` + : permissionMode === 'auto' + ? 'Auto-permission on — click for manual approval prompts' + : 'Manual approval prompts — click for auto-permission'} + </TooltipContent> + </Tooltip> {codeModeFeatureEnabled && (codeModeEnabled ? ( <div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground"> <Tooltip> @@ -915,7 +951,7 @@ export interface ChatInputWithMentionsProps { knowledgeFiles: string[] recentFiles: string[] visibleFiles: string[] - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void onStop?: () => void isProcessing: boolean isStopping?: boolean diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 7987e6dd..f8923e4f 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -28,6 +28,7 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } import { WebSearchResult } from '@/components/ai-elements/web-search-result' import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { PermissionRequest } from '@/components/ai-elements/permission-request' +import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision' import { TerminalOutput } from '@/components/terminal-output' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' @@ -36,7 +37,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over import { defaultRemarkPlugins } from 'streamdown' import remarkBreaks from 'remark-breaks' import { type ChatTab } from '@/components/tab-bar' -import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { useSidebar } from '@/components/ui/sidebar' import { wikiLabel } from '@/lib/wiki-links' @@ -139,7 +140,7 @@ interface ChatSidebarProps { isProcessing: boolean isStopping?: boolean onStop?: () => void - onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void knowledgeFiles?: string[] recentFiles?: string[] visibleFiles?: string[] @@ -154,6 +155,7 @@ interface ChatSidebarProps { pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests'] permissionResponses?: ChatTabViewState['permissionResponses'] + autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions'] onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void isToolOpenForTab?: (tabId: string, toolId: string) => boolean @@ -211,6 +213,7 @@ export function ChatSidebar({ pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), permissionResponses = new Map(), + autoPermissionDecisions = new Map(), onPermissionResponse, onAskHumanResponse, isToolOpenForTab, @@ -325,6 +328,7 @@ export function ChatSidebar({ pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, }), [ runId, conversation, @@ -332,6 +336,7 @@ export function ChatSidebar({ pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), []) const getTabState = useCallback((tabId: string): ChatTabViewState => { @@ -358,7 +363,11 @@ export function ChatSidebar({ } }, [activeRunId]) - const renderConversationItem = (item: ConversationItem, tabId: string) => { + const renderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, + ) => { if (isChatMessage(item)) { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { @@ -451,6 +460,7 @@ export function ChatSidebar({ key={item.id} open={isToolOpenForTab?.(tabId, item.id) ?? false} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)} + autoPermissionDetail={options?.autoPermissionDetail} > <ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} /> <ToolContent> @@ -626,7 +636,7 @@ export function ChatSidebar({ <> {groupConversationItems( tabState.conversation, - (id) => !!tabState.allPermissionRequests.get(id) + (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) ).map((item) => { if (isToolGroup(item)) { return ( @@ -638,22 +648,43 @@ export function ChatSidebar({ /> ) } - const rendered = renderConversationItem(item, tab.id) - if (isToolCall(item) && onPermissionResponse) { + const autoDecision = isToolCall(item) + ? tabState.autoPermissionDecisions.get(item.id) + : undefined + const rendered = renderConversationItem( + item, + tab.id, + autoDecision?.decision === 'allow' + ? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } } + : undefined, + ) + if (isToolCall(item)) { + const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null const permRequest = tabState.allPermissionRequests.get(item.id) - if (permRequest) { + if (deniedAutoDecision || (permRequest && onPermissionResponse)) { const response = tabState.permissionResponses.get(item.id) || null return ( <React.Fragment key={item.id}> - <PermissionRequest - toolCall={permRequest.toolCall} - onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} - onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isActive && isProcessing} - response={response} - /> + {deniedAutoDecision && ( + <AutoPermissionDecision + toolCall={deniedAutoDecision.toolCall} + permission={deniedAutoDecision.permission} + decision={deniedAutoDecision.decision} + reason={deniedAutoDecision.reason} + /> + )} + {permRequest && onPermissionResponse && ( + <PermissionRequest + toolCall={permRequest.toolCall} + permission={permRequest.permission} + onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} + onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} + onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + isProcessing={isActive && isProcessing} + response={response} + /> + )} {rendered} </React.Fragment> ) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 37e0a930..74082664 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -277,17 +277,27 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = { "openai-compatible": "http://localhost:1234/v1", } +type ProviderModelConfig = { + apiKey: string + baseURL: string + models: string[] + knowledgeGraphModel: string + meetingNotesModel: string + liveNoteAgentModel: string + autoPermissionDecisionModel: string +} + function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { const [provider, setProvider] = useState<LlmProviderFlavor>("openai") const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null) - const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({ - openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, + const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({ + openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, }) const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({}) const [modelsLoading, setModelsLoading] = useState(false) @@ -313,7 +323,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) const updateConfig = useCallback( - (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => { + (prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => { setProviderConfigs(prev => ({ ...prev, [prov]: { ...prev[prov], ...updates }, @@ -388,6 +398,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: e.knowledgeGraphModel || "", meetingNotesModel: e.meetingNotesModel || "", liveNoteAgentModel: e.liveNoteAgentModel || "", + autoPermissionDecisionModel: e.autoPermissionDecisionModel || "", }; } } @@ -406,6 +417,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: parsed.knowledgeGraphModel || "", meetingNotesModel: parsed.meetingNotesModel || "", liveNoteAgentModel: parsed.liveNoteAgentModel || "", + autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "", }; } return next; @@ -481,6 +493,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined, liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined, + autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -515,6 +528,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, meetingNotesModel: config.meetingNotesModel.trim() || undefined, liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined, + autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined, }) setDefaultProvider(prov) window.dispatchEvent(new Event('models-config-changed')) @@ -546,6 +560,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined + parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined } await window.ipc.invoke("workspace:writeFile", { path: "config/models.json", @@ -553,7 +568,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { }) setProviderConfigs(prev => ({ ...prev, - [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" }, + [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, })) setTestState({ status: "idle" }) window.dispatchEvent(new Event('models-config-changed')) @@ -811,6 +826,40 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { </Select> )} </div> + + {/* Auto-permission model */} + <div className="space-y-2"> + <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span> + {modelsLoading ? ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="size-4 animate-spin" /> + Loading... + </div> + ) : showModelInput ? ( + <Input + value={activeConfig.autoPermissionDecisionModel} + onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })} + placeholder={primaryModel || "Enter model"} + /> + ) : ( + <Select + value={activeConfig.autoPermissionDecisionModel || "__same__"} + onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })} + > + <SelectTrigger> + <SelectValue placeholder="Select a model" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__same__">Same as assistant</SelectItem> + {modelsForProvider.map((m) => ( + <SelectItem key={m.id} value={m.id}> + {m.name || m.id} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> </div> {/* API Key */} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index bbf1cde2..7b0c15c6 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -1,6 +1,6 @@ import type { ToolUIPart } from 'ai' import z from 'zod' -import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' +import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js' export interface MessageAttachment { @@ -46,6 +46,7 @@ export type ChatTabViewState = { pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>> allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>> permissionResponses: Map<string, PermissionResponse> + autoPermissionDecisions: Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>> } export type ChatViewportAnchorState = { @@ -60,6 +61,7 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({ pendingAskHumanRequests: new Map(), allPermissionRequests: new Map(), permissionResponses: new Map(), + autoPermissionDecisions: new Map(), }) export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error' diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 84aa4092..f42fad72 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -36,6 +36,7 @@ import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js"; import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js"; import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js"; import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js"; +import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js"; const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); @@ -901,6 +902,7 @@ export class AgentState { agentName: string | null = null; runModel: string | null = null; runProvider: string | null = null; + permissionMode: "manual" | "auto" = "manual"; runUseCase: UseCase | null = null; runSubUseCase: string | null = null; messages: z.infer<typeof MessageList> = []; @@ -912,6 +914,8 @@ export class AgentState { pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {}; allowedToolCallIds: Record<string, true> = {}; deniedToolCallIds: Record<string, true> = {}; + autoAllowedToolCalls: Record<string, { reason: string }> = {}; + autoDeniedToolCalls: Record<string, { reason: string }> = {}; sessionAllowedCommands: Set<string> = new Set(); sessionAllowedFileAccess: FileAccessGrant[] = []; @@ -1019,6 +1023,7 @@ export class AgentState { this.agentName = event.agentName; this.runModel = event.model; this.runProvider = event.provider; + this.permissionMode = event.permissionMode ?? "manual"; this.runUseCase = event.useCase ?? null; this.runSubUseCase = event.subUseCase ?? null; break; @@ -1031,6 +1036,7 @@ export class AgentState { this.subflowStates[event.toolCallId].agentName = event.agentName; this.subflowStates[event.toolCallId].runModel = this.runModel; this.subflowStates[event.toolCallId].runProvider = this.runProvider; + this.subflowStates[event.toolCallId].permissionMode = this.permissionMode; this.subflowStates[event.toolCallId].runUseCase = this.runUseCase; this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase; break; @@ -1081,10 +1087,22 @@ export class AgentState { break; case "deny": this.deniedToolCallIds[event.toolCallId] = true; + delete this.autoDeniedToolCalls[event.toolCallId]; break; } delete this.pendingToolPermissionRequests[event.toolCallId]; break; + case "tool-permission-auto-decision": + switch (event.decision) { + case "allow": + this.allowedToolCallIds[event.toolCallId] = true; + this.autoAllowedToolCalls[event.toolCallId] = { reason: event.reason }; + break; + case "deny": + this.autoDeniedToolCalls[event.toolCallId] = { reason: event.reason }; + break; + } + break; case "ask-human-request": this.pendingAskHumanRequests[event.toolCallId] = event; break; @@ -1190,13 +1208,19 @@ export async function* streamAgent({ // if tool has been denied, deny if (state.deniedToolCallIds[toolCallId]) { _logger.log('returning denied tool message, reason: tool has been denied'); + const autoDenied = state.autoDeniedToolCalls[toolCallId]; yield* processEvent({ runId, messageId: await idGenerator.next(), type: "message", message: { role: "tool", - content: "Unable to execute this tool: Permission was denied.", + content: autoDenied + ? JSON.stringify({ + success: false, + error: `Auto-permission denied: ${autoDenied.reason}`, + }) + : "Unable to execute this tool: Permission was denied.", toolCallId: toolCallId, toolName: toolCall.toolName, }, @@ -1493,6 +1517,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated // if there were any ask-human calls, emit those events if (message.content instanceof Array) { + const permissionCandidates: AutoPermissionCandidate[] = []; for (const part of message.content) { if (part.type === "tool-call") { const underlyingTool = agent.tools![part.toolName]; @@ -1518,14 +1543,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated state.sessionAllowedFileAccess, ); if (permission) { - loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId); - yield* processEvent({ - runId, - type: "tool-permission-request", - toolCall: part, - permission, - subflow: [], - }); + permissionCandidates.push({ toolCall: part, permission }); } if (underlyingTool.type === "agent" && underlyingTool.name) { loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId); @@ -1549,6 +1567,87 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated } } } + + if (permissionCandidates.length > 0) { + if (state.permissionMode === "auto") { + let decisionsByToolCallId = new Map<string, { decision: "allow" | "deny"; reason: string }>(); + try { + const decisions = await classifyToolPermissions({ + runId, + agentName: state.agentName, + messages: convertFromMessages(state.messages), + candidates: permissionCandidates, + useCase: state.runUseCase ?? "copilot_chat", + subUseCase: state.runSubUseCase, + }); + decisionsByToolCallId = new Map(decisions.map((decision) => [ + decision.toolCallId, + { decision: decision.decision, reason: decision.reason }, + ])); + } catch (error) { + loopLogger.log( + 'auto-permission classifier failed:', + error instanceof Error ? error.message : String(error), + ); + } + + for (const candidate of permissionCandidates) { + const decision = decisionsByToolCallId.get(candidate.toolCall.toolCallId); + if (!decision) { + loopLogger.log('auto-permission missing decision, falling back to prompt:', candidate.toolCall.toolCallId); + yield* processEvent({ + runId, + type: "tool-permission-request", + toolCall: candidate.toolCall, + permission: candidate.permission, + subflow: [], + }); + continue; + } + + loopLogger.log( + 'emitting tool-permission-auto-decision, toolCallId:', + candidate.toolCall.toolCallId, + 'decision:', + decision.decision, + ); + yield* processEvent({ + runId, + type: "tool-permission-auto-decision", + toolCallId: candidate.toolCall.toolCallId, + toolCall: candidate.toolCall, + permission: candidate.permission, + decision: decision.decision, + reason: decision.reason, + subflow: [], + }); + if (decision.decision === "deny") { + loopLogger.log( + 'auto-permission denied, falling back to prompt:', + candidate.toolCall.toolCallId, + ); + yield* processEvent({ + runId, + type: "tool-permission-request", + toolCall: candidate.toolCall, + permission: candidate.permission, + subflow: [], + }); + } + } + } else { + for (const candidate of permissionCandidates) { + loopLogger.log('emitting tool-permission-request, toolCallId:', candidate.toolCall.toolCallId); + yield* processEvent({ + runId, + type: "tool-permission-request", + toolCall: candidate.toolCall, + permission: candidate.permission, + subflow: [], + }); + } + } + } } } } diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 3163438c..6d05e393 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -8,6 +8,7 @@ const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4"; const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite"; const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite"; +const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite"; /** * The single source of truth for "what model+provider should we use when @@ -76,6 +77,17 @@ export async function getLiveNoteAgentModel(): Promise<string> { return cfg.liveNoteAgentModel ?? cfg.model; } +/** + * Model used by the auto-permission classifier. + * Signed-in: curated default. BYOK: user override + * (`autoPermissionDecisionModel`) or assistant model. + */ +export async function getAutoPermissionDecisionModel(): Promise<string> { + if (await isSignedIn()) return SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL; + const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig(); + return cfg.autoPermissionDecisionModel ?? cfg.model; +} + /** * Model used by the meeting-notes summarizer. No special signed-in default — * historically meetings used the assistant model. BYOK: user override diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index 3f21e675..29febf4b 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -53,6 +53,7 @@ export class FSModelConfigRepo implements IModelConfigRepo { knowledgeGraphModel: config.knowledgeGraphModel, meetingNotesModel: config.meetingNotesModel, liveNoteAgentModel: config.liveNoteAgentModel, + autoPermissionDecisionModel: config.autoPermissionDecisionModel, }; const toWrite = { ...config, providers: existingProviders }; diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index addb4f35..8a1d3e85 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -35,6 +35,7 @@ export type CreateRunRepoOptions = { agentId: string; model: string; provider: string; + permissionMode: "manual" | "auto"; useCase: z.infer<typeof UseCase>; subUseCase?: string; }; @@ -204,6 +205,7 @@ export class FSRunsRepo implements IRunsRepo { agentName: options.agentId, model: options.model, provider: options.provider, + permissionMode: options.permissionMode, useCase: options.useCase, ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), subflow: [], @@ -216,6 +218,7 @@ export class FSRunsRepo implements IRunsRepo { agentId: options.agentId, model: options.model, provider: options.provider, + permissionMode: options.permissionMode, useCase: options.useCase, ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), log: [start], @@ -251,6 +254,7 @@ export class FSRunsRepo implements IRunsRepo { agentId: start.agentName, model: start.model, provider: start.provider, + permissionMode: start.permissionMode ?? "manual", ...(start.useCase ? { useCase: start.useCase } : {}), ...(start.subUseCase ? { subUseCase: start.subUseCase } : {}), log: events, @@ -320,4 +324,4 @@ export class FSRunsRepo implements IRunsRepo { async delete(id: string): Promise<void> { await fsp.unlink(runLogPath(id)); } -} \ No newline at end of file +} diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index c316cd3b..f832c00d 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -32,6 +32,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise agentId: opts.agentId, model, provider, + permissionMode: opts.permissionMode ?? "manual", useCase, ...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}), }); diff --git a/apps/x/packages/core/src/security/auto-permission-classifier.ts b/apps/x/packages/core/src/security/auto-permission-classifier.ts new file mode 100644 index 00000000..352512be --- /dev/null +++ b/apps/x/packages/core/src/security/auto-permission-classifier.ts @@ -0,0 +1,112 @@ +import { generateObject, type ModelMessage } from "ai"; +import z from "zod"; +import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; +import { ToolCallPart } from "@x/shared/dist/message.js"; +import { captureLlmUsage } from "../analytics/usage.js"; +import { withUseCase, type UseCase } from "../analytics/use_case.js"; +import { getAutoPermissionDecisionModel, getDefaultModelAndProvider, resolveProviderConfig } from "../models/defaults.js"; +import { createProvider } from "../models/models.js"; + +const DecisionSchema = z.object({ + decisions: z.array(z.object({ + toolCallId: z.string(), + decision: z.enum(["allow", "deny"]), + reason: z.string().min(1), + })), +}); + +export type AutoPermissionCandidate = { + toolCall: z.infer<typeof ToolCallPart>; + permission: z.infer<typeof ToolPermissionMetadata>; +}; + +export type AutoPermissionDecision = { + toolCallId: string; + decision: "allow" | "deny"; + reason: string; +}; + +const SYSTEM_PROMPT = `You decide whether a personal productivity app may run tool calls without interrupting the user. + +You only receive tool calls that already require permission under deterministic rules. + +Allow a tool call only when it is clearly consistent with the user's request and low risk. +Deny tool calls that are destructive, credential-sensitive, privacy-sensitive, broad in scope, likely irreversible, or not clearly requested. + +Command examples to deny unless explicitly requested: deleting data, force pushing, deploying, running migrations, changing permissions, reading secrets, exfiltrating tokens, or modifying files outside the user's workspace. +File examples to deny unless explicitly requested: deleting paths, writing outside the workspace, reading secrets or credentials, or broad access to private directories. + +Return one decision for every toolCallId. Use the exact toolCallId values provided.`; + +function compact(value: unknown, max = 8_000): string { + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + if (text.length <= max) return text; + return `${text.slice(0, max)}\n...<truncated>`; +} + +function recentContext(messages: ModelMessage[]): unknown[] { + return messages.slice(-8).map((message) => { + if (typeof message.content === "string") { + return { role: message.role, content: compact(message.content, 2_000) }; + } + return { role: message.role, content: compact(message.content, 3_000) }; + }); +} + +function buildPrompt(input: { + agentName: string | null; + messages: ModelMessage[]; + candidates: AutoPermissionCandidate[]; +}) { + return compact({ + agentName: input.agentName, + recentConversation: recentContext(input.messages), + toolCalls: input.candidates.map(({ toolCall, permission }) => ({ + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + arguments: toolCall.arguments, + permission, + })), + }, 24_000); +} + +export async function classifyToolPermissions(input: { + runId: string; + agentName: string | null; + messages: ModelMessage[]; + candidates: AutoPermissionCandidate[]; + useCase: UseCase; + subUseCase?: string | null; +}): Promise<AutoPermissionDecision[]> { + if (input.candidates.length === 0) return []; + + const modelId = await getAutoPermissionDecisionModel(); + const { provider: providerName } = await getDefaultModelAndProvider(); + const providerConfig = await resolveProviderConfig(providerName); + const model = createProvider(providerConfig).languageModel(modelId); + + const result = await withUseCase( + { + useCase: input.useCase, + subUseCase: "auto_permission_classifier", + ...(input.agentName ? { agentName: input.agentName } : {}), + }, + () => generateObject({ + model, + system: SYSTEM_PROMPT, + prompt: buildPrompt(input), + schema: DecisionSchema, + }), + ); + + captureLlmUsage({ + useCase: input.useCase, + subUseCase: "auto_permission_classifier", + model: modelId, + provider: providerName, + usage: result.usage, + }); + + const allowedIds = new Set(input.candidates.map((candidate) => candidate.toolCall.toolCallId)); + return result.object.decisions.filter((decision) => allowedIds.has(decision.toolCallId)); +} diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 3a24c217..4571de2c 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -17,10 +17,15 @@ export const LlmModelConfig = z.object({ headers: z.record(z.string(), z.string()).optional(), model: z.string().optional(), models: z.array(z.string()).optional(), + knowledgeGraphModel: z.string().optional(), + meetingNotesModel: z.string().optional(), + liveNoteAgentModel: z.string().optional(), + autoPermissionDecisionModel: z.string().optional(), })).optional(), // Per-category model overrides (BYOK only — signed-in users always get // the curated gateway defaults). Read by helpers in core/models/defaults.ts. knowledgeGraphModel: z.string().optional(), meetingNotesModel: z.string().optional(), liveNoteAgentModel: z.string().optional(), + autoPermissionDecisionModel: z.string().optional(), }); diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index a977db0b..a4deea9a 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -21,6 +21,7 @@ export const StartEvent = BaseRunEvent.extend({ agentName: z.string(), model: z.string(), provider: z.string(), + permissionMode: z.enum(["manual", "auto"]).optional(), // useCase/subUseCase tag the run for analytics. Optional on read so legacy // run files written before these fields existed still parse cleanly. useCase: z.enum([ @@ -110,6 +111,15 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({ scope: z.enum(["once", "session", "always"]).optional(), }); +export const ToolPermissionAutoDecisionEvent = BaseRunEvent.extend({ + type: z.literal("tool-permission-auto-decision"), + toolCallId: z.string(), + toolCall: ToolCallPart, + permission: ToolPermissionMetadata.optional(), + decision: z.enum(["allow", "deny"]), + reason: z.string(), +}); + export const RunErrorEvent = BaseRunEvent.extend({ type: z.literal("error"), error: z.string(), @@ -134,6 +144,7 @@ export const RunEvent = z.union([ AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, + ToolPermissionAutoDecisionEvent, RunErrorEvent, RunStoppedEvent, ]); @@ -166,6 +177,7 @@ export const Run = z.object({ agentId: z.string(), model: z.string(), provider: z.string(), + permissionMode: z.enum(["manual", "auto"]).optional(), useCase: UseCase.optional(), subUseCase: z.string().optional(), log: z.array(RunEvent), @@ -185,6 +197,7 @@ export const CreateRunOptions = z.object({ agentId: z.string(), model: z.string().optional(), provider: z.string().optional(), + permissionMode: z.enum(["manual", "auto"]).optional(), useCase: UseCase.optional(), subUseCase: z.string().optional(), }); From 547a22ae1a2da290d73ef1296bf74a618f27cbed Mon Sep 17 00:00:00 2001 From: hrsvrn <harshvardhanvatsa@gmail.com> Date: Wed, 3 Jun 2026 11:26:17 +0530 Subject: [PATCH 27/35] added icon and rowboat url scheme handler to linux packages --- apps/x/apps/main/forge.config.cjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index ad639a86..8cd34acd 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -66,7 +66,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,7 +79,9 @@ 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'], } } }, From 81cc4e10b727400af4dd67a45d9b7f66cafff955 Mon Sep 17 00:00:00 2001 From: gagan <gaganp000999@gmail.com> Date: Thu, 4 Jun 2026 14:01:10 +0530 Subject: [PATCH 28/35] fix: set rowboat icon for windows taskbar and installer (#595) Co-authored-by: arkml <6592213+arkml@users.noreply.github.com> --- apps/x/apps/main/forge.config.cjs | 1 + apps/x/apps/main/icons/icon.ico | Bin 0 -> 4286 bytes apps/x/apps/main/src/main.ts | 1 + 3 files changed, 2 insertions(+) create mode 100644 apps/x/apps/main/icons/icon.ico diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 8cd34acd..7806f6cd 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'), }) }, { diff --git a/apps/x/apps/main/icons/icon.ico b/apps/x/apps/main/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0e5ac8701ad6d4d73a4578cf7066b20668d6f327 GIT binary patch literal 4286 zcmc(iJ&!3-7{|x^OF}LY6eJ{sAav@j6+)?&=zIj*C?u;v;w#8))Hc~dmO_C}f|`U5 z#f`$tbLT&sbK_<0wXB(ubI!~eGr#|N&htEH4#&y(pUcIe=6?U;I4j3-e!lQs+!J5W zFC6^6KTjX!qeG|-eErOqIbTSm0v4Ezy%vdkI6g3;VI~2a=>U5#A^$G6LZKk7R?EgB zdaF{YD3wZ)*XtFX&E_l~k5i-3Aiv))i3X$5NX=%G+U+*E-EK)VWV2bqQLop@Y&MIo zAruPHY&H`#6bc1WDwU#Y(CKva`uZyHSF05zlSxtea=DypwVJ?3)$8@h;c$qi!Rd6; zcs%~c$4Ne)Cz(tpiiUJLP227Eh(DQ3C=!W0oNu$)sMF~jvDrnrTqev7%N5tO_`h1M zzUmo`Mnf+zFSOt98S<!w4T8boRqtZjz(!6*UNaaB7oW9StyC-)X}w+_F;6dYx!lEf zFy?9N@py!s`u_e-$SoKH)*09aevSEt&3C)qDemkVv0$-SzO4cIywz$6@0-u(f*!;e z{Cu<7oN+F7z&^29?Av_U#N~1cIrW-xUgD#_R4U!XhX&+x<W%IfTaRwHd)K;vbv}_u zP`}^5=<8o;TPzk7jYe;_0rOAz9`f3BI{gaw>Ag%Qb2lG45Ch?Gm@1WupyN{PpNgnf zt3SeLw!pVvAP^wLH)8C6dEo8s?P1@Fh;PIg{2W@A%jKD$42MIq+wDJU17-`>V!bn& zOoFdrzF~8Wdo&u+U@#z`&-YM1({b1$Z(`hF>-GB2_;>9A4p}#xgDv&`>zUUe<^GfH Nfu-7i^TU_!_zUc72ZsOv literal 0 HcmV?d00001 diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 81d43553..780d78cd 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -220,6 +220,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, From 05a93c98ae7c3352ccf3222a38eb2cc3bb5d1cc2 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:04:21 +0530 Subject: [PATCH 29/35] show last working directories --- .../components/chat-input-with-mentions.tsx | 236 +++++++++++++++++- 1 file changed, 224 insertions(+), 12 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 8c62054c..624b0e7c 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 @@ -10,7 +10,10 @@ import { FileSpreadsheet, FileText, FileVideo, + FolderCheck, + FolderClock, FolderCog, + FolderOpen, Globe, Headphones, ImagePlus, @@ -30,6 +33,9 @@ import { DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { @@ -61,6 +67,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<string, string> = { @@ -81,6 +93,11 @@ interface ConfiguredModel { model: string } +type RecentWorkDir = { + path: string + lastUsedAt: number +} + export interface SelectedModel { provider: string model: string @@ -111,6 +128,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<string, unknown> + 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<RecentWorkDir[]> { + 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<string>() + 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 +281,7 @@ function ChatInputInner({ const [codeModeEnabled, setCodeModeEnabled] = useState(false) const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto') + const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([]) // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { @@ -205,6 +301,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) => { @@ -311,6 +416,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 +443,7 @@ function ChatInputInner({ }, []) const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => { - let existing: Record<string, 'claude' | 'codex'> = {} + const existing: Record<string, 'claude' | 'codex'> = {} try { const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' }) const parsed = JSON.parse(result.data) as Record<string, unknown> @@ -353,6 +469,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 +493,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 +661,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 ( <div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none"> {attachments.length > 0 && ( @@ -651,17 +785,95 @@ function ChatInputInner({ </button> </DropdownMenuTrigger> </TooltipTrigger> - <TooltipContent side="top">Add files or set work directory</TooltipContent> + <TooltipContent side="top"> + {workDir ? 'Add files or change work directory' : 'Add files or set work directory'} + </TooltipContent> </Tooltip> - <DropdownMenuContent align="start" className="min-w-56"> - <DropdownMenuItem onSelect={() => fileInputRef.current?.click()}> - <ImagePlus className="size-4" /> - <span>Add files or photos</span> - </DropdownMenuItem> - <DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}> - <FolderCog className="size-4" /> - <span>{workDir ? 'Change work directory' : 'Set work directory'}</span> - </DropdownMenuItem> + <DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-2"> + <div className="rounded-[14px] border border-border/80 bg-background p-1"> + <DropdownMenuItem onSelect={() => fileInputRef.current?.click()} className="h-9 rounded-[9px] px-2.5"> + <ImagePlus className="size-4" /> + <span>Add files or photos</span> + </DropdownMenuItem> + + {/* 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. */} + <DropdownMenuSub> + <DropdownMenuSubTrigger className="h-9 rounded-[9px] px-2.5"> + <FolderCog className="size-4" /> + <span className="flex min-w-0 flex-1 items-center justify-between gap-3"> + <span>Set working directory</span> + <span className="min-w-0 max-w-[110px] truncate text-xs text-muted-foreground"> + {currentWorkDirLabel} + </span> + </span> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent className="w-72 max-w-[calc(100vw-2rem)] p-1"> + {/* Current selection — shown for context only when one is set. */} + {workDir && ( + <div + title={workDir} + className="mb-1 flex items-center gap-2 rounded-[9px] bg-blue-50/80 px-2.5 py-2 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300" + > + <FolderCheck className="size-4 shrink-0 text-blue-600 dark:text-blue-300" /> + <span className="flex min-w-0 flex-1 flex-col gap-0.5"> + <span className="truncate text-sm font-medium">{currentWorkDirLabel}</span> + <span className="truncate text-xs text-blue-700/70 dark:text-blue-300/70"> + {currentWorkDirPath} + </span> + </span> + </div> + )} + + {/* Primary action: choose when unset, change when set. Always on top. */} + <DropdownMenuItem + onSelect={() => { void handleSetWorkDir() }} + className="h-9 rounded-[9px] px-2.5" + > + <FolderOpen className="size-4" /> + <span>{workDir ? 'Change folder…' : 'Choose a folder…'}</span> + </DropdownMenuItem> + + {visibleRecentWorkDirs.length > 0 && ( + <> + <div className="px-2.5 pb-1 pt-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground"> + Recent + </div> + {visibleRecentWorkDirs.map((entry) => { + const name = basename(entry.path) || entry.path + const when = formatRecentWorkDirTime(entry.lastUsedAt) + return ( + <DropdownMenuItem + key={entry.path} + title={entry.path} + onSelect={() => { void handleSelectRecentWorkDir(entry.path) }} + className="h-8 rounded-[9px] px-2.5" + > + <FolderClock className="size-4" /> + <span className="min-w-0 flex-1 truncate">{name}</span> + {when && <span className="shrink-0 text-xs text-muted-foreground">{when}</span>} + </DropdownMenuItem> + ) + })} + </> + )} + + {/* Clear — only meaningful once a directory is set. Kept at the bottom. */} + {workDir && ( + <> + <div className="my-1 h-px bg-border/60" /> + <DropdownMenuItem + onSelect={handleClearWorkDir} + className="h-8 rounded-[9px] px-2.5 text-red-600 focus:bg-red-50 focus:text-red-600 dark:text-red-400 dark:focus:bg-red-950/30" + > + <X className="size-4" /> + <span>Clear folder</span> + </DropdownMenuItem> + </> + )} + </DropdownMenuSubContent> + </DropdownMenuSub> + </div> </DropdownMenuContent> </DropdownMenu> {workDir && ( From 97c8f9d7876bbe2a86527fd94a582acf414c9896 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:43:43 +0530 Subject: [PATCH 30/35] hide background task details if there is output already --- .../renderer/src/components/bg-tasks-view.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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..4ba7479f 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -1237,6 +1237,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 +1254,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) From 7f3c16cc332569f0e7fbbd94c5701bc4072e88c2 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:49:49 +0530 Subject: [PATCH 31/35] Pane placement (#598) * allow user to change pane placement * allow user to change starting pane size --- apps/x/apps/renderer/src/App.tsx | 36 +++++++++++--- .../renderer/src/components/chat-sidebar.tsx | 35 ++++++++++---- .../src/components/settings-dialog.tsx | 48 ++++++++++++++++++- .../renderer/src/contexts/theme-context.tsx | 42 +++++++++++++++- 4 files changed, 144 insertions(+), 17 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 56445821..df85e06b 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'; @@ -117,6 +117,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<typeof workspace.DirEntry> type RunEventType = z.infer<typeof RunEvent> @@ -165,6 +166,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 +738,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 +770,7 @@ function App() { // Lives in ViewState so folder drill-down participates in back/forward history. const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState<string | null>(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<string | null>(null) const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) @@ -5246,6 +5251,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<React.CSSProperties>(() => { + 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 +5339,11 @@ function App() { <SidebarInset className={cn( "overflow-hidden! min-h-0 min-w-0", + isRightPaneContext && isChatPaneInMiddle && "order-3", insetAnimateMaxWidth && "transition-[max-width] duration-200 ease-linear", shouldCollapseLeftPane && "pointer-events-none select-none" )} - style={{ maxWidth: insetMaxWidth }} + style={nonChatPaneStyle} aria-hidden={shouldCollapseLeftPane} onMouseDownCapture={() => setActiveShortcutPane('left')} onFocusCapture={() => setActiveShortcutPane('left')} @@ -5438,7 +5455,11 @@ function App() { : (viewOpen && !isChatSidebarOpen) ? { onClick: openChatSidePane, icon: <MessageSquare className="size-5" />, label: 'Open chat' } : (viewOpen && isChatSidebarOpen && !isRightPaneMaximized) - ? { onClick: () => setIsChatSidebarOpen(false), icon: <ArrowRight className="size-5" />, label: 'Expand pane' } + ? { + onClick: () => setIsChatSidebarOpen(false), + icon: isChatPaneInMiddle ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />, + label: 'Expand pane' + } : null return ( <Tooltip> @@ -5989,10 +6010,13 @@ function App() { )} </SidebarInset> - {/* Chat sidebar - shown when viewing files/graph */} + {/* Chat pane - shown when viewing files/graph */} {isRightPaneContext && ( <ChatSidebar - defaultWidth={460} + placement={chatPanePlacement} + paneSize={chatPaneSize} + className={isChatPaneInMiddle ? "order-2" : undefined} + defaultWidth={DEFAULT_CHAT_PANE_WIDTH} isOpen={isChatSidebarOpen} isMaximized={isRightPaneMaximized} chatTabs={chatTabs} 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<ChatTabViewState>(() => ({ 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 ( <div @@ -511,16 +525,19 @@ export function ChatSidebar({ onMouseDownCapture={onActivate} onFocusCapture={onActivate} className={cn( - 'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background', - !isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear' + 'relative flex min-w-0 flex-col overflow-hidden bg-background', + isMiddlePlacement ? 'border-r border-border' : 'border-l border-border', + !isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear', + className )} style={paneStyle} > - {!isMaximized && ( + {!isMaximized && isResizable && ( <div onMouseDown={handleMouseDown} className={cn( - 'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize', + 'absolute inset-y-0 z-20 w-4 cursor-col-resize', + isMiddlePlacement ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2', 'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors', 'hover:after:bg-sidebar-border', isResizing && 'after:bg-primary' @@ -587,7 +604,9 @@ export function ChatSidebar({ className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground" aria-label={isMaximized ? 'Dock chat to side pane' : 'Expand chat'} > - {isMaximized ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />} + {isMaximized + ? (isMiddlePlacement ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />) + : (isMiddlePlacement ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />)} </Button> </TooltipTrigger> <TooltipContent side="bottom">{isMaximized ? 'Dock to side pane' : 'Expand chat'}</TooltipContent> diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 74082664..bf85d99b 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react" import { Dialog, @@ -210,7 +210,7 @@ function ThemeOption({ } function AppearanceSettings() { - const { theme, setTheme } = useTheme() + const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme() return ( <div className="space-y-6"> @@ -240,6 +240,50 @@ function AppearanceSettings() { /> </div> </div> + <div> + <h4 className="text-sm font-medium mb-3">Chat</h4> + <p className="text-xs text-muted-foreground mb-4"> + Choose where chat sits when another pane is open + </p> + <div className="grid grid-cols-2 gap-3"> + <ThemeOption + label="Chat right" + icon={PanelRight} + isSelected={chatPanePlacement === "right"} + onClick={() => setChatPanePlacement("right")} + /> + <ThemeOption + label="Chat middle" + icon={MessageCircle} + isSelected={chatPanePlacement === "middle"} + onClick={() => setChatPanePlacement("middle")} + /> + </div> + <h4 className="mt-6 text-sm font-medium mb-3">Chat size</h4> + <p className="text-xs text-muted-foreground mb-4"> + Choose how much width chat gets when another pane is open + </p> + <div className="grid grid-cols-3 gap-3"> + <ThemeOption + label="Chat smaller" + icon={MessageCircle} + isSelected={chatPaneSize === "chat-smaller"} + onClick={() => setChatPaneSize("chat-smaller")} + /> + <ThemeOption + label="Chat equal" + icon={Monitor} + isSelected={chatPaneSize === "chat-equal"} + onClick={() => setChatPaneSize("chat-equal")} + /> + <ThemeOption + label="Chat bigger" + icon={PanelRight} + isSelected={chatPaneSize === "chat-bigger"} + onClick={() => setChatPaneSize("chat-bigger")} + /> + </div> + </div> </div> ) } diff --git a/apps/x/apps/renderer/src/contexts/theme-context.tsx b/apps/x/apps/renderer/src/contexts/theme-context.tsx index 1149cb42..04df59e7 100644 --- a/apps/x/apps/renderer/src/contexts/theme-context.tsx +++ b/apps/x/apps/renderer/src/contexts/theme-context.tsx @@ -3,16 +3,32 @@ import * as React from "react" export type Theme = "light" | "dark" | "system" +export type ChatPanePlacement = "right" | "middle" +export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger" type ThemeContextProps = { theme: Theme resolvedTheme: "light" | "dark" setTheme: (theme: Theme) => void + chatPanePlacement: ChatPanePlacement + setChatPanePlacement: (placement: ChatPanePlacement) => void + chatPaneSize: ChatPaneSize + setChatPaneSize: (size: ChatPaneSize) => void } const ThemeContext = React.createContext<ThemeContextProps | null>(null) const STORAGE_KEY = "rowboat-theme" +const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement" +const CHAT_PANE_SIZE_STORAGE_KEY = "rowboat-chat-pane-size" + +function isChatPanePlacement(value: string | null): value is ChatPanePlacement { + return value === "right" || value === "middle" +} + +function isChatPaneSize(value: string | null): value is ChatPaneSize { + return value === "chat-smaller" || value === "chat-equal" || value === "chat-bigger" +} function getSystemTheme(): "light" | "dark" { if (typeof window === "undefined") return "light" @@ -39,6 +55,16 @@ export function ThemeProvider({ const stored = localStorage.getItem(STORAGE_KEY) as Theme | null return stored || defaultTheme }) + const [chatPanePlacement, setChatPanePlacementState] = React.useState<ChatPanePlacement>(() => { + if (typeof window === "undefined") return "right" + const stored = localStorage.getItem(CHAT_PANE_PLACEMENT_STORAGE_KEY) + return isChatPanePlacement(stored) ? stored : "right" + }) + const [chatPaneSize, setChatPaneSizeState] = React.useState<ChatPaneSize>(() => { + if (typeof window === "undefined") return "chat-smaller" + const stored = localStorage.getItem(CHAT_PANE_SIZE_STORAGE_KEY) + return isChatPaneSize(stored) ? stored : "chat-smaller" + }) const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => { if (theme === "system") return getSystemTheme() @@ -76,13 +102,27 @@ export function ThemeProvider({ setThemeState(newTheme) }, []) + const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => { + localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement) + setChatPanePlacementState(placement) + }, []) + + const setChatPaneSize = React.useCallback((size: ChatPaneSize) => { + localStorage.setItem(CHAT_PANE_SIZE_STORAGE_KEY, size) + setChatPaneSizeState(size) + }, []) + const contextValue = React.useMemo<ThemeContextProps>( () => ({ theme, resolvedTheme, setTheme, + chatPanePlacement, + setChatPanePlacement, + chatPaneSize, + setChatPaneSize, }), - [theme, resolvedTheme, setTheme] + [theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize] ) return ( From 372309eb1898a6eb52a457ea0ee1b2e4a72aa495 Mon Sep 17 00:00:00 2001 From: gagan <gaganp000999@gmail.com> Date: Fri, 5 Jun 2026 14:45:08 +0530 Subject: [PATCH 32/35] feat: run code mode on an in-app ACP client with live approvals (#593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(code-mode): add ACP client engine (Layer 2 core) Own the Agent Client Protocol client instead of shelling out to `acpx`, so code mode can stream structured events (tool calls, diffs, plan) and surface live permission requests. Headless acpx can't do live approvals (it only supports --approve-all), which is why we drive the agent adapters ourselves. - code-mode/acp/{agents,client,permission-broker,session-store,manager,types}.ts: headless engine driving the Claude/Codex ACP adapters; one warm session per chat with create-or-resume via session/load; approval policy (ask | auto-approve-reads | yolo) in the broker. - claude-exec.ts: cross-platform claude resolver (Windows .cmd EINVAL fix + macOS/Linux GUI-PATH safety net) shared with the legacy acpx path in builtin-tools.ts. - add @agentclientprotocol/sdk + claude/codex adapters to core. * feat(code-mode): route code mode through code_agent_run tool + live approvals Replace the acpx shell-out with a structured code_agent_run tool that drives the ACP engine directly, streaming the agent's tool calls / diffs / plan into the chat and surfacing permission requests inline. - shared: code-mode.ts zod schemas; add code-run-event + code-run-permission-request RunEvent variants (stream to the renderer over the existing runs:events channel); codeRun:resolvePermission IPC channel. - core: CodePermissionRegistry (promise-based mid-run approvals — the LLM tool-loop's pre-call gate can't model a mid-execution wait); register codeModeManager + codePermissionRegistry in awilix. - core: code_agent_run builtin tool (streams via ctx.publish, asks via the registry, cancels on ctx.signal, returns the agent summary). CodeModeConfig.approvalPolicy (ask | auto-approve-reads | yolo; default ask). Exclude the tool from the headless background-task / live-note / inline-task agents so they can't block on an approval. - main: codeRun:resolvePermission handler -> registry.resolve. - rewrite the code-with-agents skill and the runtime "Code Mode (Active)" block to call code_agent_run instead of emitting npx acpx commands. * feat(code-mode): render coding runs inline (live timeline + permission card) Render a code_agent_run tool call as a live CodingRun block instead of generic tool output: the agent's text, tool-call rows (kind icon + status + changed-file names from diffs), a plan checklist, and resolved-permission lines — plus an inline Allow / Always-allow / Deny card wired to codeRun:resolvePermission. - chat-conversation.ts: ToolCall carries codeRunEvents + pendingCodePermission; code_agent_run is excluded from tool-grouping so it renders standalone. - App.tsx: handle code-run-event / code-run-permission-request, clear the pending card on tool-result, handleCodePermissionResponse, render via CodingRunBlock. * fix(code-mode): run the ACP adapter as Node under Electron + resolve it from main Two runtime failures that only surfaced inside the packaged/bundled Electron app (the headless harness used real node, so neither showed there): - "ACP connection closed": the main process spawns the adapter via process.execPath, which inside Electron is the Electron binary, not node — so the child never ran as Node and its ACP stdio stream closed immediately. Set ELECTRON_RUN_AS_NODE=1 on the adapter env (a no-op under real node). - "Cannot find module '@agentclientprotocol/claude-agent-acp'": the adapters were transitive (core) deps, unreachable from the esbuild-bundled main.cjs. Add them as direct deps of the main app so require.resolve finds them at runtime (and so they ship when packaged). Also capture the adapter's stderr + exit code and enrich connection errors, so a future failure reports the real cause instead of the opaque "ACP connection closed". * chore(code-mode): remove dead acpx code paths and stale copy Code mode now runs through the code_agent_run tool (owning the ACP client), so the legacy acpx shell-out paths are dead. Remove them: - core: envForCommand (acpx-only CLAUDE_CODE_EXECUTABLE injection) from executeCommand; getCodeModeCommandLabel (acpx run-status label). - renderer: the acpx-detection "switch agent / auto-flip the code-mode chip" flow — App.tsx executeCommand detection, the permission-request onSwitchAgent button + badge, and the composer's code-mode-detected listener. - copy: Settings -> Code Mode and the code-with-agents skill summary no longer mention acpx; tidy stale comments (claude-exec, command-executor). No behavior change for code mode; the general executeCommand tool is unaffected. * feat(code-mode): approval-policy selector in Settings Surface the approval policy (Ask every time / Auto-approve reads / YOLO) in Settings -> Code Mode, instead of being config-file only. The broker already reads CodeModeConfig.approvalPolicy; this plumbs it through the codeMode:getConfig / setConfig IPC + main handlers and adds the picker UI (with a one-line explanation of each level). Defaults to "ask". * fix(code-mode): harden ACP engine — turn-scoped connections, chip-authoritative agent, reliable stop Three robustness fixes that co-modify manager.runPrompt and the code_agent_run tool, so they land together: - Lifecycle: scope each ACP adapter connection to the agent turn. Dispose it a short grace (60s) after the turn ends instead of holding it for the app's life; the next turn resumes via session/load (both agents support it). Wire disposeAll() on app quit (was dead code). Fixes the unbounded per-chat leak of booted agent processes. - Agent selection: make the composer chip the source of truth. Thread codeMode into ToolContext; code_agent_run uses it instead of the model's guessed `agent` arg, which anchored on the thread's earlier agent and ignored a chip change. Prompts updated to match; the run is labelled by the agent that actually ran. - Stop/abort: guarantee a stopped turn unwinds. On abort the manager sends ACP session/cancel, then force-kills the adapter after a 2s grace and resolves the turn as cancelled — a wedged adapter can no longer hang the run and lock the chat. code_agent_run returns a clean cancelled result. * fix(code-mode): hide Codex's native console window on Windows Codex's engine ships as a native console-subsystem binary (codex.exe). Launched from our console-less Electron process tree, Windows allocated a fresh *visible* console window for it; closing that window wedged the run in a pending state. (Claude Code is a Node CLI, so it never triggers this.) The window is created by @openai/codex's launcher (bin/codex.js), which spawns codex.exe with no windowsHide. Patch it via pnpm to pass windowsHide: true (CREATE_NO_WINDOW) so the console stays hidden — no window, nothing to close. * refactor(code-mode): move ACP session files out of WorkDir/config Per-run ACP session state is runtime state that accumulates one file per chat run, not user/app config. Relocate it from WorkDir/config to a dedicated WorkDir/code-mode/sessions/ so it can be listed, cleaned up, and managed on its own without crowding config. Drop the now-redundant codesession- filename prefix (the directory conveys it). --- apps/x/apps/main/package.json | 2 + apps/x/apps/main/src/ipc.ts | 10 +- apps/x/apps/main/src/main.ts | 9 +- apps/x/apps/renderer/src/App.tsx | 96 +++-- .../ai-elements/permission-request.tsx | 37 +- .../components/chat-input-with-mentions.tsx | 14 - .../renderer/src/components/coding-run.tsx | 253 ++++++++++++ .../src/components/settings-dialog.tsx | 60 ++- .../renderer/src/lib/chat-conversation.ts | 37 +- apps/x/packages/core/package.json | 3 + apps/x/packages/core/src/agents/runtime.ts | 46 +-- .../skills/code-with-agents/skill.ts | 86 +--- .../src/application/assistant/skills/index.ts | 2 +- .../core/src/application/lib/builtin-tools.ts | 171 ++++---- .../src/application/lib/command-executor.ts | 2 +- .../core/src/application/lib/exec-tool.ts | 4 + .../core/src/background-tasks/agent.ts | 4 +- .../packages/core/src/code-mode/acp/agents.ts | 60 +++ .../core/src/code-mode/acp/claude-exec.ts | 91 +++++ .../packages/core/src/code-mode/acp/client.ts | 219 ++++++++++ .../core/src/code-mode/acp/manager.ts | 186 +++++++++ .../src/code-mode/acp/permission-broker.ts | 91 +++++ .../src/code-mode/acp/permission-registry.ts | 43 ++ .../core/src/code-mode/acp/session-store.ts | 48 +++ .../packages/core/src/code-mode/acp/types.ts | 11 + apps/x/packages/core/src/code-mode/status.ts | 2 +- apps/x/packages/core/src/code-mode/types.ts | 4 + apps/x/packages/core/src/di/container.ts | 8 + .../core/src/knowledge/inline_task_agent.ts | 3 + .../core/src/knowledge/live-note/agent.ts | 4 +- apps/x/packages/shared/src/code-mode.ts | 70 ++++ apps/x/packages/shared/src/ipc.ts | 13 + apps/x/packages/shared/src/runs.ts | 20 + apps/x/patches/@openai__codex@0.128.0.patch | 15 + apps/x/pnpm-lock.yaml | 377 ++++++++++++++++++ apps/x/pnpm-workspace.yaml | 2 + 36 files changed, 1809 insertions(+), 294 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/coding-run.tsx create mode 100644 apps/x/packages/core/src/code-mode/acp/agents.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/claude-exec.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/client.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/manager.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/permission-broker.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/permission-registry.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/session-store.ts create mode 100644 apps/x/packages/core/src/code-mode/acp/types.ts create mode 100644 apps/x/packages/shared/src/code-mode.ts create mode 100644 apps/x/patches/@openai__codex@0.128.0.patch diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 74cb1598..3330c3c0 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", diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ec2803aa..e5d407f8 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -32,6 +32,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'; @@ -536,6 +537,11 @@ export function setupIpcHandlers() { await runsCore.authorizePermission(args.runId, args.authorization); return { success: true }; }, + 'codeRun:resolvePermission': async (_event, args) => { + const registry = container.resolve<CodePermissionRegistry>('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 }; @@ -637,11 +643,11 @@ export function setupIpcHandlers() { 'codeMode:getConfig': async () => { const repo = container.resolve<ICodeModeConfigRepo>('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<ICodeModeConfigRepo>('codeModeConfigRepo'); - await repo.setConfig({ enabled: args.enabled }); + await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy }); invalidateCopilotInstructionsCache(); return { success: true }; }, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 780d78cd..f4415b5d 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"; @@ -417,6 +418,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>('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.tsx b/apps/x/apps/renderer/src/App.tsx index df85e06b..b850b57f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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'; @@ -2198,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) { @@ -2308,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 @@ -2388,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 => { @@ -2730,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 { @@ -5147,6 +5184,21 @@ function App() { } if (isToolCall(item)) { + if (item.name === 'code_agent_run') { + return ( + <CodingRunBlock + key={item.id} + item={item} + open={isToolOpenForTab(tabId, item.id)} + onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} + onPermissionDecision={(decision) => { + if (item.pendingCodePermission) { + handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision) + } + }} + /> + ) + } const appActionData = getAppActionCardData(item) if (appActionData) { return <AppActionCard key={item.id} data={appActionData} status={item.status} /> @@ -5886,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} /> diff --git a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx index d99d2e8b..cdcafc27 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx @@ -1,6 +1,5 @@ "use client"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -9,7 +8,7 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, RefreshCwIcon, Terminal, XIcon } from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"; import { useState, type ComponentProps } from "react"; import { ToolCallPart } from "@x/shared/dist/message.js"; import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; @@ -21,7 +20,6 @@ export type PermissionRequestProps = ComponentProps<"div"> & { onApproveSession?: () => void; onApproveAlways?: () => void; onDeny?: () => void; - onSwitchAgent?: (newAgent: 'claude' | 'codex') => void; isProcessing?: boolean; response?: 'approve' | 'deny' | null; permission?: z.infer<typeof ToolPermissionMetadata>; @@ -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 = ({ </h3> <p className="text-sm text-muted-foreground mt-1"> {isResponded ? "Requested:" : "The agent wants to execute:"} <span className="font-mono font-medium">{toolCall.toolName}</span> - {agentDisplay && ( - <Badge - variant="secondary" - className="ml-2 align-middle bg-secondary text-foreground" - > - <Terminal className="size-3 mr-1" /> - {agentDisplay} - </Badge> - )} </p> </div> {isResponded && ( @@ -220,18 +197,6 @@ export const PermissionRequest = ({ <XIcon className="size-4" /> Deny </Button> - {otherAgent && otherDisplay && onSwitchAgent && ( - <Button - variant="secondary" - size="sm" - onClick={() => onSwitchAgent(otherAgent)} - disabled={isProcessing} - className="flex-1" - > - <RefreshCwIcon className="size-4" /> - Use {otherDisplay} instead - </Button> - )} </div> )} </div> 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 624b0e7c..a7d548ea 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 @@ -394,20 +394,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 => { 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<string, number>() + 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 <Eye className="size-3.5 shrink-0 text-muted-foreground" /> + case 'edit': return <Pencil className="size-3.5 shrink-0 text-muted-foreground" /> + case 'delete': return <Trash2 className="size-3.5 shrink-0 text-muted-foreground" /> + case 'search': return <Search className="size-3.5 shrink-0 text-muted-foreground" /> + case 'execute': return <Terminal className="size-3.5 shrink-0 text-muted-foreground" /> + case 'fetch': return <FileText className="size-3.5 shrink-0 text-muted-foreground" /> + default: return <Wrench className="size-3.5 shrink-0 text-muted-foreground" /> + } +} + +function planMarker(status?: string) { + if (status === 'completed') return <CheckCircle2 className="size-3.5 shrink-0 text-green-600" /> + if (status === 'in_progress') return <CircleDot className="size-3.5 shrink-0 text-blue-500" /> + return <Circle className="size-3.5 shrink-0 text-muted-foreground" /> +} + +const basename = (p: string) => p.split(/[\\/]/).pop() || p + +function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) { + const rows = useMemo(() => reduceEvents(events), [events]) + if (rows.length === 0) { + return <div className="px-4 py-3 text-xs text-muted-foreground">Starting the agent…</div> + } + return ( + <div className="flex flex-col gap-2 px-4 py-3"> + {rows.map((row) => { + if (row.kind === 'text') { + return ( + <p key={row.id} className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90"> + {row.text} + </p> + ) + } + if (row.kind === 'tool') { + const running = row.status !== 'completed' && row.status !== 'failed' + return ( + <div key={row.id} className="flex flex-col gap-1"> + <div className="flex items-center gap-2 text-sm"> + {running + ? <Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" /> + : <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />} + {toolKindIcon(row.toolKind)} + <span className="truncate text-foreground/90">{row.title ?? row.toolKind ?? 'Tool call'}</span> + </div> + {row.diffs.length > 0 && ( + <div className="ml-7 flex flex-col gap-0.5"> + {row.diffs.map((d) => ( + <span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}> + {basename(d)} + </span> + ))} + </div> + )} + </div> + ) + } + if (row.kind === 'plan') { + return ( + <div key={row.id} className="flex flex-col gap-1 rounded-lg border bg-muted/30 p-2"> + {row.entries.map((entry, idx) => ( + <div key={idx} className="flex items-center gap-2 text-sm text-foreground/90"> + {planMarker(entry.status)} + <span className={cn('truncate', entry.status === 'completed' && 'text-muted-foreground line-through')}> + {entry.content} + </span> + </div> + ))} + </div> + ) + } + // resolved permission + const denied = row.decision === 'reject' || row.decision === 'cancelled' + return ( + <div key={row.id} className={cn('flex items-center gap-2 text-xs', denied ? 'text-red-600' : 'text-green-600')}> + {denied ? '✕' : '✓'} + <span className="truncate">{denied ? 'Denied' : 'Allowed'}: {row.title}</span> + </div> + ) + })} + </div> + ) +} + +// ── 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 ( + <div className="mb-4 rounded-[20px] border border-amber-500/40 bg-amber-500/5 p-4"> + <div className="flex items-center gap-2 text-sm font-medium text-foreground"> + <ShieldQuestion className="size-4 shrink-0 text-amber-600" /> + Permission needed + </div> + <p className="mt-1 text-sm text-muted-foreground"> + The agent wants to: <span className="font-medium text-foreground">{ask.title}</span> + </p> + <div className="mt-3 flex flex-wrap gap-2"> + <button type="button" disabled={busy} onClick={() => decide('allow_once')} + className={cn(btn, 'bg-foreground text-background hover:bg-foreground/90')}> + Allow + </button> + <button type="button" disabled={busy} onClick={() => decide('allow_always')} + className={cn(btn, 'border hover:bg-muted')}> + Always allow{ask.kind ? ` (${ask.kind})` : ''} + </button> + <button type="button" disabled={busy} onClick={() => decide('reject')} + className={cn(btn, 'border border-red-500/40 text-red-600 hover:bg-red-500/10')}> + Deny + </button> + </div> + </div> + ) +} + +// ── Block wrapper (rendered in the chat for a code_agent_run tool call) ── + +const AGENT_LABEL: Record<string, string> = { 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 ( + <> + <Tool open={open} onOpenChange={onOpenChange}> + <ToolHeader title={title} type="tool-code_agent_run" state={toToolState(item.status)} /> + <ToolContent> + <CodingRunTimeline events={item.codeRunEvents ?? []} /> + </ToolContent> + </Tool> + {item.pendingCodePermission && ( + <CodeRunPermissionRequest ask={item.pendingCodePermission.ask} onDecide={onPermissionDecision} /> + )} + </> + ) +} diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index bf85d99b..c45ed64e 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -25,6 +25,7 @@ import { useTheme } from "@/contexts/theme-context" 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" type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help" @@ -1805,6 +1806,7 @@ function AgentStatusRow({ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { const [enabled, setEnabled] = useState(false) + const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>('ask') const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [status, setStatus] = useState<CodeModeAgentStatus | null>(null) @@ -1829,7 +1831,10 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { setLoading(true) try { const result = await window.ipc.invoke("codeMode:getConfig", null) - if (!cancelled) setEnabled(result.enabled) + if (!cancelled) { + setEnabled(result.enabled) + setApprovalPolicy(result.approvalPolicy ?? 'ask') + } } catch { if (!cancelled) setEnabled(false) } finally { @@ -1845,7 +1850,7 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { setSaving(true) setEnabled(next) try { - await window.ipc.invoke("codeMode:setConfig", { enabled: next }) + await window.ipc.invoke("codeMode:setConfig", { enabled: next, approvalPolicy }) window.dispatchEvent(new Event("code-mode-config-changed")) toast.success(next ? "Code mode enabled" : "Code mode disabled") } catch { @@ -1854,7 +1859,22 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { } finally { setSaving(false) } - }, []) + }, [approvalPolicy]) + + const handlePolicyChange = useCallback(async (next: ApprovalPolicy) => { + const prev = approvalPolicy + setSaving(true) + setApprovalPolicy(next) + try { + await window.ipc.invoke("codeMode:setConfig", { enabled, approvalPolicy: next }) + window.dispatchEvent(new Event("code-mode-config-changed")) + } catch { + setApprovalPolicy(prev) + toast.error("Failed to update approval policy") + } finally { + setSaving(false) + } + }, [enabled, approvalPolicy]) const anyReady = status?.claude.installed && status?.claude.signedIn || status?.codex.installed && status?.codex.signedIn @@ -1874,9 +1894,8 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { <p> <strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running - on your machine. Pick the agent inline from the composer; the assistant calls it via - <code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">acpx</code> - and streams results back into chat. + on your machine. Pick the agent inline from the composer; the assistant runs it on-device + and streams its work — tool calls, file diffs, and approvals — back into chat. </p> <p> Requires an active <strong className="text-foreground">Claude Code</strong> subscription or @@ -1926,6 +1945,35 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { /> </div> + {enabled && ( + <div className="rounded-md border px-3 py-3 space-y-2"> + <div className="text-sm font-medium">Approvals</div> + <div className="text-xs text-muted-foreground"> + How the coding agent checks in before changing files or running commands. You always see + everything it does in the timeline — this only controls the prompts. + </div> + <Select + value={approvalPolicy} + onValueChange={(v) => handlePolicyChange(v as ApprovalPolicy)} + disabled={saving} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="ask">Ask every time</SelectItem> + <SelectItem value="auto-approve-reads">Auto-approve reads</SelectItem> + <SelectItem value="yolo">Auto-approve everything (YOLO)</SelectItem> + </SelectContent> + </Select> + <div className="text-xs text-muted-foreground"> + {approvalPolicy === 'ask' && 'You approve every file change and command the agent wants to run.'} + {approvalPolicy === 'auto-approve-reads' && 'Reading and searching run automatically; you still approve writes, edits, and commands.'} + {approvalPolicy === 'yolo' && 'The agent runs everything — writes, edits, and commands — without asking. Use only in folders you trust.'} + </div> + </div> + )} + {enabled && status && !anyReady && ( <div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs"> <AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" /> diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 7b0c15c6..5fb28574 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -2,6 +2,7 @@ import type { ToolUIPart } from 'ai' import z from 'zod' import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js' +import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js' export interface MessageAttachment { path: string @@ -27,6 +28,9 @@ export interface ToolCall { streamingOutput?: string status: 'pending' | 'running' | 'completed' | 'error' timestamp: number + // code_agent_run only: structured ACP stream items + the in-flight permission ask. + codeRunEvents?: CodeRunEvent[] + pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null } export interface ErrorMessage { @@ -519,41 +523,9 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = { * For builtin tools, returns a static friendly name (e.g., "Reading file"). * Falls back to the raw tool name if no mapping exists. */ -// Phrases shown while a code-mode task is running. They advance over time (5s -// each) to read as progress, then hold on the last one until the task finishes. -const CODE_MODE_RUNNING_LABELS = [ - 'Working on the task…', - 'Inspecting the project…', - 'Digging into the code…', - 'Figuring it out…', - 'Making the changes…', - 'Wiring things up…', - 'Putting it together…', -] -const CODE_MODE_LABEL_INTERVAL_MS = 5000 - -// Detect acpx coding-agent invocations (code mode) and produce a status-aware -// label, e.g. "Working on the task…" → "Completed the task". -export const getCodeModeCommandLabel = (tool: ToolCall): string | null => { - if (tool.name !== 'executeCommand') return null - const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined - const command = typeof input?.command === 'string' ? input.command : '' - const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/) - if (!match) return null - if (tool.status === 'error') return `Couldn't complete the task` - if (tool.status === 'completed') return `Completed the task` - // Advance through the phrases from the tool's start, holding on the last. - const elapsed = Math.max(0, Date.now() - tool.timestamp) - const step = Math.floor(elapsed / CODE_MODE_LABEL_INTERVAL_MS) - const idx = Math.min(step, CODE_MODE_RUNNING_LABELS.length - 1) - return CODE_MODE_RUNNING_LABELS[idx] -} - export const getToolDisplayName = (tool: ToolCall): string => { const browserLabel = getBrowserControlLabel(tool) if (browserLabel) return browserLabel - const codeModeLabel = getCodeModeCommandLabel(tool) - if (codeModeLabel) return codeModeLabel const composioData = getComposioActionCardData(tool) if (composioData) return composioData.label return TOOL_DISPLAY_NAMES[tool.name] || tool.name @@ -634,6 +606,7 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup => const isPlainToolCall = (item: ConversationItem): item is ToolCall => { if (!isToolCall(item)) return false + if (item.name === 'code_agent_run') return false // rich standalone block, never grouped if (getWebSearchCardData(item)) return false if (getComposioConnectCardData(item)) return false if (getAppActionCardData(item)) return false diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index b552eab7..08c2644d 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -11,6 +11,9 @@ "test:watch": "vitest" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.39.0", + "@agentclientprotocol/codex-acp": "^0.0.44", + "@agentclientprotocol/sdk": "^0.22.1", "@ai-sdk/anthropic": "^2.0.63", "@ai-sdk/google": "^2.0.53", "@ai-sdk/openai": "^2.0.91", diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index f42fad72..6f563a07 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -1279,6 +1279,7 @@ export async function* streamAgent({ signal, abortRegistry, publish: (event) => bus.publish(event), + codeMode, }); } } catch (error) { @@ -1426,44 +1427,19 @@ Do not announce the work directory unless it's relevant. Just use it.`; if (codeMode) { loopLogger.log('code mode enabled, injecting coding-agent context', codeMode); const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex'; - const otherAgent = codeMode === 'claude' ? 'codex' : 'claude'; - const otherDisplay = codeMode === 'claude' ? 'Codex' : 'Claude Code'; - // Deterministic, per-chat session name so the coding agent keeps - // context across the user's requests within this chat. Reusing the - // same -s <name> resumes the session; the first call creates it. - const sessionName = `rowboat-${runId}`; - instructionsWithDateTime += `\n\n# Code Mode (Active) — Default agent: ${agentDisplay} -The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). Use this as the **default** agent for coding tasks in this turn. + instructionsWithDateTime += `\n\n# Code Mode (Active) — Agent: ${agentDisplay} +The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). For EVERY coding task this turn, use **${agentDisplay}**, and narrate that agent ("Using ${agentDisplay} to …"). -**The user can override the agent at any time, two ways:** -1. By toggling the chip in the composer (preferred). -2. By asking you directly in chat ("use codex", "switch to claude", "do this with ${otherDisplay}", etc.). When the user explicitly asks to use a different agent in the current message, honor that — use \`${otherAgent}\` instead of \`${codeMode}\` for this turn, and briefly mention they can also toggle it via the chip for stickiness. +The chip is the single source of truth for which agent runs: +- Do NOT carry over a different agent from earlier in this thread — even if a previous run used the other agent, use **${agentDisplay}** now. +- Do NOT switch agents based on an in-chat text request ("use codex", "switch to claude"). The agent only changes when the user toggles the chip; if they ask in chat, tell them to toggle the chip. -**Persistent session for this chat — session name: \`${sessionName}\`.** This chat uses one named agent session so the agent keeps context across your requests. The session must exist before it can be prompted (\`-s\` only resumes; it does not create). +**How to run coding work — call the \`code_agent_run\` tool** with: +- \`agent\`: \`${codeMode}\` (always — match the chip). +- \`cwd\`: the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once). +- \`prompt\`: a clear, self-contained coding instruction. -**1. First coding action in this chat — ensure the session exists:** - -\`\`\` -npx acpx@latest --approve-all --cwd <workdir> <agent> sessions ensure --name ${sessionName} -\`\`\` - -(\`ensure\` creates the session if missing and reuses it if it already exists — safe to call when reopening this chat later.) - -**2. Then run the prompt:** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>" -\`\`\` - -**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>" -\`\`\` - -Run these as **separate, sequential** \`executeCommand\` calls — issue the \`sessions ensure\` call first and WAIT for it to finish, then issue the prompt call. Do NOT fire both in the same turn / batch. - -Where \`<agent>\` is either \`claude\` or \`codex\` — pick based on (in priority order): an explicit in-chat override → the chip setting (\`${codeMode}\`). Use \`${sessionName}\` exactly — do NOT invent a different name, and do NOT use \`exec\` (it is one-shot and forgets). +The tool runs the agent on-device and streams its tool calls, file diffs, and plan into the chat; any action needing approval surfaces as an inline permission card, so you do NOT pre-confirm with an in-chat "reply yes". This chat keeps ONE persistent agent session, so follow-up coding requests automatically resume with full context — just call \`code_agent_run\` again. Do NOT shell out to \`acpx\` or \`executeCommand\` for coding, and do NOT fall back to your own file tools. If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`; } diff --git a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts index d8e81a58..d9f15dd8 100644 --- a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts @@ -5,6 +5,8 @@ Use this skill whenever the user asks you to write code, build a project, create Coding agents operate on **arbitrary file paths** (including paths outside the Rowboat workspace root, like \`G:/4th sem/CN\` or \`~/projects/foo\`). Do NOT raise "outside workspace" concerns, and do NOT fall back to your own \`executeCommand\` (PowerShell / bash) or workspace file tools to do code work yourself. +All coding work runs through the **\`code_agent_run\`** tool. It launches the selected on-device coding agent (Claude Code / Codex), streams its tool calls, file diffs, and plan into the chat, and surfaces any action needing approval as an inline permission card. One persistent session is kept per chat, so follow-up requests resume with full context automatically. + --- ## STEP 1 — MANDATORY FIRST ACTION @@ -39,96 +41,52 @@ This is non-negotiable. The user gets clickable buttons. Free-text "which agent? --- -## STEP 2 — Resolve workdir, confirm, execute +## STEP 2 — Resolve workdir, then run **Resolve the workdir** (in this priority order): 1. A path the user named in their original message (e.g. \`G:/4th sem/CN\`). 2. The path from a "# User Work Directory" block in your context. 3. Ask once in plain text: "Which folder should I work in?" -**State your intent in one line, then execute immediately — do NOT wait for a "yes".** The \`executeCommand\` call surfaces a permission card that is itself the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like: +**Pick the agent** (\`claude\` or \`codex\`): use the agent from the "# Code Mode (Active)" block (the composer chip) / the Step 1 choice. The chip is authoritative — do NOT carry over a different agent from earlier in this thread, and do NOT switch on an in-chat text request ("use codex"); tell the user to toggle the chip instead. + +**State your intent in one line, then call the tool immediately — do NOT wait for a "yes".** The tool's own permission cards are the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like: > Using [Claude Code / Codex] to [task description] in \`[folder]\`. -…and then immediately make the \`executeCommand\` call in the same turn. - -**Execute** with the chosen agent using a **persistent named session** so follow-up coding requests in this chat resume the same agent and keep context. - -Pick \`<agent>\` (\`claude\` or \`codex\`) by, in priority order: -- An explicit in-chat override from the user this turn ("use codex", "switch to claude") — honor it. -- The agent chosen in Step 1 / the "# Code Mode (Active)" block. - -Pick \`<session-name>\` — **stable for this whole chat**: -- If the "# Code Mode (Active)" block gives a session name (e.g. \`rowboat-<runId>\`), use that exact name. -- Otherwise pick one short, kebab-case name and **reuse it for every coding call this turn and in follow-ups** — never a new name each time. - -**\`-s\` resumes an existing session; it does NOT create one.** So ensure the session exists once at the start, then prompt: - -**1. First coding action in this chat — ensure the session exists:** +…and then immediately call: \`\`\` -npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name> +code_agent_run({ + agent: "<claude|codex>", + cwd: "<resolved absolute folder>", + prompt: "<clear, self-contained coding instruction>" +}) \`\`\` -(\`ensure\` creates the session if missing and reuses it if it already exists — so reopening this chat later just resumes the same session instead of erroring.) - -**2. Then run the prompt:** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>" -\`\`\` - -**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):** - -\`\`\` -npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>" -\`\`\` - -**Run steps 1 and 2 as separate, sequential \`executeCommand\` calls.** Issue the \`sessions ensure\` call FIRST, wait for it to finish, and only THEN issue the prompt call. Do NOT fire both in the same turn / batch — each must surface and complete its own permission + command block before the next begins. - -Do NOT use \`exec\` — it is one-shot and forgets everything. - -Concrete example: - -\`\`\` -# First coding message in the chat — ensure the session, then prompt: -npx acpx@latest --approve-all --cwd "G:\\Blogging\\myblog" claude sessions ensure --name diskspace-check -npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Check the system disk space and report total, used, and free space." - -# Follow-up in the same chat — reuse the session, no create: -npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Summarize what we did and the final findings." -\`\`\` - -### Critical: flag order - -\`--approve-all\`, \`--timeout\`, and \`--cwd\` are GLOBAL flags and MUST appear BEFORE the agent name. \`sessions ensure --name <name>\` and \`-s <session-name>\` come AFTER the agent name: - -- ✓ Correct: \`npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"\` -- ✗ Wrong: \`npx acpx@latest <agent> --approve-all -s <name> "..."\` (will fail) - -### Writing good prompts for the agent - +**Writing good prompts for the agent:** - Be specific: file names, function signatures, expected behavior. - Mention constraints (language, framework, style). -- Expand short user requests into clear, actionable prompts. +- Expand short user requests into clear, actionable instructions. + +**Follow-ups:** for every later coding request in this chat, just call \`code_agent_run\` again with the same \`cwd\` and the chip's current agent. The session resumes automatically — do NOT start over or re-explain prior context. --- ## STEP 3 — Report results -After the command finishes: -- Pass through the coding agent's summary as-is. Do not rewrite. +After \`code_agent_run\` returns: +- Pass through the agent's \`summary\` as-is. Do not rewrite it. - Refer to file paths as plain text. Do NOT use \`\`\`file:path\`\`\` reference blocks. (This overrides the global "always wrap paths in filepath blocks" rule — for code-mode output, plain text.) -- Only add your own explanation if the command failed (non-zero exit): - - Exit code 5 — permissions were denied (shouldn't happen with \`--approve-all\`; flag it). - - Exit code 4 / "No acpx session found" — the \`-s <session-name>\` session doesn't exist yet. Create it once with \`npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name>\`, then retry the prompt. (\`-s\` only resumes; it never creates.) - - "command not found" / agent not installed, or an auth/sign-in error — the agent isn't set up. Tell the user to install or sign in to the agent via **Settings → Code Mode**, where Rowboat shows the install and sign-in status. +- Only add your own explanation if it failed: + - \`success: false\` with a message — surface the message. If it mentions the agent isn't installed or signed in, tell the user to install or sign in via **Settings → Code Mode**. + - \`stopReason: "cancelled"\` — the run was stopped; acknowledge briefly and ask if they want to continue. --- ## Once delegating: delegate fully -After Step 2 fires, delegate ALL related coding tasks for this turn to the coding agent — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work. +After Step 2 fires, delegate ALL related coding tasks for this turn to \`code_agent_run\` — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work. ## Prerequisites (informational) diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 30ceea95..a06c153b 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -99,7 +99,7 @@ const definitions: SkillDefinition[] = [ { id: "code-with-agents", title: "Code with Agents", - summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.", + summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex.", content: codeWithAgentsSkill, }, { 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 9bfb4250..08e8334f 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1,7 +1,6 @@ import { z, ZodType } from "zod"; import * as path from "path"; import * as fs from "fs/promises"; -import { existsSync, readFileSync } from "fs"; import { executeCommand, executeCommandAbortable } from "./command-executor.js"; import { resolveSkill, availableSkills } from "../assistant/skills/index.js"; import { executeTool, listServers, listTools } from "../../mcp/mcp.js"; @@ -16,6 +15,10 @@ import { executeAction as executeComposioAction, isConfigured as isComposioConfi import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js"; import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js"; import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js"; +import type { CodeModeManager } from "../../code-mode/acp/manager.js"; +import type { CodePermissionRegistry } from "../../code-mode/acp/permission-registry.js"; +import { ICodeModeConfigRepo } from "../../code-mode/repo.js"; +import type { ApprovalPolicy } from "@x/shared/dist/code-mode.js"; // Inputs for the bg-task builtin tools. Reuse the canonical schema field // descriptions; only `triggers` gets a tighter contextual override (the @@ -90,69 +93,6 @@ const LLMPARSE_MIME_TYPES: Record<string, string> = { '.tiff': 'image/tiff', }; -// Windows-only workaround: the Claude ACP bridge spawns CLAUDE_CODE_EXECUTABLE -// without `shell: true`, and Node refuses to spawn .cmd files that way (EINVAL). -// When the LLM invokes acpx via executeCommand, pre-resolve claude's real .exe -// from the npm-shim layout and inject it via env so the bridge can spawn it. -function resolveClaudeExeOnWindows(): string | undefined { - // Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global - // bin dirs. Electron's runtime PATH can omit these even when the user's shell - // includes them, which would otherwise leave us unable to find claude.exe and - // force a fallback to claude.cmd (which Node refuses to spawn — EINVAL). - const home = process.env.USERPROFILE ?? ''; - const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming')); - const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local')); - const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; - const knownDirs = [ - appData && path.join(appData, 'npm'), - localAppData && path.join(localAppData, 'npm'), - appData && path.join(appData, 'pnpm'), - localAppData && path.join(localAppData, 'pnpm'), - home && path.join(home, '.volta', 'bin'), - path.join(programFiles, 'nodejs'), - ].filter(Boolean) as string[]; - - const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean); - const seen = new Set<string>(); - const candidates = [...pathDirs, ...knownDirs].filter((d) => { - const key = d.toLowerCase(); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - - for (const dir of candidates) { - // Direct npm-shim layout: <dir>\node_modules\@anthropic-ai\claude-code\bin\claude.exe - const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'); - if (existsSync(exeFromLayout)) return exeFromLayout; - - // Otherwise parse the claude.cmd shim for the real exe path. - const cmdPath = path.join(dir, 'claude.cmd'); - if (!existsSync(cmdPath)) continue; - try { - const content = readFileSync(cmdPath, 'utf-8'); - const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i); - if (absMatch && existsSync(absMatch[0])) return absMatch[0]; - const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i); - if (relMatch) { - const resolved = path.join(dir, relMatch[1]); - if (existsSync(resolved)) return resolved; - } - } catch { - // ignore shim parse failures - } - } - return undefined; -} - -function envForCommand(command: string): NodeJS.ProcessEnv | undefined { - if (process.platform !== 'win32') return undefined; - if (!/\bacpx\b/.test(command)) return undefined; - if (process.env.CLAUDE_CODE_EXECUTABLE) return undefined; - const exe = resolveClaudeExeOnWindows(); - if (!exe) return undefined; - return { ...process.env, CLAUDE_CODE_EXECUTABLE: exe }; -} export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = { loadSkill: { @@ -814,14 +754,11 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = { // }; // } - const envOverride = envForCommand(command); - // Use abortable version when we have a signal if (ctx?.signal) { const { promise, process: proc } = executeCommandAbortable(command, { cwd: workingDir, signal: ctx.signal, - env: envOverride, onData: (chunk: string) => { ctx.publish({ runId: ctx.runId, @@ -851,7 +788,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = { } // Fallback to original for backward compatibility - const result = await executeCommand(command, { cwd: workingDir, env: envOverride }); + const result = await executeCommand(command, { cwd: workingDir }); return { success: result.exitCode === 0, @@ -871,6 +808,104 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = { }, }, + code_agent_run: { + description: 'Run a coding/software task with the selected on-device coding agent (Claude Code or Codex) inside a project folder. Streams the agent\'s tool calls, file diffs, and plan into the chat and surfaces permission requests inline. Use this for ALL code-mode work (writing/editing/reading code, running tests, debugging, exploring a repo). Reuses one persistent session per chat, so follow-up requests keep context.', + inputSchema: z.object({ + agent: z.enum(['claude', 'codex']).describe('Which coding agent to use: "claude" (Claude Code) or "codex". Set this to the active code-mode chip agent. Note: when the chip is set, the backend uses the chip agent regardless of this value — this only takes effect in the ask-human flow where no chip is set.'), + cwd: z.string().describe('Absolute path to the working directory / project folder the agent should operate in.'), + prompt: z.string().describe('The full, self-contained coding instruction for the agent (file names, expected behavior, constraints).'), + }), + execute: async ({ agent, cwd, prompt }: { agent: 'claude' | 'codex', cwd: string, prompt: string }, ctx?: ToolContext) => { + if (!ctx) { + return { success: false, message: 'code_agent_run requires run context (runId / streaming).' }; + } + // The composer chip is the source of truth for the agent. The model's `agent` + // argument is only a fallback for the ask-human flow (code mode not active, no + // chip set) — otherwise it can anchor on the thread's earlier agent and ignore a + // chip change. Honor the chip so switching it deterministically switches agents. + const effectiveAgent = ctx.codeMode ?? agent; + const manager = container.resolve<CodeModeManager>('codeModeManager'); + const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry'); + + // Approval policy from settings; default to asking the user. + let policy: ApprovalPolicy = 'ask'; + try { + const cfg = await container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo').getConfig(); + if (cfg.approvalPolicy) policy = cfg.approvalPolicy; + } catch { + // fall back to 'ask' + } + + // On stop, unblock any pending approval card so the broker stops waiting for + // an answer that will never come. The ACP cancel + force-kill backstop that + // actually ends the turn is handled inside manager.runPrompt via the signal + // we pass below. + const onAbort = () => registry.cancelRun(ctx.runId); + if (ctx.signal.aborted) onAbort(); + else ctx.signal.addEventListener('abort', onAbort, { once: true }); + + let finalText = ''; + const changedFiles = new Set<string>(); + try { + const result = await manager.runPrompt({ + runId: ctx.runId, + agent: effectiveAgent, + cwd, + prompt, + policy, + signal: ctx.signal, + onEvent: (event) => { + if (event.type === 'message' && event.role === 'agent') finalText += event.text; + if (event.type === 'tool_call_update') for (const f of event.diffs) changedFiles.add(f); + void ctx.publish({ + runId: ctx.runId, + type: 'code-run-event', + toolCallId: ctx.toolCallId, + event, + subflow: [], + }); + }, + ask: (permAsk) => registry.request(ctx.runId, (requestId) => { + void ctx.publish({ + runId: ctx.runId, + type: 'code-run-permission-request', + toolCallId: ctx.toolCallId, + requestId, + ask: permAsk, + subflow: [], + }); + }), + }); + return { + success: result.stopReason === 'end_turn', + stopReason: result.stopReason, + // The agent that actually ran (the chip), so the UI can label the run + // authoritatively rather than trusting the model's `agent` argument. + agent: effectiveAgent, + summary: finalText.trim(), + changedFiles: [...changedFiles], + }; + } catch (error) { + // A stop mid-run isn't a failure — report it as a clean cancellation. + if (ctx.signal.aborted) { + return { + success: false, + stopReason: 'cancelled', + agent: effectiveAgent, + summary: finalText.trim(), + changedFiles: [...changedFiles], + }; + } + return { + success: false, + message: `Coding agent failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } finally { + ctx.signal.removeEventListener('abort', onAbort); + } + }, + }, + // ============================================================================ // Browser Skills (browser-use/browser-harness domain-skills cache) // ============================================================================ diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 6be3c0e6..357ce1a8 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -80,7 +80,7 @@ export async function executeCommand( cwd?: string; timeout?: number; // timeout in milliseconds maxBuffer?: number; // max buffer size in bytes - env?: NodeJS.ProcessEnv; // override environment (e.g. CLAUDE_CODE_EXECUTABLE for acpx) + env?: NodeJS.ProcessEnv; // override environment } ): Promise<CommandResult> { try { diff --git a/apps/x/packages/core/src/application/lib/exec-tool.ts b/apps/x/packages/core/src/application/lib/exec-tool.ts index 92e87fa6..b34f6ed4 100644 --- a/apps/x/packages/core/src/application/lib/exec-tool.ts +++ b/apps/x/packages/core/src/application/lib/exec-tool.ts @@ -14,6 +14,10 @@ export interface ToolContext { signal: AbortSignal; abortRegistry: IAbortRegistry; publish: (event: z.infer<typeof RunEvent>) => Promise<void>; + // The composer code-mode chip for the message that triggered this turn. When set, + // it is the authoritative coding agent — code_agent_run uses it rather than the + // agent the model guessed, so switching the chip deterministically switches agents. + codeMode?: 'claude' | 'codex' | null; } async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> { diff --git a/apps/x/packages/core/src/background-tasks/agent.ts b/apps/x/packages/core/src/background-tasks/agent.ts index 3f3a2d47..853c1ef0 100644 --- a/apps/x/packages/core/src/background-tasks/agent.ts +++ b/apps/x/packages/core/src/background-tasks/agent.ts @@ -71,7 +71,9 @@ The workspace lives at \`${WorkDir}\`. export function buildBackgroundTaskAgent(): z.infer<typeof Agent> { const tools: Record<string, z.infer<typeof ToolAttachment>> = {}; for (const name of Object.keys(BuiltinTools)) { - if (name === 'executeCommand') continue; + // code_agent_run requires an interactive UI for permission approvals — skip it + // here (headless) so it can't hang on an approval no one can answer. + if (name === 'executeCommand' || name === 'code_agent_run') continue; tools[name] = { type: 'builtin', name }; } diff --git a/apps/x/packages/core/src/code-mode/acp/agents.ts b/apps/x/packages/core/src/code-mode/acp/agents.ts new file mode 100644 index 00000000..da06d8ea --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/agents.ts @@ -0,0 +1,60 @@ +import { createRequire } from 'module'; +import * as path from 'path'; +import type { CodingAgent } from './types.js'; +import { resolveClaudeExecutable } from './claude-exec.js'; + +const require = createRequire(import.meta.url); + +// The ACP adapter npm package that exposes each coding agent as an ACP server. +const ADAPTER_PACKAGE: Record<CodingAgent, string> = { + claude: '@agentclientprotocol/claude-agent-acp', + codex: '@agentclientprotocol/codex-acp', +}; + +export interface AgentLaunchSpec { + /** Executable to spawn — always `node` so we never hit the Windows .cmd EINVAL. */ + command: string; + /** Args = [adapter entry script]. */ + args: string[]; + /** Extra env merged over process.env (e.g. CLAUDE_CODE_EXECUTABLE on Windows). */ + env: NodeJS.ProcessEnv; +} + +// Resolve the adapter's executable ENTRY (its `bin`, not its library `main`) to an +// absolute path so we can spawn it directly with `node <entry>`. createRequire lets +// us resolve workspace/pnpm-installed packages from this module's location. +function resolveAdapterEntry(pkg: string): string { + const pkgJsonPath = require.resolve(`${pkg}/package.json`); + const pkgDir = path.dirname(pkgJsonPath); + const pkgJson = require(`${pkg}/package.json`) as { bin?: string | Record<string, string> }; + const bin = pkgJson.bin; + const rel = typeof bin === 'string' ? bin : bin ? Object.values(bin)[0] : undefined; + if (!rel) { + throw new Error(`ACP adapter ${pkg} has no bin entry to spawn`); + } + return path.join(pkgDir, rel); +} + +export function getAgentLaunchSpec(agent: CodingAgent): AgentLaunchSpec { + const entry = resolveAdapterEntry(ADAPTER_PACKAGE[agent]); + const env: NodeJS.ProcessEnv = { ...process.env }; + + // Point the Claude adapter at the real claude executable. On Windows this is + // mandatory (Node can't spawn the .cmd shim — EINVAL); on macOS/Linux it's a + // PATH safety net for GUI launches. Resolver is a no-op when claude isn't found, + // leaving the adapter to do its own lookup. (Codex relies on PATH for now — wire + // an equivalent when we add Codex support.) + if (agent === 'claude' && !env.CLAUDE_CODE_EXECUTABLE) { + const exe = resolveClaudeExecutable(); + if (exe) env.CLAUDE_CODE_EXECUTABLE = exe; + } + + // We spawn the adapter with process.execPath. Inside Electron's main process + // that is the Electron binary, NOT node — so set ELECTRON_RUN_AS_NODE=1 to make + // it behave as a plain Node runtime. (Harmless under a real node process, which + // ignores the var.) Without this the child never runs as node and the ACP stdio + // stream closes immediately ("ACP connection closed"). + env.ELECTRON_RUN_AS_NODE = '1'; + + return { command: process.execPath, args: [entry], env }; +} diff --git a/apps/x/packages/core/src/code-mode/acp/claude-exec.ts b/apps/x/packages/core/src/code-mode/acp/claude-exec.ts new file mode 100644 index 00000000..ae6c9c51 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/claude-exec.ts @@ -0,0 +1,91 @@ +import { execSync } from 'child_process'; +import * as path from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { commonInstallPaths } from '../status.js'; + +// Windows-only: Node refuses to spawn `.cmd` files without `shell: true` (EINVAL), +// and the Claude ACP adapter spawns its executable directly. So we pre-resolve +// claude's real `.exe` from the npm-shim layout. Used by resolveClaudeExecutable below. +export function resolveClaudeExeOnWindows(): string | undefined { + // Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global + // bin dirs. Electron's runtime PATH can omit these even when the user's shell + // includes them, which would otherwise leave us unable to find claude.exe and + // force a fallback to claude.cmd (which Node refuses to spawn — EINVAL). + const home = process.env.USERPROFILE ?? ''; + const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming')); + const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local')); + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const knownDirs = [ + appData && path.join(appData, 'npm'), + localAppData && path.join(localAppData, 'npm'), + appData && path.join(appData, 'pnpm'), + localAppData && path.join(localAppData, 'pnpm'), + home && path.join(home, '.volta', 'bin'), + path.join(programFiles, 'nodejs'), + ].filter(Boolean) as string[]; + + const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean); + const seen = new Set<string>(); + const candidates = [...pathDirs, ...knownDirs].filter((d) => { + const key = d.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + for (const dir of candidates) { + // Direct npm-shim layout: <dir>\node_modules\@anthropic-ai\claude-code\bin\claude.exe + const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'); + if (existsSync(exeFromLayout)) return exeFromLayout; + + // Otherwise parse the claude.cmd shim for the real exe path. + const cmdPath = path.join(dir, 'claude.cmd'); + if (!existsSync(cmdPath)) continue; + try { + const content = readFileSync(cmdPath, 'utf-8'); + const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i); + if (absMatch && existsSync(absMatch[0])) return absMatch[0]; + const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i); + if (relMatch) { + const resolved = path.join(dir, relMatch[1]); + if (existsSync(resolved)) return resolved; + } + } catch { + // ignore shim parse failures + } + } + return undefined; +} + +// macOS/Linux: find the real `claude` binary. Unlike Windows this isn't a spawn +// requirement (no .cmd problem) — it's a PATH safety net. Electron apps launched +// from the GUI (Dock/Finder) often don't inherit the login shell's PATH, so the +// spawned adapter may fail to find `claude`. We resolve the path here so the adapter +// can be pointed straight at it. +function resolveClaudeBinaryUnix(): string | undefined { + // Primary: a login shell sees the user's full PATH (~/.zprofile, nvm, homebrew, …). + try { + const out = execSync("/bin/sh -lc 'command -v claude'", { timeout: 5000, encoding: 'utf-8' }).trim(); + if (out && existsSync(out)) return out; + } catch { + // not found on the login-shell PATH + } + // Fallback: scan well-known install locations directly. + for (const candidate of commonInstallPaths('claude')) { + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +let cached: string | undefined; + +// Cross-platform: the real `claude` executable to hand the ACP adapter via +// CLAUDE_CODE_EXECUTABLE (the adapter prefers this env var on every OS). Returns +// undefined if it can't be found — callers then fall back to the adapter's own lookup. +// Cached on first success so we don't re-probe the shell on every cold start. +export function resolveClaudeExecutable(): string | undefined { + if (cached) return cached; + const resolved = process.platform === 'win32' ? resolveClaudeExeOnWindows() : resolveClaudeBinaryUnix(); + if (resolved) cached = resolved; + return resolved; +} diff --git a/apps/x/packages/core/src/code-mode/acp/client.ts b/apps/x/packages/core/src/code-mode/acp/client.ts new file mode 100644 index 00000000..5c2bd1ba --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/client.ts @@ -0,0 +1,219 @@ +import { spawn, type ChildProcess } from 'child_process'; +import { Writable, Readable } from 'node:stream'; +import fs from 'fs/promises'; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type SessionUpdate, + type PromptResponse, + type ReadTextFileRequest, + type ReadTextFileResponse, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { CodingAgent, CodeRunEvent } from './types.js'; +import type { PermissionBroker } from './permission-broker.js'; +import { getAgentLaunchSpec } from './agents.js'; + +export interface AcpClientOptions { + agent: CodingAgent; + cwd: string; + broker: PermissionBroker; + onEvent: (event: CodeRunEvent) => void; +} + +// Map a raw ACP session/update notification onto our small CodeRunEvent union. +function toEvent(update: SessionUpdate): CodeRunEvent { + switch (update.sessionUpdate) { + case 'agent_message_chunk': + case 'user_message_chunk': { + const c = update.content; + const role = update.sessionUpdate === 'user_message_chunk' ? 'user' : 'agent'; + return { type: 'message', role, text: c.type === 'text' ? c.text : `[${c.type}]` }; + } + case 'agent_thought_chunk': + return { type: 'thought' }; + case 'tool_call': + return { + type: 'tool_call', + id: update.toolCallId, + title: update.title, + kind: update.kind ?? undefined, + status: update.status ?? undefined, + }; + case 'tool_call_update': { + const diffs = (update.content ?? []) + .filter((c): c is Extract<typeof c, { type: 'diff' }> => c.type === 'diff') + .map((c) => c.path); + return { type: 'tool_call_update', id: update.toolCallId, status: update.status ?? undefined, diffs }; + } + case 'plan': + return { + type: 'plan', + entries: (update.entries ?? []).map((e) => ({ + content: e.content, + status: e.status ?? undefined, + priority: e.priority ?? undefined, + })), + }; + default: + return { type: 'other', sessionUpdate: update.sessionUpdate }; + } +} + +// Owns one spawned adapter process + ACP connection. Stateless about sessions — +// the manager decides whether to newSession or loadSession. +// +// The connection is long-lived and reused across follow-up prompts, but each prompt +// may stream to a different message's UI, so broker + onEvent are swappable via +// setHandlers() rather than fixed at construction. +export class AcpClient { + readonly agent: CodingAgent; + readonly cwd: string; + private broker: PermissionBroker; + private onEvent: (event: CodeRunEvent) => void; + private child?: ChildProcess; + private connection?: ClientSideConnection; + private loadSession_ = false; + // Diagnostics: the adapter's stderr/exit are captured so a dropped connection + // reports WHY (e.g. a crash) instead of the SDK's bare "ACP connection closed". + private stderrTail = ''; + private exitInfo: string | null = null; + + constructor(opts: AcpClientOptions) { + this.agent = opts.agent; + this.cwd = opts.cwd; + this.broker = opts.broker; + this.onEvent = opts.onEvent; + } + + get loadSupported(): boolean { + return this.loadSession_; + } + + // Re-point the live connection at a new prompt's broker / event sink. + setHandlers(broker: PermissionBroker, onEvent: (event: CodeRunEvent) => void): void { + this.broker = broker; + this.onEvent = onEvent; + } + + // Spawn the adapter and negotiate the protocol. Returns once initialized. + async start(): Promise<void> { + const spec = getAgentLaunchSpec(this.agent); + const child = spawn(spec.command, spec.args, { + cwd: this.cwd, + env: spec.env, + // Capture stderr (not inherit) so we can attribute a dropped connection. + stdio: ['pipe', 'pipe', 'pipe'], + }); + this.child = child; + child.stderr?.on('data', (d: Buffer) => { + this.stderrTail = (this.stderrTail + d.toString()).slice(-4000); + }); + child.on('exit', (code, signal) => { + this.exitInfo = `adapter exited (code ${code}${signal ? `, signal ${signal}` : ''})`; + }); + child.on('error', (err) => { + this.stderrTail = (this.stderrTail + `\nspawn error: ${err.message}`).slice(-4000); + }); + + const stream = ndJsonStream( + Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>, + Readable.toWeb(child.stdout!) as ReadableStream<Uint8Array>, + ); + const client = this.buildClient(); + this.connection = new ClientSideConnection(() => client, stream); + + try { + const init = await this.connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + }); + this.loadSession_ = init.agentCapabilities?.loadSession === true; + } catch (e) { + throw this.enrich(e, 'initialize'); + } + } + + async newSession(): Promise<string> { + try { + const res = await this.conn().newSession({ cwd: this.cwd, mcpServers: [] }); + return res.sessionId; + } catch (e) { + throw this.enrich(e, 'newSession'); + } + } + + async loadSession(sessionId: string): Promise<void> { + try { + await this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }); + } catch (e) { + throw this.enrich(e, 'loadSession'); + } + } + + async prompt(sessionId: string, text: string): Promise<PromptResponse> { + try { + return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] }); + } catch (e) { + throw this.enrich(e, 'prompt'); + } + } + + // Wrap a connection error with the adapter's exit/stderr so failures are + // self-explanatory rather than the SDK's opaque "ACP connection closed". + private enrich(err: unknown, phase: string): Error { + const base = err instanceof Error ? err.message : String(err); + const parts = [ + this.exitInfo, + this.stderrTail.trim() ? `adapter output: ${this.stderrTail.trim().slice(-1200)}` : '', + ].filter(Boolean); + return new Error(parts.length ? `${base} — ${parts.join(' | ')} [during ${phase}]` : `${base} [during ${phase}]`); + } + + async cancel(sessionId: string): Promise<void> { + await this.conn().cancel({ sessionId }); + } + + dispose(): void { + try { + this.child?.kill(); + } catch { + // already gone + } + this.child = undefined; + this.connection = undefined; + } + + private conn(): ClientSideConnection { + if (!this.connection) throw new Error('AcpClient not started'); + return this.connection; + } + + // The client side of ACP: the agent calls these on us. These read the CURRENT + // handlers off `self` so follow-up prompts can swap them via setHandlers(). + private buildClient(): Client { + const self = this; + return { + async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> { + return self.broker.resolve(params); + }, + async sessionUpdate(params: SessionNotification): Promise<void> { + self.onEvent(toEvent(params.update)); + }, + async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> { + const content = await fs.readFile(params.path, 'utf8'); + return { content }; + }, + async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> { + await fs.writeFile(params.path, params.content); + return {}; + }, + }; + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/manager.ts b/apps/x/packages/core/src/code-mode/acp/manager.ts new file mode 100644 index 00000000..04ccdebd --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/manager.ts @@ -0,0 +1,186 @@ +import type { ApprovalPolicy, CodeRunEvent, CodingAgent, PermissionAsk, PermissionDecision, RunPromptResult } from './types.js'; +import { AcpClient } from './client.js'; +import { PermissionBroker } from './permission-broker.js'; +import { readStoredSession, writeStoredSession, clearStoredSession } from './session-store.js'; + +export interface RunPromptArgs { + runId: string; + agent: CodingAgent; + cwd: string; + prompt: string; + policy: ApprovalPolicy; + /** Called when the policy needs the user to decide (the "ask" path). */ + ask: (ask: PermissionAsk) => Promise<PermissionDecision>; + /** Stream sink for this prompt's run. */ + onEvent: (event: CodeRunEvent) => void; + /** Aborts the turn on stop; the manager cancels then force-kills the adapter. */ + signal?: AbortSignal; +} + +interface ActiveRun { + client: AcpClient; + sessionId: string; + agent: CodingAgent; + cwd: string; + // Prompts currently streaming on this connection. Disposal is deferred while + // this is > 0 so we never tear down a connection mid-turn. + inflight: number; + // Pending grace-window teardown, cleared if the run is reused before it fires. + disposeTimer?: ReturnType<typeof setTimeout>; +} + +// How long a connection stays warm after its last turn ends before we tear it down. +// A coding "turn" is one code_agent_run tool call; we keep the adapter briefly so +// back-to-back calls within one copilot turn (edit -> test -> fix) and quick user +// follow-ups reuse the warm connection instead of cold-starting. Set to 0 for strict +// per-turn teardown. Context is never lost either way: the next turn resumes the +// persisted session via session/load. +const DISPOSE_GRACE_MS = 60_000; + +// On stop, how long to let the adapter cancel gracefully (ACP session/cancel) before +// we force-kill it. The kill guarantees the turn unwinds even if the adapter ignores +// cancel or is blocked — otherwise a hung prompt would lock the chat indefinitely. +const CANCEL_GRACE_MS = 2_000; + +// Drives ACP coding sessions. A connection's lifetime is scoped to the agent turn +// (one code_agent_run): it is torn down a short grace window after the turn ends, so +// idle chats hold no adapter processes. Turns that land within the grace window reuse +// the warm connection; anything colder (grace elapsed, or after an app restart) +// resumes the persisted session via session/load. +export class CodeModeManager { + private readonly runs = new Map<string, ActiveRun>(); + + async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> { + const { runId, agent, cwd, prompt, policy, ask, onEvent, signal } = args; + + const broker = new PermissionBroker({ + policy, + ask, + onResolved: (a, decision, auto) => onEvent({ type: 'permission', ask: a, decision, auto }), + }); + + const run = await this.ensureRun(runId, agent, cwd, broker, onEvent); + run.inflight++; + + let graceTimer: ReturnType<typeof setTimeout> | undefined; + let onAbort: (() => void) | undefined; + try { + const promptP = run.client.prompt(run.sessionId, prompt); + // We may stop awaiting this prompt below (force-kill on stop rejects it); + // attach a no-op catch so the orphaned rejection isn't flagged. + promptP.catch(() => {}); + + // Stop handling: on abort, ask the adapter to cancel; if it hasn't unwound + // within the grace, force-kill it and resolve as cancelled. This guarantees + // the turn ends even if the adapter ignores cancel or is wedged — a hung + // prompt would otherwise lock the chat (no run-stopped, composer disabled). + const cancelledP = new Promise<{ stopReason: string }>((resolve) => { + if (!signal) return; + onAbort = () => { + run.client.cancel(run.sessionId).catch(() => {}); + graceTimer = setTimeout(() => { + this.dispose(runId); + resolve({ stopReason: 'cancelled' }); + }, CANCEL_GRACE_MS); + graceTimer.unref?.(); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }); + + const res = await Promise.race([promptP, cancelledP]); + return { stopReason: res.stopReason, sessionId: run.sessionId }; + } catch (e) { + // A kill-induced "connection closed" during a stop is an expected cancel. + if (signal?.aborted) return { stopReason: 'cancelled', sessionId: run.sessionId }; + throw e; + } finally { + if (signal && onAbort) signal.removeEventListener('abort', onAbort); + if (graceTimer) clearTimeout(graceTimer); + run.inflight--; + this.scheduleDispose(runId); + } + } + + dispose(runId: string): void { + const run = this.runs.get(runId); + if (!run) return; + this.cancelDispose(run); + run.client.dispose(); + this.runs.delete(runId); + } + + // Tear down the connection a grace window after its last turn ends. Skipped while a + // prompt is still streaming, and re-armed when each turn ends so the window measures + // idle-since-last-activity. With grace 0 we dispose immediately (strict per-turn). + private scheduleDispose(runId: string): void { + const run = this.runs.get(runId); + if (!run || run.inflight > 0) return; + this.cancelDispose(run); + if (DISPOSE_GRACE_MS <= 0) { + this.dispose(runId); + return; + } + run.disposeTimer = setTimeout(() => { + const r = this.runs.get(runId); + if (r && r.inflight === 0) this.dispose(runId); + }, DISPOSE_GRACE_MS); + // A pending teardown timer must not keep the process alive at quit. + run.disposeTimer.unref?.(); + } + + private cancelDispose(run: ActiveRun): void { + if (run.disposeTimer) { + clearTimeout(run.disposeTimer); + run.disposeTimer = undefined; + } + } + + disposeAll(): void { + for (const runId of [...this.runs.keys()]) this.dispose(runId); + } + + // Reuse the warm connection if it matches; otherwise (cold start, or the user + // switched agent/cwd for this chat) build a fresh one and create-or-resume its session. + private async ensureRun( + runId: string, + agent: CodingAgent, + cwd: string, + broker: PermissionBroker, + onEvent: (event: CodeRunEvent) => void, + ): Promise<ActiveRun> { + const existing = this.runs.get(runId); + if (existing && existing.agent === agent && existing.cwd === cwd) { + this.cancelDispose(existing); // reused before its grace window elapsed + existing.client.setHandlers(broker, onEvent); + return existing; + } + if (existing) this.dispose(runId); // agent/cwd changed — start over + + const client = new AcpClient({ agent, cwd, broker, onEvent }); + await client.start(); + + const sessionId = await this.openSession(runId, agent, cwd, client); + const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 }; + this.runs.set(runId, run); + return run; + } + + // Resume the persisted session for this chat when possible; else start a new one + // and persist its id so a later restart can resume it. + private async openSession(runId: string, agent: CodingAgent, cwd: string, client: AcpClient): Promise<string> { + const stored = await readStoredSession(runId); + if (stored && stored.agent === agent && stored.cwd === cwd && client.loadSupported) { + try { + await client.loadSession(stored.sessionId); + return stored.sessionId; + } catch { + // Stored session is stale/unloadable — fall through to a fresh one. + await clearStoredSession(runId); + } + } + const sessionId = await client.newSession(); + await writeStoredSession({ runId, agent, cwd, sessionId }); + return sessionId; + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/permission-broker.ts b/apps/x/packages/core/src/code-mode/acp/permission-broker.ts new file mode 100644 index 00000000..9699dec4 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/permission-broker.ts @@ -0,0 +1,91 @@ +import type { + RequestPermissionRequest, + RequestPermissionResponse, + PermissionOption, + PermissionOptionKind, +} from '@agentclientprotocol/sdk'; +import type { ApprovalPolicy, PermissionDecision, PermissionAsk } from './types.js'; + +// Tool kinds that don't mutate anything — eligible for `auto-approve-reads`. +const READ_KINDS = new Set(['read', 'search', 'fetch', 'think']); + +function toAsk(request: RequestPermissionRequest): PermissionAsk { + const tc = request.toolCall; + const kind = tc.kind ?? undefined; + const title = tc.title ?? kind ?? 'Tool call'; + return { + toolCallId: tc.toolCallId ?? undefined, + title, + kind, + isRead: kind ? READ_KINDS.has(kind) : false, + }; +} + +// Map a desired decision to one of the options the agent actually offered. +// Agents may offer only a subset (e.g. allow_once + reject_once, no allow_always), +// so we fall back within the same allow/reject family before giving up. +function pickOption(options: PermissionOption[], decision: PermissionDecision): PermissionOption | undefined { + const order: Record<PermissionDecision, PermissionOptionKind[]> = { + allow_always: ['allow_always', 'allow_once'], + allow_once: ['allow_once', 'allow_always'], + reject: ['reject_once', 'reject_always'], + }; + for (const kind of order[decision]) { + const found = options.find((o) => o.kind === kind); + if (found) return found; + } + return undefined; +} + +function selected(optionId: string): RequestPermissionResponse { + return { outcome: { outcome: 'selected', optionId } }; +} + +// A request's identity for "always allow" memory: prefer tool kind, else title. +function memoryKey(ask: PermissionAsk): string { + return ask.kind ? `kind:${ask.kind}` : `title:${ask.title}`; +} + +export interface PermissionBrokerOptions { + policy: ApprovalPolicy; + // Called only when the policy can't decide on its own (the "ask" path). + ask: (ask: PermissionAsk) => Promise<PermissionDecision>; + // Notified of every resolved request so the engine can emit a stream event. + onResolved?: (ask: PermissionAsk, decision: PermissionDecision, auto: boolean) => void; +} + +// Decides how to answer the agent's requestPermission calls. Holds per-session +// "always allow" memory so a one-time approval sticks for the rest of the run. +export class PermissionBroker { + private readonly opts: PermissionBrokerOptions; + private readonly alwaysAllow = new Set<string>(); + + constructor(opts: PermissionBrokerOptions) { + this.opts = opts; + } + + async resolve(request: RequestPermissionRequest): Promise<RequestPermissionResponse> { + const ask = toAsk(request); + const key = memoryKey(ask); + + const finish = (decision: PermissionDecision, auto: boolean): RequestPermissionResponse => { + if (decision === 'allow_always') this.alwaysAllow.add(key); + this.opts.onResolved?.(ask, decision, auto); + const opt = pickOption(request.options, decision); + // If the agent offered no matching option we fall back to its first one + // (don't deadlock the turn); decision precedence above keeps this rare. + return selected(opt?.optionId ?? request.options[0]?.optionId ?? ''); + }; + + // 1. Sticky "always allow" from earlier this session. + if (this.alwaysAllow.has(key)) return finish('allow_always', true); + + // 2. Policy-level auto decisions. + if (this.opts.policy === 'yolo') return finish('allow_always', true); + if (this.opts.policy === 'auto-approve-reads' && ask.isRead) return finish('allow_once', true); + + // 3. Ask the user. + const decision = await this.opts.ask(ask); + return finish(decision, false); + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/permission-registry.ts b/apps/x/packages/core/src/code-mode/acp/permission-registry.ts new file mode 100644 index 00000000..862f2de4 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/permission-registry.ts @@ -0,0 +1,43 @@ +import type { PermissionDecision } from './types.js'; + +interface Pending { + runId: string; + resolve: (decision: PermissionDecision) => void; +} + +// Holds in-flight mid-run permission asks. The agent (via the broker) calls +// request() which BLOCKS the coding turn until the user answers; the renderer's +// answer arrives over IPC and calls resolve(). This is separate from the LLM +// tool-loop's pre-call permission gate, which can't model a mid-execution wait. +export class CodePermissionRegistry { + private readonly pending = new Map<string, Pending>(); + private counter = 0; + + // Register a pending ask, hand the generated requestId to `emit` (so the caller + // can publish the UI event), and resolve once the user answers. + request(runId: string, emit: (requestId: string) => void): Promise<PermissionDecision> { + const requestId = `cpr-${runId}-${++this.counter}`; + return new Promise<PermissionDecision>((resolve) => { + this.pending.set(requestId, { runId, resolve }); + emit(requestId); + }); + } + + // Called from the IPC handler when the user answers a card. + resolve(requestId: string, decision: PermissionDecision): void { + const entry = this.pending.get(requestId); + if (!entry) return; + this.pending.delete(requestId); + entry.resolve(decision); + } + + // On run stop/cancel: reject anything still waiting so the turn can unwind. + cancelRun(runId: string): void { + for (const [id, entry] of [...this.pending]) { + if (entry.runId === runId) { + this.pending.delete(id); + entry.resolve('reject'); + } + } + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/session-store.ts b/apps/x/packages/core/src/code-mode/acp/session-store.ts new file mode 100644 index 00000000..e5e45666 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/session-store.ts @@ -0,0 +1,48 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { WorkDir } from '../../config/config.js'; +import type { CodingAgent } from './types.js'; + +// One ACP session is pinned per chat run. We persist its sessionId (plus the agent +// and cwd it belongs to) so reopening the chat after an app restart can resume the +// same agent context via session/load instead of starting over. +export interface StoredSession { + runId: string; + agent: CodingAgent; + cwd: string; + sessionId: string; +} + +// Per-run ACP session state lives in its own directory (not WorkDir/config): it's +// runtime state that accumulates one file per chat run, so it's kept separate from +// user/app config to be listed and cleaned up on its own. +const SESSIONS_DIR = path.join(WorkDir, 'code-mode', 'sessions'); + +function sessionFile(runId: string): string { + return path.join(SESSIONS_DIR, `${runId}.json`); +} + +export async function readStoredSession(runId: string): Promise<StoredSession | null> { + try { + const raw = await fs.readFile(sessionFile(runId), 'utf8'); + const parsed = JSON.parse(raw) as StoredSession; + if (parsed && parsed.sessionId && parsed.agent && parsed.cwd) return parsed; + return null; + } catch { + return null; + } +} + +export async function writeStoredSession(session: StoredSession): Promise<void> { + const file = sessionFile(session.runId); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(session, null, 2)); +} + +export async function clearStoredSession(runId: string): Promise<void> { + try { + await fs.rm(sessionFile(runId), { force: true }); + } catch { + // best effort + } +} diff --git a/apps/x/packages/core/src/code-mode/acp/types.ts b/apps/x/packages/core/src/code-mode/acp/types.ts new file mode 100644 index 00000000..6fafd438 --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/types.ts @@ -0,0 +1,11 @@ +// Rowboat-facing types for the ACP code-mode engine. The schemas live in +// @x/shared (so the IPC/renderer layers share them); we re-export the inferred +// types here so the engine modules import from one local barrel. +export type { + CodingAgent, + ApprovalPolicy, + PermissionDecision, + PermissionAsk, + CodeRunEvent, + RunPromptResult, +} from '@x/shared/dist/code-mode.js'; diff --git a/apps/x/packages/core/src/code-mode/status.ts b/apps/x/packages/core/src/code-mode/status.ts index 3858708b..a78b23f4 100644 --- a/apps/x/packages/core/src/code-mode/status.ts +++ b/apps/x/packages/core/src/code-mode/status.ts @@ -12,7 +12,7 @@ const execAsync = promisify(exec); // We scan these directly because Electron's spawned shell sometimes doesn't // inherit the user's full PATH (especially on macOS GUI launches, and even on // Windows when global npm prefix isn't propagated to system PATH). -function commonInstallPaths(binary: string): string[] { +export function commonInstallPaths(binary: string): string[] { const home = os.homedir(); if (process.platform === 'win32') { const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); diff --git a/apps/x/packages/core/src/code-mode/types.ts b/apps/x/packages/core/src/code-mode/types.ts index 57a3158f..f52ae813 100644 --- a/apps/x/packages/core/src/code-mode/types.ts +++ b/apps/x/packages/core/src/code-mode/types.ts @@ -1,7 +1,11 @@ import z from "zod"; +import { ApprovalPolicy } from "@x/shared/dist/code-mode.js"; export const CodeModeConfig = z.object({ enabled: z.boolean(), + // How the ACP engine answers the coding agent's permission requests. + // Optional for back-compat; the tool defaults to "ask" when unset. + approvalPolicy: ApprovalPolicy.optional(), }); export type CodeModeConfig = z.infer<typeof CodeModeConfig>; diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index f452105a..d7b17ce7 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -16,6 +16,8 @@ import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js"; import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js"; +import { CodeModeManager } from "../code-mode/acp/manager.js"; +import { CodePermissionRegistry } from "../code-mode/acp/permission-registry.js"; import type { IBrowserControlService } from "../application/browser-control/service.js"; import type { INotificationService } from "../application/notification/service.js"; @@ -43,6 +45,12 @@ container.register({ agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(), agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(), slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(), + + // ACP code-mode engine: the manager holds a live agent connection per chat only + // around an active turn (torn down after a short idle grace; resumed via + // session/load); the registry brokers mid-run approvals. + codeModeManager: asClass(CodeModeManager).singleton(), + codePermissionRegistry: asClass(CodePermissionRegistry).singleton(), }); export default container; diff --git a/apps/x/packages/core/src/knowledge/inline_task_agent.ts b/apps/x/packages/core/src/knowledge/inline_task_agent.ts index 1a5c2582..db81198d 100644 --- a/apps/x/packages/core/src/knowledge/inline_task_agent.ts +++ b/apps/x/packages/core/src/knowledge/inline_task_agent.ts @@ -1,7 +1,10 @@ import { BuiltinTools } from '../application/lib/builtin-tools.js'; export function getRaw(): string { + // code_agent_run needs an interactive UI to answer its permission asks; exclude it + // from this headless agent so it can't hang waiting on an approval no one can give. const toolEntries = Object.keys(BuiltinTools) + .filter(name => name !== 'code_agent_run') .map(name => ` ${name}:\n type: builtin\n name: ${name}`) .join('\n'); diff --git a/apps/x/packages/core/src/knowledge/live-note/agent.ts b/apps/x/packages/core/src/knowledge/live-note/agent.ts index 8bba90bc..7638384e 100644 --- a/apps/x/packages/core/src/knowledge/live-note/agent.ts +++ b/apps/x/packages/core/src/knowledge/live-note/agent.ts @@ -152,7 +152,9 @@ Avoid: "I updated the note.", "Done!", "Here is the update:". The summary is a d export function buildLiveNoteAgent(): z.infer<typeof Agent> { const tools: Record<string, z.infer<typeof ToolAttachment>> = {}; for (const name of Object.keys(BuiltinTools)) { - if (name === 'executeCommand') continue; + // code_agent_run requires an interactive UI for permission approvals — skip it + // here (headless) so it can't hang on an approval no one can answer. + if (name === 'executeCommand' || name === 'code_agent_run') continue; tools[name] = { type: 'builtin', name }; } diff --git a/apps/x/packages/shared/src/code-mode.ts b/apps/x/packages/shared/src/code-mode.ts new file mode 100644 index 00000000..a3bd46a7 --- /dev/null +++ b/apps/x/packages/shared/src/code-mode.ts @@ -0,0 +1,70 @@ +import z from "zod"; + +// Shared zod schemas for the ACP code-mode engine. Single source of truth: the +// core engine re-exports the inferred TS types, and runs.ts builds the RunEvent +// variants that carry these to the renderer. + +export const CodingAgent = z.enum(["claude", "codex"]); +export type CodingAgent = z.infer<typeof CodingAgent>; + +// How the permission broker answers the agent's requests before any per-tool +// "always allow" memory is applied. `yolo` is the safe, scoped equivalent of +// `claude --dangerously-skip-permissions` (our toggle, not a CLI flag). +export const ApprovalPolicy = z.enum(["ask", "auto-approve-reads", "yolo"]); +export type ApprovalPolicy = z.infer<typeof ApprovalPolicy>; + +export const PermissionDecision = z.enum(["allow_once", "allow_always", "reject"]); +export type PermissionDecision = z.infer<typeof PermissionDecision>; + +// What the UI needs to render a permission card. +export const PermissionAsk = z.object({ + toolCallId: z.string().optional(), + title: z.string(), + kind: z.string().optional(), // tool kind, e.g. "edit" | "execute" | "read" + isRead: z.boolean(), +}); +export type PermissionAsk = z.infer<typeof PermissionAsk>; + +// Normalized per-run stream items. The engine maps raw ACP session/update +// notifications onto this union; the renderer renders them. +export const CodeRunEvent = z.discriminatedUnion("type", [ + // role distinguishes the agent's own output from replayed user turns + // (loadSession streams the whole prior conversation back on resume). + z.object({ type: z.literal("message"), role: z.enum(["agent", "user"]), text: z.string() }), + z.object({ type: z.literal("thought") }), + z.object({ + type: z.literal("tool_call"), + id: z.string().optional(), + title: z.string().optional(), + kind: z.string().optional(), + status: z.string().optional(), + }), + z.object({ + type: z.literal("tool_call_update"), + id: z.string().optional(), + status: z.string().optional(), + diffs: z.array(z.string()), + }), + z.object({ + type: z.literal("plan"), + entries: z.array(z.object({ + content: z.string(), + status: z.string().optional(), + priority: z.string().optional(), + })), + }), + z.object({ + type: z.literal("permission"), + ask: PermissionAsk, + decision: z.union([PermissionDecision, z.literal("cancelled")]), + auto: z.boolean(), + }), + z.object({ type: z.literal("other"), sessionUpdate: z.string() }), +]); +export type CodeRunEvent = z.infer<typeof CodeRunEvent>; + +export const RunPromptResult = z.object({ + stopReason: z.string(), + sessionId: z.string(), +}); +export type RunPromptResult = z.infer<typeof RunPromptResult>; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 092a4b29..f3acffa1 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -19,6 +19,7 @@ import { ZListToolkitsResponse } from './composio.js'; import { BrowserStateSchema } from './browser-control.js'; import { BillingInfoSchema } from './billing.js'; import { EmailBlockSchema, GmailThreadSchema } from './blocks.js'; +import { PermissionDecision, ApprovalPolicy } from './code-mode.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -430,11 +431,23 @@ const ipcSchemas = { req: z.null(), res: z.object({ enabled: z.boolean(), + approvalPolicy: ApprovalPolicy.optional(), }), }, 'codeMode:setConfig': { req: z.object({ enabled: z.boolean(), + approvalPolicy: ApprovalPolicy.optional(), + }), + res: z.object({ + success: z.literal(true), + }), + }, + // Answer a mid-run permission request from a code_agent_run coding turn. + 'codeRun:resolvePermission': { + req: z.object({ + requestId: z.string(), + decision: PermissionDecision, }), res: z.object({ success: z.literal(true), diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index a4deea9a..a5043cde 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -1,5 +1,6 @@ import { LlmStepStreamEvent } from "./llm-step-events.js"; import { Message, ToolCallPart } from "./message.js"; +import { CodeRunEvent as CodeRunEventSchema, PermissionAsk } from "./code-mode.js"; import z from "zod"; const BaseRunEvent = z.object({ @@ -111,6 +112,23 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({ scope: z.enum(["once", "session", "always"]).optional(), }); +// A structured item from a code_agent_run coding turn (tool call, diff, plan, +// message chunk, resolved permission). Fire-and-forget — rendered live. +export const CodeRunStreamEvent = BaseRunEvent.extend({ + type: z.literal("code-run-event"), + toolCallId: z.string(), + event: CodeRunEventSchema, +}); + +// The coding agent is asking for permission mid-turn and the run is BLOCKED until +// the user answers via `codeRun:resolvePermission` (keyed by requestId). +export const CodeRunPermissionRequestEvent = BaseRunEvent.extend({ + type: z.literal("code-run-permission-request"), + toolCallId: z.string(), + requestId: z.string(), + ask: PermissionAsk, +}); + export const ToolPermissionAutoDecisionEvent = BaseRunEvent.extend({ type: z.literal("tool-permission-auto-decision"), toolCallId: z.string(), @@ -144,6 +162,8 @@ export const RunEvent = z.union([ AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, + CodeRunStreamEvent, + CodeRunPermissionRequestEvent, ToolPermissionAutoDecisionEvent, RunErrorEvent, RunStoppedEvent, diff --git a/apps/x/patches/@openai__codex@0.128.0.patch b/apps/x/patches/@openai__codex@0.128.0.patch new file mode 100644 index 00000000..73b2e0c3 --- /dev/null +++ b/apps/x/patches/@openai__codex@0.128.0.patch @@ -0,0 +1,15 @@ +diff --git a/bin/codex.js b/bin/codex.js +index 67ab3e2d95dfac1c91882578b5403916c3121484..f8030b6e1459e05161af99e152b2e7f65ea6c41d 100644 +--- a/bin/codex.js ++++ b/bin/codex.js +@@ -175,6 +175,10 @@ env[packageManagerEnvVar] = "1"; + const child = spawn(binaryPath, process.argv.slice(2), { + stdio: "inherit", + env, ++ // Native console-subsystem binary: without this Windows pops a visible console ++ // window when launched from a console-less (Electron GUI) parent. Closing that ++ // window wedges the agent. CREATE_NO_WINDOW keeps the console hidden. ++ windowsHide: true, + }); + + child.on("error", (err) => { diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 6c78cdce..c4e5a8d5 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -10,6 +10,11 @@ catalogs: specifier: 4.1.7 version: 4.1.7 +patchedDependencies: + '@openai/codex@0.128.0': + hash: 9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86 + path: patches/@openai__codex@0.128.0.patch + importers: .: @@ -47,6 +52,12 @@ importers: apps/main: dependencies: + '@agentclientprotocol/claude-agent-acp': + specifier: ^0.39.0 + 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) '@x/core': specifier: workspace:* version: link:../../packages/core @@ -362,6 +373,15 @@ importers: packages/core: dependencies: + '@agentclientprotocol/claude-agent-acp': + specifier: ^0.39.0 + 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) + '@agentclientprotocol/sdk': + specifier: ^0.22.1 + version: 0.22.1(zod@4.2.1) '@ai-sdk/anthropic': specifier: ^2.0.63 version: 2.0.70(zod@4.2.1) @@ -489,6 +509,24 @@ importers: packages: + '@agentclientprotocol/claude-agent-acp@0.39.0': + resolution: {integrity: sha512-+tCm5v32L0R3zE4qjZQowfO1L/zqvQ5FapmsMSIf4gawXfTf26CG5hgz99wARdo0zn20/1eP80gzx7PbZlSX9A==} + hasBin: true + + '@agentclientprotocol/codex-acp@0.0.44': + resolution: {integrity: sha512-iHzFWKzJ0Z8I6yJCkuLZ+nb9mF2WYmfTcHFFvc7sU/awBsQmVBmpSOXOpZ+IK2Dy9cR3iRoML/B2/Wq2/zKBCA==} + hasBin: true + + '@agentclientprotocol/sdk@0.21.1': + resolution: {integrity: sha512-ZTLH+o9QxcZDLX/9ww+W7C2iExnXFM+vD/uGFVSlR61Kzj9FaxUqBC6Rv/kwgA7qVWYUEI9c5ZNqCuO9PM4rKg==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + '@agentclientprotocol/sdk@0.22.1': + resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@ai-sdk/anthropic@2.0.70': resolution: {integrity: sha512-W3WjQlb0Ho+CVAQUvb8Rtk3hGS3Jlgy79ihY2H0yj2k4yU8XuxpQw0Oz+7JQsB47j+jlHhk7nUXtxhAeRg3S3Q==} engines: {node: '>=18'} @@ -544,6 +582,67 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156': + resolution: {integrity: sha512-IkjcS9dqAUlD4Nb62L9AZtmAXCa+FV4ul8lIlyXXUprh3nlecbKsWOXVd/GORrzAhMmynJaX4+iV1JiutFKXUA==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156': + resolution: {integrity: sha512-6PKi5fPmGRuzXu+Em/iwLmPG3mqg0hl92wcTU8fmChqyNtxhxsjCw7LTbdFqp/05o5NeZVVV4k3p7YUv5IFD6g==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156': + resolution: {integrity: sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156': + resolution: {integrity: sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156': + resolution: {integrity: sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156': + resolution: {integrity: sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156': + resolution: {integrity: sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156': + resolution: {integrity: sha512-/PofeTWoiKgnWNSNk0wG4SsRn22GGLmnLhg2R94WcNhCRFOyOTmiZcYH2DBlWZBIRVTZDsSfa/Pl1DyPvYCGKw==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.156': + resolution: {integrity: sha512-6nM/Dj+VMds52UXJ2YaV4IKhYamlUqN0HtdDrFzYz5lvPMpDS935qD8YZDAUpy+ltdoD6PJMd1V/CKFY3/oWCQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.100.1': + resolution: {integrity: sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -1743,6 +1842,47 @@ packages: resolution: {integrity: sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==} engines: {node: '>=8.0'} + '@openai/codex@0.128.0': + resolution: {integrity: sha512-+xp6ODmFfBNnexIWRHApEaPXot2j6gyM8A5we/5IS/uY4eYHj4arETct4hQ5M4eO+MK7JY3ZU4xhuobhlysr0A==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.128.0-darwin-arm64': + resolution: {integrity: sha512-w+6zohfHx/kHBdles/CyFKaY57u9I3nK8QI9+NrdwMliKA0b7xn13yblRNkMpe09j6vL1oAWoxYsMOQ/vjBGug==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.128.0-darwin-x64': + resolution: {integrity: sha512-SDbn6fO22Puy8xmMIbZi4f2znMrUEPwABApke4mo+4ihaauwuVjeqzXvW5SPJz5ty/bG11/mSupQgReT7T8BBw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.128.0-linux-arm64': + resolution: {integrity: sha512-+SvH73H60qvCXFuQGP/EsmR//s1hHMBR22PvJkXvM/hdnTIGucx+JqRUjAWdmmQ1IU6j3kgwVvdLW/6ICB+M6w==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.128.0-linux-x64': + resolution: {integrity: sha512-2lnSPA05CRRuKAzFW8BCmmNCSieDcToLwfC2ALLbBYilGLgzhRibjlDglK9F1BkEzfohSSWJu4PBbRu/aG60lQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.128.0-win32-arm64': + resolution: {integrity: sha512-ECJvsqmYFdA9pn42xxK3Odp/G16AjmBW0BglX8L0PwPjqbstbmlew9bfHf7xvL+SNfNl4NmyotW0+RNo1phgaA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.128.0-win32-x64': + resolution: {integrity: sha512-k3jmUAFrzkUtvjGTXvSKjQqJLLlzjxp/VoHJDYedgmXUn6j70HxK38IwapzmnYfiBiTuzETvGwjXHzZgzKjhoQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@openrouter/ai-sdk-provider@1.5.4': resolution: {integrity: sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw==} engines: {node: '>=18'} @@ -3057,6 +3197,9 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -4060,6 +4203,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -4540,6 +4687,14 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -4551,6 +4706,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -4592,6 +4751,10 @@ packages: diff3@0.0.3: resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} @@ -4942,6 +5105,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -5541,6 +5707,11 @@ packages: engines: {node: '>=8'} hasBin: true + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -5560,6 +5731,15 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -5617,6 +5797,10 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -5677,6 +5861,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -6431,6 +6619,10 @@ packages: oniguruma-to-es@4.3.4: resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + open@7.4.2: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} @@ -6679,6 +6871,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + preact@10.28.2: resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==} @@ -7097,6 +7293,10 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -7305,6 +7505,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -7523,6 +7726,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -7827,6 +8033,10 @@ packages: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} + vscode-jsonrpc@8.2.1: + resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==} + engines: {node: '>=14.0.0'} + vscode-languageserver-protocol@3.17.5: resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} @@ -7933,6 +8143,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + x-is-array@0.1.0: resolution: {integrity: sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA==} @@ -8028,6 +8242,33 @@ packages: snapshots: + '@agentclientprotocol/claude-agent-acp@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))': + dependencies: + '@agentclientprotocol/sdk': 0.22.1(zod@4.2.1) + '@anthropic-ai/claude-agent-sdk': 0.3.156(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))(zod@4.2.1) + zod: 4.2.1 + transitivePeerDependencies: + - '@anthropic-ai/sdk' + - '@modelcontextprotocol/sdk' + + '@agentclientprotocol/codex-acp@0.0.44(zod@4.2.1)': + dependencies: + '@agentclientprotocol/sdk': 0.21.1(zod@4.2.1) + '@openai/codex': 0.128.0(patch_hash=9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86) + diff: 8.0.4 + open: 11.0.0 + vscode-jsonrpc: 8.2.1 + transitivePeerDependencies: + - zod + + '@agentclientprotocol/sdk@0.21.1(zod@4.2.1)': + dependencies: + zod: 4.2.1 + + '@agentclientprotocol/sdk@0.22.1(zod@4.2.1)': + dependencies: + zod: 4.2.1 + '@ai-sdk/anthropic@2.0.70(zod@4.2.1)': dependencies: '@ai-sdk/provider': 2.0.1 @@ -8089,6 +8330,52 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.156(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))(zod@4.2.1)': + dependencies: + '@anthropic-ai/sdk': 0.100.1(zod@4.2.1) + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.3)(zod@4.2.1) + zod: 4.2.1 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.156 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.156 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.156 + + '@anthropic-ai/sdk@0.100.1(zod@4.2.1)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.2.1 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -9819,6 +10106,33 @@ snapshots: '@oozcitak/util@8.3.4': {} + '@openai/codex@0.128.0(patch_hash=9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86)': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.128.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.128.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.128.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.128.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.128.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.128.0-win32-x64' + + '@openai/codex@0.128.0-darwin-arm64': + optional: true + + '@openai/codex@0.128.0-darwin-x64': + optional: true + + '@openai/codex@0.128.0-linux-arm64': + optional: true + + '@openai/codex@0.128.0-linux-x64': + optional: true + + '@openai/codex@0.128.0-win32-arm64': + optional: true + + '@openai/codex@0.128.0-win32-x64': + optional: true + '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.151(zod@4.2.1))(zod@4.2.1)': dependencies: '@openrouter/sdk': 0.1.27 @@ -11301,6 +11615,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -12431,6 +12747,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bytes@3.1.2: {} cacache@16.1.3: @@ -12925,6 +13245,13 @@ snapshots: deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -12937,6 +13264,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -12971,6 +13300,8 @@ snapshots: diff3@0.0.3: {} + diff@8.0.4: {} + dingbat-to-unicode@1.0.1: {} dir-compare@4.2.0: @@ -13473,6 +13804,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fast-xml-parser@5.2.5: @@ -14248,6 +14581,8 @@ snapshots: is-docker@2.2.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -14260,6 +14595,12 @@ snapshots: is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-interactive@1.0.0: {} is-lambda@1.0.1: {} @@ -14310,6 +14651,10 @@ snapshots: dependencies: is-docker: 2.2.1 + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isarray@1.0.0: {} isarray@2.0.5: {} @@ -14378,6 +14723,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -15367,6 +15717,15 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + open@7.4.2: dependencies: is-docker: 2.2.1 @@ -15606,6 +15965,8 @@ snapshots: dependencies: commander: 9.5.0 + powershell-utils@0.1.0: {} + preact@10.28.2: {} prelude-ls@1.2.1: {} @@ -16189,6 +16550,8 @@ snapshots: transitivePeerDependencies: - supports-color + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -16424,6 +16787,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.1.0: {} @@ -16664,6 +17032,8 @@ snapshots: trough@2.2.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -16965,6 +17335,8 @@ snapshots: vscode-jsonrpc@8.2.0: {} + vscode-jsonrpc@8.2.1: {} + vscode-languageserver-protocol@3.17.5: dependencies: vscode-jsonrpc: 8.2.0 @@ -17097,6 +17469,11 @@ snapshots: wrappy@1.0.2: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + x-is-array@0.1.0: {} x-is-string@0.1.0: {} diff --git a/apps/x/pnpm-workspace.yaml b/apps/x/pnpm-workspace.yaml index 19bafad8..f5cdd141 100644 --- a/apps/x/pnpm-workspace.yaml +++ b/apps/x/pnpm-workspace.yaml @@ -13,3 +13,5 @@ onlyBuiltDependencies: - fs-xattr - macos-alias - protobufjs +patchedDependencies: + '@openai/codex@0.128.0': patches/@openai__codex@0.128.0.patch From 46042f94651ec8b259e164a53cf4bb48cb4c4802 Mon Sep 17 00:00:00 2001 From: gagan <gaganp000999@gmail.com> Date: Mon, 8 Jun 2026 02:10:23 +0530 Subject: [PATCH 33/35] fix: keep chat input toolbar usable when the panel is narrow (#606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent chat bar model selector from overflowing in narrow panel * fix: contain chat bar left items so code pill clips instead of overflowing * fix: compact icon-only mode for chat bar when panel is narrow * fix: dynamic compact threshold based on visible toolbar items * fix: use actual DOM overflow detection to eliminate toolbar overlap * fix: progressive right-to-left icon collapse for chat toolbar * fix: instant icon switch, remove search label transition * fix: correct right-to-left collapse order (code→perm→search→workDir) * fix: measure actual DOM overflow instead of estimating — eliminates half-text and disappearing icons * refactor: replace JS overflow logic with CSS container queries Drop the ResizeObserver/useLayoutEffect collapse machinery and the estimated pixel thresholds in favor of declarative @container variants. Each toolbar item swaps to icon-only at a fixed container-width breakpoint (code 560, perm 460, search 410, workDir 370px), collapsing right-to-left. Atomic swaps mean no half-clipped text and no disappearing buttons. * fix: move @container to card root so breakpoints track panel width Putting container-type on the toolbar's own flex row made it stop stretching to fill the card and hug its collapsed content instead, so the query read a permanently-narrow width that never grew on widen. The card root reliably spans the full panel width. * fix: collapse toolbar by measuring real overflow, not fixed breakpoints Fixed container-query breakpoints can't know the workdir name length or model name width, so labels stayed full and overflowed into the model selector. Replace with overflow measurement: a ResizeObserver resets to full on any width/content change, then a pre-paint layout effect collapses items right-to-left (code -> perm -> search -> workdir) until the row fits. overflow-hidden on the group is a hard guarantee against any overlap. * feat: overflow menu for toolbar items that don't fit even as icons When the bar is too narrow to show every control as an icon, the right-most items move into a '...' overflow dropdown (code -> perm -> search -> workdir) instead of being clipped, so no icon is ever hidden. Toggle items keep the menu open on click via onSelect preventDefault. * fix: keep overflow menu open when toggling items inside it Toggling an in-menu item (code mode, agent, search, perm) updated state that was in the collapse-reset deps, resetting collapseLevel to 0 and unmounting the '...' trigger mid-interaction. Drop the in-place toggles from the reset deps so the menu stays open on click. * fix: drop 'Options' label from toolbar overflow menu --------- Co-authored-by: arkml <6592213+arkml@users.noreply.github.com> --- .../components/chat-input-with-mentions.tsx | 236 ++++++++++++++---- 1 file changed, 185 insertions(+), 51 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 a7d548ea..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, @@ -19,6 +19,7 @@ import { ImagePlus, LoaderIcon, Mic, + MoreHorizontal, Plus, ShieldCheck, Square, @@ -29,6 +30,7 @@ import { import { Button } from '@/components/ui/button' import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, @@ -283,6 +285,51 @@ function ChatInputInner({ const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto') const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([]) + // 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<HTMLDivElement>(null) + const leftGroupRef = useRef<HTMLDivElement>(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(() => { if (!runId) { @@ -757,7 +804,8 @@ function ChatInputInner({ className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0" /> </div> - <div className="flex items-center gap-2 px-4 pb-3"> + <div ref={toolbarRef} className="flex items-center gap-2 px-4 pb-3"> + <div ref={leftGroupRef} className="flex min-w-0 items-center gap-2 overflow-hidden"> <DropdownMenu> <Tooltip> <TooltipTrigger asChild> @@ -862,26 +910,32 @@ function ChatInputInner({ </div> </DropdownMenuContent> </DropdownMenu> - {workDir && ( + {workDir && collapseLevel < 8 && ( <Tooltip> <TooltipTrigger asChild> - <div className="group flex h-7 max-w-[180px] shrink-0 items-center rounded-full border border-border bg-muted/40 pl-2.5 pr-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"> + {/* Level 4: collapse to a square icon */} + <div className={cn( + "group flex h-7 shrink-0 items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", + collapseLevel >= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2" + )}> <button type="button" onClick={handleSetWorkDir} className="flex min-w-0 items-center gap-1.5" > <FolderCog className="h-3.5 w-3.5 shrink-0" /> - <span className="truncate">{basename(workDir) || workDir}</span> - </button> - <button - type="button" - onClick={handleClearWorkDir} - aria-label="Remove work directory" - className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100" - > - <X className="h-3.5 w-3.5 shrink-0" /> + {collapseLevel < 4 && <span className="truncate">{basename(workDir) || workDir}</span>} </button> + {collapseLevel < 4 && ( + <button + type="button" + onClick={handleClearWorkDir} + aria-label="Remove work directory" + className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100" + > + <X className="h-3.5 w-3.5 shrink-0" /> + </button> + )} </div> </TooltipTrigger> <TooltipContent side="top"> @@ -889,7 +943,7 @@ function ChatInputInner({ </TooltipContent> </Tooltip> )} - {searchAvailable && ( + {searchAvailable && collapseLevel < 7 && ( <button type="button" onClick={() => setSearchEnabled((v) => !v)} @@ -903,16 +957,14 @@ function ChatInputInner({ )} > <Globe className="h-4 w-4 shrink-0" /> - <span - className={cn( - 'overflow-hidden whitespace-nowrap text-xs font-medium transition-all duration-150 ease-out', - searchEnabled ? 'ml-1.5 max-w-[60px] opacity-100' : 'max-w-0 opacity-0' - )} - > - Search - </span> + {searchEnabled && collapseLevel < 3 && ( + <span className="ml-1.5 whitespace-nowrap text-xs font-medium"> + Search + </span> + )} </button> )} + {collapseLevel < 6 && ( <Tooltip> <TooltipTrigger asChild> <button @@ -923,7 +975,8 @@ function ChatInputInner({ }} disabled={Boolean(runId)} className={cn( - "flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors", + "flex h-7 shrink-0 items-center gap-1.5 rounded-full text-xs font-medium transition-colors", + collapseLevel >= 2 ? "w-7 justify-center" : "px-2.5", permissionMode === 'auto' ? "bg-secondary text-foreground hover:bg-secondary/70" : "text-muted-foreground hover:bg-muted hover:text-foreground", @@ -931,8 +984,8 @@ function ChatInputInner({ )} aria-label="Permission mode" > - <ShieldCheck className="h-3.5 w-3.5" /> - <span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span> + <ShieldCheck className="h-3.5 w-3.5 shrink-0" /> + {collapseLevel < 2 && <span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>} </button> </TooltipTrigger> <TooltipContent side="top"> @@ -943,37 +996,54 @@ function ChatInputInner({ : 'Manual approval prompts — click for auto-permission'} </TooltipContent> </Tooltip> - {codeModeFeatureEnabled && (codeModeEnabled ? ( - <div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground"> + )} + {codeModeFeatureEnabled && collapseLevel < 5 && (codeModeEnabled ? ( + collapseLevel >= 1 ? ( + /* Level 1: collapse the pill to a single icon */ <Tooltip> <TooltipTrigger asChild> <button type="button" onClick={() => setCodeModeEnabled(false)} - className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70" + className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/70" > <Terminal className="h-3.5 w-3.5" /> - <span>Code</span> </button> </TooltipTrigger> - <TooltipContent side="top">Code mode on — click to disable</TooltipContent> + <TooltipContent side="top">Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable</TooltipContent> </Tooltip> - <span className="text-foreground/30">·</span> - <Tooltip> - <TooltipTrigger asChild> - <button - type="button" - onClick={handleToggleCodingAgent} - className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70" - > - <span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span> - </button> - </TooltipTrigger> - <TooltipContent side="top"> - Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap - </TooltipContent> - </Tooltip> - </div> + ) : ( + <div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground"> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={() => setCodeModeEnabled(false)} + className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70" + > + <Terminal className="h-3.5 w-3.5" /> + <span>Code</span> + </button> + </TooltipTrigger> + <TooltipContent side="top">Code mode on — click to disable</TooltipContent> + </Tooltip> + <span className="text-foreground/30">·</span> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={handleToggleCodingAgent} + className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70" + > + <span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span> + </button> + </TooltipTrigger> + <TooltipContent side="top"> + Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap + </TooltipContent> + </Tooltip> + </div> + ) ) : ( <Tooltip> <TooltipTrigger asChild> @@ -989,25 +1059,89 @@ function ChatInputInner({ <TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent> </Tooltip> ))} + </div> + {collapseLevel >= 5 && ( + <DropdownMenu> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <button + type="button" + aria-label="More options" + className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + <MoreHorizontal className="h-4 w-4" /> + </button> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="top">More options</TooltipContent> + </Tooltip> + <DropdownMenuContent align="start" side="top" className="min-w-52"> + {workDir && collapseLevel >= 8 && ( + <DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}> + <FolderCog className="size-4" /> + <span className="min-w-0 flex-1 truncate">{basename(workDir) || workDir}</span> + </DropdownMenuItem> + )} + {searchAvailable && collapseLevel >= 7 && ( + <DropdownMenuCheckboxItem + checked={searchEnabled} + onSelect={(e) => e.preventDefault()} + onCheckedChange={(c) => setSearchEnabled(Boolean(c))} + > + Web search + </DropdownMenuCheckboxItem> + )} + {collapseLevel >= 6 && ( + <DropdownMenuCheckboxItem + checked={permissionMode === 'auto'} + disabled={Boolean(runId)} + onSelect={(e) => e.preventDefault()} + onCheckedChange={(c) => setPermissionMode(c ? 'auto' : 'manual')} + > + Auto-approve actions + </DropdownMenuCheckboxItem> + )} + {codeModeFeatureEnabled && collapseLevel >= 5 && ( + <> + <DropdownMenuCheckboxItem + checked={codeModeEnabled} + onSelect={(e) => e.preventDefault()} + onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))} + > + Code mode + </DropdownMenuCheckboxItem> + {codeModeEnabled && ( + <DropdownMenuItem onSelect={(e) => { e.preventDefault(); handleToggleCodingAgent() }}> + <Terminal className="size-4" /> + <span className="min-w-0 flex-1">Coding agent</span> + <span className="text-xs text-muted-foreground">{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span> + </DropdownMenuItem> + )} + </> + )} + </DropdownMenuContent> + </DropdownMenu> + )} <div className="flex-1" /> {lockedModel ? ( <span - className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground" + className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground" title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`} > - <span className="max-w-[150px] truncate">{getSelectedModelDisplayName(lockedModel.model)}</span> + <span className="min-w-0 truncate">{getSelectedModelDisplayName(lockedModel.model)}</span> </span> ) : configuredModels.length > 0 ? ( <DropdownMenu> <DropdownMenuTrigger asChild> <button type="button" - className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" > - <span className="max-w-[150px] truncate"> + <span className="min-w-0 truncate"> {getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')} </span> - <ChevronDown className="h-3 w-3" /> + <ChevronDown className="h-3 w-3 shrink-0" /> </button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> From 8fb0833b194cd22a3a16023d6a44fa7392317f50 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:37:05 +0530 Subject: [PATCH 34/35] upgrade gh actions pnpm --- .github/workflows/electron-build.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index ec60096f..b919ab07 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 @@ -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 @@ -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 From f6f6c715a0479ffcee7051e0cb7648c3885d9cad Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:37:12 +0530 Subject: [PATCH 35/35] fix spacing --- .github/workflows/electron-build.yml | 30 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index b919ab07..787e28e6 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -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 @@ -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); " @@ -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); "