+ Code mode lets the assistant delegate coding tasks
+ to Claude Code or Codex running
+ on your machine. Pick the agent inline from the composer; the assistant calls it via
+ acpx
+ and streams results back into chat.
+
+
+ Requires an active Claude Code subscription or
+ a ChatGPT/Codex subscription. You can have one or both.
+
+
+
+
+
+ Agent status
+
+
+
+
+
+
+
+
+
+
+
Enable code mode
+
+ Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
+
+
+
+
+
+ {enabled && status && !anyReady && (
+
+
+
+ Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
+ account, then click Re-check.
+
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 = {
* 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 | 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): Promise 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 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 -s ${sessionName} ""
+\`\`\`
+
+**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 -s ${sessionName} ""
+\`\`\`
+
+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 \`\` 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 {
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('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 claude exec ""
-` + "`" + `
+> Using [Claude Code / Codex] to [task description] in \`[folder]\`.
-**For Codex:**
-` + "`" + `
-npx acpx@latest --approve-all --cwd codex exec ""
-` + "`" + `
+…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 \`\` (\`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 \`\` — **stable for this whole chat**:
+- If the "# Code Mode (Active)" block gives a session name (e.g. \`rowboat-\`), 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 sessions ensure --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 -s ""
+\`\`\`
+
+**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 -s ""
+\`\`\`
+
+**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 \` and \`-s \` come AFTER the agent name:
-` + "`" + `
-npx acpx@latest [global flags] exec ""
-` + "`" + `
+- ✓ Correct: \`npx acpx@latest --approve-all --timeout 600 --cwd -s ""\`
+- ✗ Wrong: \`npx acpx@latest --approve-all -s "..."\` (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 doesn't exist yet. Create it once with \`npx acpx@latest --approve-all --cwd sessions ensure --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 = {
// 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();
+ 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: \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 = {
}
// 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 {
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;
+ enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise;
dequeue(runId: string): Promise;
}
@@ -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 {
+ async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise {
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;
+ setConfig(config: CodeModeConfig): Promise;
+}
+
+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 {
+ 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 {
+ try {
+ const content = await fs.readFile(this.configPath, 'utf8');
+ return CodeModeConfig.parse(JSON.parse(content));
+ } catch {
+ return this.defaultConfig;
+ }
+ }
+
+ async setConfig(config: CodeModeConfig): Promise {
+ 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 {
+ 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 {
+ 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 | 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 : 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;
+
+ const oauth = parsed.claudeAiOauth as Record | 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 {
+ 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 {
+ 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 {
+ 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;
+
+ if (typeof parsed.OPENAI_API_KEY === 'string' && parsed.OPENAI_API_KEY.length > 10) return true;
+
+ const tokens = parsed.tokens as Record | 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 {
+ 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;
+
+export const AgentStatus = z.object({
+ installed: z.boolean(),
+ signedIn: z.boolean(),
+});
+export type AgentStatus = z.infer;
+
+export const CodeModeAgentStatus = z.object({
+ claude: AgentStatus,
+ codex: AgentStatus,
+});
+export type CodeModeAgentStatus = z.infer;
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(FSOAuthRepo).singleton(),
clientRegistrationRepo: asClass(FSClientRegistrationRepo).singleton(),
granolaConfigRepo: asClass(FSGranolaConfigRepo).singleton(),
+ codeModeConfigRepo: asClass(FSCodeModeConfigRepo).singleton(),
agentScheduleRepo: asClass(FSAgentScheduleRepo).singleton(),
agentScheduleStateRepo: asClass(FSAgentScheduleStateRepo).singleton(),
slackConfigRepo: asClass(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): Promise
return run;
}
-export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise {
+export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex'): Promise {
const queue = container.resolve('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('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 02/11] 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 {
+ 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 03/11] 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() {
New chat
)}
-
-
-
-
-
-
-
- Chat options
-
-
- {
- void handleDownloadActiveChatLog()
- }}
- >
-
- Download chat log
-
-
-
{/* 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 04/11] 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;
@@ -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 | null = null;
- constructor() {
- this.ensureConfigFile();
- }
-
- private async ensureConfigFile(): Promise {
- 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 {
+ 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 {
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 05/11] 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['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 {
+ return {
+ currentDateTime: formatCurrentDateTime(new Date()),
+ ...(isCopilotLikeAgent(agentName)
+ ? { middlePane: toUserMessageContextMiddlePane(middlePaneContext) }
+ : {}),
+ };
+}
+
+function formatUserMessageContextForLlm(userMessageContext: z.infer): 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;
}
@@ -722,17 +812,18 @@ export function convertFromMessages(messages: z.infer[]): 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[]): 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[]): 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 06/11] 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 07/11] 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
copyPath: (path: string) => void
revealInFileManager: (path: string, isDir: boolean) => void
+ createNote: (parentPath?: string) => void
+ createFolder: (parentPath?: string) => Promise
+ 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
}
@@ -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 {
})
}
-export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
- const [currentPath, setCurrentPath] = useState(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(null)
const folderInputRef = useRef(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
');
+ });
+
+ it('regenerates html from clean text if only the text boundary is detected', () => {
+ const result = sanitizeReplyBodyForGmailReply(
+ '