{activeTab === "account" ? (
) : activeTab === "connections" ? (
@@ -2165,6 +2264,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
) : activeTab === "appearance" ? (
+ ) : activeTab === "notifications" ? (
+
) : activeTab === "help" ? (
) : activeTab === "code-mode" ? (
diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts
index 6f563a07..b3d39716 100644
--- a/apps/x/packages/core/src/agents/runtime.ts
+++ b/apps/x/packages/core/src/agents/runtime.ts
@@ -17,6 +17,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
import { resolveFilePathForPermission } from "../filesystem/files.js";
import container from "../di/container.js";
+import { notifyIfEnabled } from "../application/notification/notifier.js";
import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js";
import { resolveProviderConfig } from "../models/defaults.js";
@@ -377,6 +378,7 @@ export class AgentRuntime implements IAgentRuntime {
type: "run-processing-start",
subflow: [],
});
+ let totalEvents = 0;
while (true) {
// Check for abort before each iteration
if (signal.aborted) {
@@ -417,6 +419,7 @@ export class AgentRuntime implements IAgentRuntime {
throw error;
}
+ totalEvents += eventCount;
// if no events, break
if (!eventCount) {
break;
@@ -433,6 +436,27 @@ export class AgentRuntime implements IAgentRuntime {
};
await this.runsRepo.appendEvents(runId, [stoppedEvent]);
await this.bus.publish(stoppedEvent);
+ } else if (totalEvents > 0) {
+ // The run reached a natural stopping point and actually did
+ // something this cycle. Notify "chat completion" — unless it
+ // paused on a permission request, which surfaces its own
+ // notification (distinguish by inspecting the final state).
+ const finalRun = await this.runsRepo.fetch(runId);
+ if (finalRun) {
+ const finalState = new AgentState();
+ for (const event of finalRun.log) {
+ finalState.ingest(event);
+ }
+ if (finalState.getPendingPermissions().length === 0) {
+ void notifyIfEnabled("chat_completion", {
+ title: "Response ready",
+ message: "Your agent finished responding.",
+ link: `rowboat://open?type=chat&runId=${runId}`,
+ actionLabel: "Open",
+ onlyWhenBackground: true,
+ });
+ }
+ }
}
} catch (error) {
console.error(`Run ${runId} failed:`, error);
@@ -1545,6 +1569,16 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
}
if (permissionCandidates.length > 0) {
+ // Permission prompts block the run, so they surface even when the
+ // app is focused (no onlyWhenBackground gate).
+ const notifyPermissionPrompt = (toolCall: typeof permissionCandidates[number]["toolCall"]) => {
+ void notifyIfEnabled("agent_permission", {
+ title: "Permission needed",
+ message: `${agent.name} wants to run "${toolCall.toolName}". Review to continue.`,
+ link: `rowboat://open?type=chat&runId=${runId}`,
+ actionLabel: "Review",
+ });
+ };
if (state.permissionMode === "auto") {
let decisionsByToolCallId = new Map
();
try {
@@ -1578,6 +1612,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
permission: candidate.permission,
subflow: [],
});
+ notifyPermissionPrompt(candidate.toolCall);
continue;
}
@@ -1609,6 +1644,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
permission: candidate.permission,
subflow: [],
});
+ notifyPermissionPrompt(candidate.toolCall);
}
}
} else {
@@ -1621,6 +1657,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
permission: candidate.permission,
subflow: [],
});
+ notifyPermissionPrompt(candidate.toolCall);
}
}
}
diff --git a/apps/x/packages/core/src/application/notification/notifier.ts b/apps/x/packages/core/src/application/notification/notifier.ts
new file mode 100644
index 00000000..a1cacc76
--- /dev/null
+++ b/apps/x/packages/core/src/application/notification/notifier.ts
@@ -0,0 +1,29 @@
+import type { NotificationCategory } from '@x/shared/dist/notification-settings.js';
+import { isNotificationCategoryEnabled } from '../../config/notification_config.js';
+import type { INotificationService, NotifyInput } from './service.js';
+
+/**
+ * Fire a notification for `category`, but only if the user has that category
+ * enabled and the platform supports notifications.
+ *
+ * Resolution of the notification service is done via a *dynamic* import of the
+ * DI container so that callers like the agent runtime — which the container
+ * itself imports — don't create a circular module dependency. The whole thing
+ * is wrapped so a missing service (very early startup), an unsupported
+ * platform, or a config read error can never disrupt the run/sync that
+ * triggered it. Callers should fire-and-forget (`void notifyIfEnabled(...)`).
+ */
+export async function notifyIfEnabled(
+ category: NotificationCategory,
+ input: NotifyInput,
+): Promise {
+ try {
+ if (!isNotificationCategoryEnabled(category)) return;
+ const { default: container } = await import('../../di/container.js');
+ const service = container.resolve('notificationService');
+ if (!service.isSupported()) return;
+ service.notify(input);
+ } catch (err) {
+ console.error(`[notifier] failed to notify (category=${category}):`, err);
+ }
+}
diff --git a/apps/x/packages/core/src/application/notification/service.ts b/apps/x/packages/core/src/application/notification/service.ts
index 195315b1..2d32878b 100644
--- a/apps/x/packages/core/src/application/notification/service.ts
+++ b/apps/x/packages/core/src/application/notification/service.ts
@@ -4,6 +4,14 @@ export interface NotifyInput {
link?: string;
actionLabel?: string;
secondaryActions?: Array<{ label: string; link: string }>;
+ /**
+ * When true, the notification is suppressed if the app is currently in the
+ * foreground (any window focused). Use for ambient notifications the user
+ * doesn't need while actively looking at the app (e.g. chat completion, new
+ * email). Leave unset/false for notifications that must always surface
+ * regardless of focus (e.g. an agent permission request that blocks a run).
+ */
+ onlyWhenBackground?: boolean;
}
export interface INotificationService {
diff --git a/apps/x/packages/core/src/config/notification_config.ts b/apps/x/packages/core/src/config/notification_config.ts
new file mode 100644
index 00000000..d8a23e56
--- /dev/null
+++ b/apps/x/packages/core/src/config/notification_config.ts
@@ -0,0 +1,52 @@
+import fs from 'fs';
+import path from 'path';
+import {
+ NotificationSettingsSchema,
+ DEFAULT_NOTIFICATION_SETTINGS,
+ type NotificationSettings,
+ type NotificationCategory,
+} from '@x/shared/dist/notification-settings.js';
+import { WorkDir } from './config.js';
+
+const NOTIFICATION_CONFIG_PATH = path.join(WorkDir, 'config', 'notification_settings.json');
+
+/**
+ * Load notification settings, merging any persisted values over the defaults.
+ *
+ * Merging (rather than a strict parse) keeps the file forward/backward
+ * compatible: a category added in a newer build is filled in from defaults
+ * when an older file omits it, and a malformed file falls back to defaults
+ * instead of disabling notifications entirely.
+ */
+export function loadNotificationSettings(): NotificationSettings {
+ try {
+ if (fs.existsSync(NOTIFICATION_CONFIG_PATH)) {
+ const content = fs.readFileSync(NOTIFICATION_CONFIG_PATH, 'utf-8');
+ const parsed = JSON.parse(content);
+ const categories = parsed?.categories ?? {};
+ return NotificationSettingsSchema.parse({
+ categories: {
+ ...DEFAULT_NOTIFICATION_SETTINGS.categories,
+ ...categories,
+ },
+ });
+ }
+ } catch (error) {
+ console.error('[NotificationConfig] Error loading notification settings:', error);
+ }
+ return DEFAULT_NOTIFICATION_SETTINGS;
+}
+
+export function saveNotificationSettings(settings: NotificationSettings): void {
+ const dir = path.dirname(NOTIFICATION_CONFIG_PATH);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ const validated = NotificationSettingsSchema.parse(settings);
+ fs.writeFileSync(NOTIFICATION_CONFIG_PATH, JSON.stringify(validated, null, 2));
+}
+
+/** Convenience: is a single notification category currently enabled? */
+export function isNotificationCategoryEnabled(category: NotificationCategory): boolean {
+ return loadNotificationSettings().categories[category];
+}
diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts
index 77055f37..ce2a17be 100644
--- a/apps/x/packages/core/src/knowledge/sync_gmail.ts
+++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts
@@ -9,6 +9,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
import { limitEventItems } from './limit_event_items.js';
import { createEvent } from '../events/producer.js';
import { classifyThread, getUserEmail } from './classify_thread.js';
+import { notifyIfEnabled } from '../application/notification/notifier.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
@@ -220,6 +221,26 @@ function summarizeGmailSync(threads: SyncedThread[]): string {
return lines.join('\n');
}
+/**
+ * Fire one OS notification per genuinely-new email thread. Only ever called
+ * from the partial-sync (incremental) path, so the first-time connect — which
+ * goes through fullSync — never notifies. Suppressed while the app is focused.
+ */
+function notifyNewEmails(threads: SyncedThread[]): void {
+ for (const { threadId } of threads) {
+ const snapshot = readCachedSnapshot(threadId)?.snapshot;
+ const subject = snapshot?.subject?.trim() || '(no subject)';
+ const from = snapshot?.from?.trim();
+ void notifyIfEnabled('new_email', {
+ title: from ? `New email from ${from}` : 'New email',
+ message: subject,
+ link: 'rowboat://open?type=chat',
+ actionLabel: 'Open',
+ onlyWhenBackground: true,
+ });
+ }
+}
+
async function publishGmailSyncEvent(threads: SyncedThread[]): Promise {
if (threads.length === 0) return;
try {
@@ -1260,6 +1281,9 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
const result = await processThread(auth, tid, syncDir, attachmentsDir);
if (result) synced.push(result);
}
+ // Notify for the history-derived new threads only — before the older
+ // backfilled threads are merged in below, so backfill stays silent.
+ notifyNewEmails(synced);
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
synced.push(...backfilled);
diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts
index 02f980a2..eb907f5c 100644
--- a/apps/x/packages/shared/src/index.ts
+++ b/apps/x/packages/shared/src/index.ts
@@ -17,4 +17,5 @@ export * as frontmatter from './frontmatter.js';
export * as bases from './bases.js';
export * as browserControl from './browser-control.js';
export * as billing from './billing.js';
+export * as notificationSettings from './notification-settings.js';
export { PrefixLogger };
diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts
index c2e5e8e7..6df8c8eb 100644
--- a/apps/x/packages/shared/src/ipc.ts
+++ b/apps/x/packages/shared/src/ipc.ts
@@ -20,6 +20,7 @@ import { BrowserStateSchema } from './browser-control.js';
import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
import { PermissionDecision, ApprovalPolicy } from './code-mode.js';
+import { NotificationSettingsSchema } from './notification-settings.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@@ -1028,6 +1029,17 @@ const ipcSchemas = {
req: z.null(),
res: BillingInfoSchema,
},
+ // Notification settings channels
+ 'notifications:getSettings': {
+ req: z.null(),
+ res: NotificationSettingsSchema,
+ },
+ 'notifications:setSettings': {
+ req: NotificationSettingsSchema,
+ res: z.object({
+ success: z.literal(true),
+ }),
+ },
} as const;
// ============================================================================
diff --git a/apps/x/packages/shared/src/notification-settings.ts b/apps/x/packages/shared/src/notification-settings.ts
new file mode 100644
index 00000000..1ab7646b
--- /dev/null
+++ b/apps/x/packages/shared/src/notification-settings.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+
+/**
+ * Notification categories the user can independently toggle.
+ *
+ * - chat_completion: an agent finished generating a response
+ * - new_email: a new email arrived during incremental Gmail sync
+ * - agent_permission: an agent is requesting permission to run a tool
+ */
+export const NotificationCategorySchema = z.enum([
+ 'chat_completion',
+ 'new_email',
+ 'agent_permission',
+]);
+
+export const NotificationCategoriesSchema = z.object({
+ chat_completion: z.boolean(),
+ new_email: z.boolean(),
+ agent_permission: z.boolean(),
+});
+
+export const NotificationSettingsSchema = z.object({
+ categories: NotificationCategoriesSchema,
+});
+
+export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
+ categories: {
+ chat_completion: true,
+ new_email: true,
+ agent_permission: true,
+ },
+};
+
+export type NotificationCategory = z.infer;
+export type NotificationCategories = z.infer;
+export type NotificationSettings = z.infer;
diff --git a/apps/x/pnpm-workspace.yaml b/apps/x/pnpm-workspace.yaml
index f5cdd141..b4bc70d9 100644
--- a/apps/x/pnpm-workspace.yaml
+++ b/apps/x/pnpm-workspace.yaml
@@ -2,6 +2,15 @@ packages:
- apps/*
- packages/*
+allowBuilds:
+ core-js: set this to true or false
+ electron: set this to true or false
+ electron-winstaller: set this to true or false
+ esbuild: set this to true or false
+ fs-xattr: set this to true or false
+ macos-alias: set this to true or false
+ protobufjs: set this to true or false
+
catalog:
vitest: 4.1.7
From 45100580c99a145aaf5af3b27c34ce9f8c119ca2 Mon Sep 17 00:00:00 2001
From: Arjun <6592213+arkml@users.noreply.github.com>
Date: Fri, 12 Jun 2026 23:45:38 +0530
Subject: [PATCH 09/10] add code mode: coding-agent workspace with sessions,
direct/Rowboat drive, diffs, and worktrees
Co-Authored-By: Claude Fable 5
---
apps/x/ANALYTICS.md | 3 +-
apps/x/apps/main/src/ipc.ts | 141 +++-
apps/x/apps/main/src/main.ts | 4 +
apps/x/apps/renderer/package.json | 7 +
apps/x/apps/renderer/src/App.tsx | 176 ++++-
.../components/chat-input-with-mentions.tsx | 143 ++--
.../renderer/src/components/chat-sidebar.tsx | 59 +-
.../x/apps/renderer/src/components/code/cm.ts | 84 +++
.../src/components/code/code-chat.tsx | 350 ++++++++++
.../src/components/code/code-view.tsx | 246 +++++++
.../src/components/code/diff-viewer.tsx | 121 ++++
.../src/components/code/file-tree.tsx | 101 +++
.../src/components/code/file-viewer.tsx | 70 ++
.../components/code/new-session-dialog.tsx | 315 +++++++++
.../components/code/resizable-right-pane.tsx | 132 ++++
.../src/components/code/session-rail.tsx | 179 +++++
.../src/components/code/use-code-chat.ts | 473 ++++++++++++++
.../src/components/code/use-code-sessions.ts | 72 ++
.../src/components/code/workspace-pane.tsx | 254 ++++++++
.../renderer/src/components/coding-run.tsx | 29 +-
.../src/components/sidebar-content.tsx | 11 +-
apps/x/packages/core/src/agents/runtime.ts | 8 +-
.../x/packages/core/src/analytics/use_case.ts | 2 +-
.../core/src/application/lib/builtin-tools.ts | 22 +-
.../core/src/application/lib/exec-tool.ts | 5 +
.../core/src/application/lib/message-queue.ts | 12 +-
.../core/src/code-mode/acp/manager.ts | 23 +-
.../core/src/code-mode/git/service.ts | 272 ++++++++
.../core/src/code-mode/projects/fs.ts | 83 +++
.../core/src/code-mode/projects/repo.ts | 69 ++
.../core/src/code-mode/sessions/repo.ts | 63 ++
.../core/src/code-mode/sessions/service.ts | 361 +++++++++++
.../src/code-mode/sessions/status-tracker.ts | 136 ++++
apps/x/packages/core/src/di/container.ts | 11 +
apps/x/packages/core/src/runs/repo.ts | 1 +
apps/x/packages/core/src/runs/runs.ts | 19 +-
apps/x/packages/core/src/workspace/watcher.ts | 8 +
apps/x/packages/shared/src/code-sessions.ts | 71 ++
apps/x/packages/shared/src/index.ts | 1 +
apps/x/packages/shared/src/ipc.ts | 183 +++++-
apps/x/packages/shared/src/runs.ts | 3 +
apps/x/pnpm-lock.yaml | 613 ++++++++++++++++--
42 files changed, 4786 insertions(+), 150 deletions(-)
create mode 100644 apps/x/apps/renderer/src/components/code/cm.ts
create mode 100644 apps/x/apps/renderer/src/components/code/code-chat.tsx
create mode 100644 apps/x/apps/renderer/src/components/code/code-view.tsx
create mode 100644 apps/x/apps/renderer/src/components/code/diff-viewer.tsx
create mode 100644 apps/x/apps/renderer/src/components/code/file-tree.tsx
create mode 100644 apps/x/apps/renderer/src/components/code/file-viewer.tsx
create mode 100644 apps/x/apps/renderer/src/components/code/new-session-dialog.tsx
create mode 100644 apps/x/apps/renderer/src/components/code/resizable-right-pane.tsx
create mode 100644 apps/x/apps/renderer/src/components/code/session-rail.tsx
create mode 100644 apps/x/apps/renderer/src/components/code/use-code-chat.ts
create mode 100644 apps/x/apps/renderer/src/components/code/use-code-sessions.ts
create mode 100644 apps/x/apps/renderer/src/components/code/workspace-pane.tsx
create mode 100644 apps/x/packages/core/src/code-mode/git/service.ts
create mode 100644 apps/x/packages/core/src/code-mode/projects/fs.ts
create mode 100644 apps/x/packages/core/src/code-mode/projects/repo.ts
create mode 100644 apps/x/packages/core/src/code-mode/sessions/repo.ts
create mode 100644 apps/x/packages/core/src/code-mode/sessions/service.ts
create mode 100644 apps/x/packages/core/src/code-mode/sessions/status-tracker.ts
create mode 100644 apps/x/packages/shared/src/code-sessions.ts
diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md
index 2d9816d0..5ddfcf6e 100644
--- a/apps/x/ANALYTICS.md
+++ b/apps/x/ANALYTICS.md
@@ -24,7 +24,7 @@ Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run
| Property | Type | Notes |
|---|---|---|
-| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` |
+| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` / `code_session` |
| `sub_use_case` | string? | Refines `use_case` — see taxonomy table below |
| `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` |
| `model` | string | e.g. `claude-sonnet-4-6` |
@@ -57,6 +57,7 @@ Every `llm_usage` emit point in the codebase:
| `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) |
| `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` |
| `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) |
+| `code_session` | (none) | yes | Code-section coding session in Rowboat mode (direct mode talks to the on-device coding agent and emits no `llm_usage`) | `packages/core/src/code-mode/sessions/service.ts` (createRun) |
##### `live_note_agent` sub-use-case shape
diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts
index 35112709..2f81dedc 100644
--- a/apps/x/apps/main/src/ipc.ts
+++ b/apps/x/apps/main/src/ipc.ts
@@ -34,6 +34,13 @@ 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 type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js';
+import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js';
+import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js';
+import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js';
+import * as codeGit from '@x/core/dist/code-mode/git/service.js';
+import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js';
+import type { CodeSession } from '@x/shared/dist/code-sessions.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';
@@ -375,6 +382,32 @@ export function emitOAuthEvent(event: { provider: string; success: boolean; erro
}
}
+async function requireCodeSession(sessionId: string): Promise {
+ const repo = container.resolve('codeSessionsRepo');
+ const session = await repo.get(sessionId);
+ if (!session) {
+ throw new Error(`Unknown code session: ${sessionId}`);
+ }
+ return session;
+}
+
+let codeSessionStatusWatcher: (() => void) | null = null;
+export async function startCodeSessionStatusWatcher(): Promise {
+ if (codeSessionStatusWatcher) {
+ return;
+ }
+ const tracker = container.resolve('codeSessionStatusTracker');
+ await tracker.start();
+ codeSessionStatusWatcher = tracker.onTransition((sessionId, status) => {
+ const windows = BrowserWindow.getAllWindows();
+ for (const win of windows) {
+ if (!win.isDestroyed() && win.webContents) {
+ win.webContents.send('codeSession:status', { sessionId, status });
+ }
+ }
+ });
+}
+
let runsWatcher: (() => void) | null = null;
export async function startRunsWatcher(): Promise {
if (runsWatcher) {
@@ -557,7 +590,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, args.codeMode) };
+ return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode, args.codeCwd, args.codePolicy) };
},
'runs:authorizePermission': async (_event, args) => {
await runsCore.authorizePermission(args.runId, args.authorization);
@@ -680,6 +713,103 @@ export function setupIpcHandlers() {
'codeMode:checkAgentStatus': async () => {
return await checkCodeModeAgentStatus();
},
+ 'codeProject:add': async (_event, args) => {
+ const repo = container.resolve('codeProjectsRepo');
+ const project = await repo.add(args.path);
+ const git = await codeGit.repoInfo(project.path);
+ return { project, git };
+ },
+ 'codeProject:remove': async (_event, args) => {
+ const repo = container.resolve('codeProjectsRepo');
+ await repo.remove(args.projectId);
+ return { success: true };
+ },
+ 'codeProject:list': async () => {
+ const repo = container.resolve('codeProjectsRepo');
+ const projects = await repo.list();
+ return {
+ projects: await Promise.all(projects.map(async (project) => ({
+ project,
+ git: await codeGit.repoInfo(project.path),
+ }))),
+ };
+ },
+ 'codeSession:create': async (_event, args) => {
+ const service = container.resolve('codeSessionService');
+ const session = await service.create(args);
+ return { session };
+ },
+ 'codeSession:list': async () => {
+ const repo = container.resolve('codeSessionsRepo');
+ const tracker = container.resolve('codeSessionStatusTracker');
+ return { sessions: await repo.list(), statuses: tracker.getStatuses() };
+ },
+ 'codeSession:update': async (_event, args) => {
+ const service = container.resolve('codeSessionService');
+ return { session: await service.update(args.sessionId, args.patch) };
+ },
+ 'codeSession:delete': async (_event, args) => {
+ const service = container.resolve('codeSessionService');
+ await service.delete(args.sessionId, {
+ removeWorktree: args.removeWorktree,
+ deleteBranch: args.deleteBranch,
+ });
+ return { success: true };
+ },
+ 'codeSession:sendMessage': async (_event, args) => {
+ const service = container.resolve('codeSessionService');
+ // Intentionally not awaited: the turn can run for minutes and streams over
+ // runs:events. sendMessage validates synchronously enough that busy/unknown
+ // errors are reported via the run's error events instead.
+ const resultPromise = service.sendMessage(args.sessionId, args.text);
+ // Surface immediate rejections (busy session, unknown id) to the caller.
+ const result = await Promise.race([
+ resultPromise,
+ new Promise<{ accepted: true }>((resolve) => setTimeout(() => resolve({ accepted: true }), 300)),
+ ]);
+ resultPromise.catch((err) => console.error('codeSession:sendMessage failed', err));
+ return result;
+ },
+ 'codeSession:stop': async (_event, args) => {
+ const service = container.resolve('codeSessionService');
+ await service.stop(args.sessionId);
+ return { success: true };
+ },
+ 'codeSession:gitStatus': async (_event, args) => {
+ const session = await requireCodeSession(args.sessionId);
+ const info = await codeGit.repoInfo(session.cwd);
+ if (!info.isGitRepo) {
+ return { isRepo: false, branch: null, hasCommits: false, files: [] };
+ }
+ const files = await codeGit.status(session.cwd);
+ return { isRepo: true, branch: info.branch, hasCommits: info.hasCommits, files };
+ },
+ 'codeSession:fileDiff': async (_event, args) => {
+ const session = await requireCodeSession(args.sessionId);
+ return codeGit.fileDiff(session.cwd, args.path);
+ },
+ 'codeSession:readdir': async (_event, args) => {
+ const session = await requireCodeSession(args.sessionId);
+ return { entries: await readProjectDir(session.cwd, args.relPath) };
+ },
+ 'codeSession:readFile': async (_event, args) => {
+ const session = await requireCodeSession(args.sessionId);
+ return readProjectFile(session.cwd, args.relPath);
+ },
+ 'codeSession:mergeBack': async (_event, args) => {
+ const service = container.resolve('codeSessionService');
+ return service.mergeBack(args.sessionId);
+ },
+ 'codeSession:cleanupWorktree': async (_event, args) => {
+ const service = container.resolve('codeSessionService');
+ try {
+ await service.cleanupWorktree(args.sessionId, args.deleteBranch);
+ return { success: true };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to clean up worktree';
+ return { success: false, error: message };
+ }
+ },
'granola:setConfig': async (_event, args) => {
const repo = container.resolve('granolaConfigRepo');
await repo.setConfig({ enabled: args.enabled });
@@ -830,6 +960,15 @@ export function setupIpcHandlers() {
}
return { path: result.filePaths[0] ?? null };
},
+ 'dialog:openFiles': async (event, args) => {
+ const win = BrowserWindow.fromWebContents(event.sender);
+ const result = await dialog.showOpenDialog(win!, {
+ title: args.title ?? 'Attach files',
+ ...(args.defaultPath ? { defaultPath: resolveShellPath(args.defaultPath) } : {}),
+ properties: ['openFile', 'multiSelections'],
+ });
+ return { paths: result.canceled ? [] : result.filePaths };
+ },
// Knowledge version history handlers
'knowledge:history': async (_event, args) => {
const commits = await versionHistory.getFileHistory(args.path);
diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts
index 40e49e35..5615dd6e 100644
--- a/apps/x/apps/main/src/main.ts
+++ b/apps/x/apps/main/src/main.ts
@@ -3,6 +3,7 @@ import path from "node:path";
import {
setupIpcHandlers,
startRunsWatcher,
+ startCodeSessionStatusWatcher,
startServicesWatcher,
startLiveNoteAgentWatcher,
startBackgroundTaskAgentWatcher,
@@ -352,6 +353,9 @@ app.whenReady().then(async () => {
// start runs watcher
startRunsWatcher();
+ // start code-session status tracker (derives working/needs-you/idle + notifications)
+ startCodeSessionStatusWatcher();
+
// start services watcher
startServicesWatcher();
diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json
index 67876189..3482b58e 100644
--- a/apps/x/apps/renderer/package.json
+++ b/apps/x/apps/renderer/package.json
@@ -9,7 +9,13 @@
"preview": "vite preview"
},
"dependencies": {
+ "@codemirror/language": "^6.12.3",
+ "@codemirror/language-data": "^6.5.2",
+ "@codemirror/merge": "^6.12.2",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.43.1",
"@eigenpal/docx-editor-react": "^1.0.3",
+ "@lezer/highlight": "^1.2.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
@@ -42,6 +48,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "codemirror": "^6.0.2",
"lucide-react": "^0.562.0",
"mermaid": "^11.14.0",
"motion": "^12.23.26",
diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx
index b850b57f..fcbc1ec7 100644
--- a/apps/x/apps/renderer/src/App.tsx
+++ b/apps/x/apps/renderer/src/App.tsx
@@ -34,6 +34,9 @@ import { KnowledgeView } from '@/components/knowledge-view';
import { ChatHistoryView } from '@/components/chat-history-view';
import { HomeView } from '@/components/home-view';
import { MeetingsView } from '@/components/meetings-view';
+import { CodeView, type ActiveCodeSession } from '@/components/code/code-view';
+import { CodeChat } from '@/components/code/code-chat';
+import { ResizableRightPane } from '@/components/code/resizable-right-pane';
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import {
Conversation,
@@ -199,6 +202,7 @@ const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__'
const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__'
const HOME_TAB_PATH = '__rowboat_home__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
+const CODE_TAB_PATH = '__rowboat_code__'
const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
@@ -336,6 +340,7 @@ const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PAT
const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH
const isHomeTabPath = (path: string) => path === HOME_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
+const isCodeTabPath = (path: string) => path === CODE_TAB_PATH
const getSuggestedTopicTargetFolder = (category?: string) => {
const normalized = category?.trim().toLowerCase()
@@ -589,6 +594,7 @@ type ViewState =
| { type: 'knowledge-view'; folderPath?: string }
| { type: 'chat-history' }
| { type: 'home' }
+ | { type: 'code' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false
@@ -652,6 +658,8 @@ function parseDeepLink(input: string): ViewState | null {
return { type: 'chat-history' }
case 'home':
return { type: 'home' }
+ case 'code':
+ return { type: 'code' }
default:
return null
}
@@ -1034,7 +1042,7 @@ function App() {
}, [])
// Runs history state
- type RunListItem = { id: string; title?: string; createdAt: string; agentId: string }
+ type RunListItem = { id: string; title?: string; createdAt: string; agentId: string; useCase?: string }
const [runs, setRuns] = useState([])
// Chat tab state
@@ -1159,6 +1167,23 @@ function App() {
const [activeFileTabId, setActiveFileTabId] = useState('home-tab')
const activeFileTabIdRef = useRef(activeFileTabId)
activeFileTabIdRef.current = activeFileTabId
+ // The Code section is tab-derived (no boolean to keep in sync with the other
+ // section flags): it is open exactly while its sentinel tab is active.
+ const isCodeOpen = React.useMemo(() => {
+ const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId)
+ return activeTab ? isCodeTabPath(activeTab.path) : false
+ }, [fileTabs, activeFileTabId])
+ // The code session that owns the right-hand chat pane: rowboat-mode sessions
+ // bind the assistant chat to their run; direct-mode sessions swap the pane
+ // for the direct-drive chat.
+ const [activeCodeSession, setActiveCodeSession] = useState(null)
+ // A file the code chat asked to review — consumed by the workspace pane.
+ const [codeDiffPath, setCodeDiffPath] = useState(null)
+ const boundCodeSessionRef = useRef(null)
+ // Composer locks for runs that are code sessions: the session's cwd + agent
+ // are frozen in the chat input (the backend pins them server-side anyway).
+ // Kept after the Code view unmounts — the chat tab stays bound to the run.
+ const [codeSessionLocks, setCodeSessionLocks] = useState>({})
const [editorSessionByTabId, setEditorSessionByTabId] = useState>({})
const fileHistoryHandlersRef = useRef>(new Map())
const fileTabIdCounterRef = useRef(0)
@@ -1175,6 +1200,7 @@ function App() {
if (isKnowledgeViewTabPath(tab.path)) return 'Notes'
if (isChatHistoryTabPath(tab.path)) return 'Chat history'
if (isHomeTabPath(tab.path)) return 'Home'
+ if (isCodeTabPath(tab.path)) return 'Code'
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
@@ -1807,8 +1833,8 @@ function App() {
cursor = result.nextCursor
} while (cursor)
- // Filter for copilot runs only
- const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot')
+ // Filter for copilot chats only (Code-section sessions live in the Code view)
+ const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot' && run.useCase !== 'code_session')
setRuns(copilotRuns)
} catch (err) {
console.error('Failed to load runs:', err)
@@ -2075,6 +2101,15 @@ function App() {
setConversation(items)
setRunId(id)
setMessage('')
+ // Reconcile composer state with THIS run. Loading a run while another one
+ // is mid-turn (e.g. binding a code session steals the single chat tab)
+ // must not leave isProcessing/isStopping pointing at the old run — that
+ // wedges the composer: stop targets the new run (a no-op) while the old
+ // run's processing-end arrives flagged as non-active and clears nothing.
+ setIsProcessing(processingRunIdsRef.current.has(id))
+ setIsStopping(false)
+ setStopClickedAt(null)
+ setCurrentAssistantMessage(streamingBuffersRef.current.get(id)?.assistant ?? '')
setPendingPermissionRequests(pendingPerms)
setPendingAskHumanRequests(pendingAsks)
setAllPermissionRequests(allPermissionRequests)
@@ -2145,6 +2180,11 @@ function App() {
break
case 'start':
+ // Run creation alone isn't a turn. Code-session runs are created when
+ // the session is (no message follows until the user sends one), so
+ // marking them processing here would never be cleared — and wedge the
+ // composer (Stop shown, send blocked) once the session binds a chat tab.
+ if (event.useCase === 'code_session') return
setProcessingRunIds(prev => {
if (prev.has(event.runId)) return prev
const next = new Set(prev)
@@ -2878,6 +2918,38 @@ function App() {
}
}, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab])
+ // A code session was selected (or changed mode/status) in the Code view.
+ // Rowboat-mode sessions take over the assistant chat pane by binding their
+ // run to a chat tab — the conversation IS the assistant chat, no copy.
+ // Direct-mode sessions render their own pane instead (see right-pane JSX).
+ const handleCodeSessionSelected = useCallback((active: ActiveCodeSession | null) => {
+ setActiveCodeSession(active)
+ if (active) {
+ const { id, cwd, agent } = active.session
+ setCodeSessionLocks((prev) => (
+ prev[id]?.cwd === cwd && prev[id]?.agent === agent
+ ? prev
+ : { ...prev, [id]: { cwd, agent } }
+ ))
+ }
+ const rowboatSessionId = active && active.session.mode === 'rowboat' ? active.session.id : null
+ if (!rowboatSessionId) {
+ boundCodeSessionRef.current = null
+ return
+ }
+ if (boundCodeSessionRef.current === rowboatSessionId) return
+ boundCodeSessionRef.current = rowboatSessionId
+ const existingTab = chatTabsRef.current.find((t) => t.runId === rowboatSessionId)
+ if (existingTab) {
+ switchChatTab(existingTab.id)
+ return
+ }
+ setChatTabs((prev) => prev.map((t) => (
+ t.id === activeChatTabIdRef.current ? { ...t, runId: rowboatSessionId } : t
+ )))
+ loadRun(rowboatSessionId)
+ }, [switchChatTab, loadRun])
+
const closeChatTab = useCallback((tabId: string) => {
if (chatTabs.length <= 1) return
const idx = chatTabs.findIndex(t => t.id === tabId)
@@ -3147,6 +3219,14 @@ function App() {
setIsHomeOpen(true)
return
}
+ if (isCodeTabPath(tab.path)) {
+ // isCodeOpen itself is derived from the active tab — just clear the rest.
+ setSelectedPath(null)
+ setIsGraphOpen(false)
+ setIsSuggestedTopicsOpen(false)
+ setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
+ return
+ }
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
@@ -3155,7 +3235,7 @@ function App() {
const closeFileTab = useCallback((tabId: string) => {
const closingTab = fileTabs.find(t => t.id === tabId)
- if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
+ if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isCodeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
removeEditorCacheForPath(closingTab.path)
initialContentByPathRef.current.delete(closingTab.path)
untitledRenameReadyPathsRef.current.delete(closingTab.path)
@@ -3548,10 +3628,11 @@ function App() {
if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined }
if (isChatHistoryOpen) return { type: 'chat-history' }
if (isHomeOpen) return { type: 'home' }
+ if (isCodeOpen) return { type: 'code' }
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, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId])
+ }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1]
@@ -3696,6 +3777,17 @@ function App() {
setActiveFileTabId(id)
}, [fileTabs])
+ const ensureCodeFileTab = useCallback(() => {
+ const existing = fileTabs.find((tab) => isCodeTabPath(tab.path))
+ if (existing) {
+ setActiveFileTabId(existing.id)
+ return
+ }
+ const id = newFileTabId()
+ setFileTabs((prev) => [...prev, { id, path: CODE_TAB_PATH }])
+ setActiveFileTabId(id)
+ }, [fileTabs])
+
const openEmailView = useCallback((threadId?: string) => {
setSelectedPath(null)
setIsGraphOpen(false)
@@ -3751,6 +3843,18 @@ function App() {
ensureMeetingsFileTab()
}, [ensureMeetingsFileTab])
+ const openCodeView = useCallback(() => {
+ setSelectedPath(null)
+ setIsGraphOpen(false)
+ setIsBrowserOpen(false)
+ setIsSuggestedTopicsOpen(false)
+ setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
+ setSelectedBackgroundTask(null)
+ setExpandedFrom(null)
+ setIsRightPaneMaximized(false)
+ ensureCodeFileTab()
+ }, [ensureCodeFileTab])
+
const applyViewState = useCallback(async (view: ViewState) => {
switch (view.type) {
case 'file':
@@ -3931,6 +4035,17 @@ function App() {
setIsHomeOpen(true)
ensureHomeFileTab()
return
+ case 'code':
+ setSelectedPath(null)
+ setIsGraphOpen(false)
+ setIsBrowserOpen(false)
+ setExpandedFrom(null)
+ setIsRightPaneMaximized(false)
+ setSelectedBackgroundTask(null)
+ setIsSuggestedTopicsOpen(false)
+ setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
+ ensureCodeFileTab()
+ return
case 'chat':
setSelectedPath(null)
setIsGraphOpen(false)
@@ -3959,7 +4074,7 @@ function App() {
}
return
}
- }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, handleNewChat, isRightPaneMaximized, loadRun])
+ }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, ensureCodeFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState
@@ -4294,7 +4409,7 @@ function App() {
}, [])
// Keyboard shortcut: Ctrl+L to toggle main chat view
- const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !selectedBackgroundTask && !isBrowserOpen
+ const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !isCodeOpen && !selectedBackgroundTask && !isBrowserOpen
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
@@ -5300,7 +5415,7 @@ function App() {
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
- const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen)
+ const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || isBrowserOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode
const nonChatPaneStyle = React.useMemo(() => {
@@ -5369,12 +5484,14 @@ function App() {
isHomeOpen ? 'home'
: isEmailOpen ? 'email'
: isMeetingsOpen ? 'meetings'
+ : isCodeOpen ? 'code'
: (isKnowledgeViewOpen || isGraphOpen || (selectedPath != null && selectedPath.startsWith('knowledge/'))) ? 'knowledge'
: isBgTasksOpen ? 'agents'
: isWorkspaceOpen ? 'workspaces'
: null
}
onOpenMeetings={openMeetingsView}
+ onOpenCode={openCodeView}
onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
recentRuns={runs}
@@ -5408,7 +5525,7 @@ function App() {
canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
>
- {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && fileTabs.length >= 1 ? (
+ {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen) && fileTabs.length >= 1 ? (
t.id}
onSwitchTab={switchFileTab}
onCloseTab={closeFileTab}
- allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
+ allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/>
) : isFullScreenChat ? (
Version history
)}
- {!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && (
+ {!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isCodeOpen && !selectedTask && !isBrowserOpen && (