From 220e15f642b4dff46cba11f5b6435df740a162ac Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:40:46 +0530 Subject: [PATCH 01/50] fix workdir everywhere (#475) * make workdir configurable everywhere for easy testing --- apps/x/apps/main/src/ipc.ts | 30 +++++++++---------- apps/x/apps/main/src/test-agent.ts | 4 +-- .../src/application/assistant/instructions.ts | 20 ++++++------- .../skills/background-agents/skill.ts | 10 +++---- .../assistant/skills/builtin-tools/skill.ts | 2 +- .../assistant/skills/doc-collab/skill.ts | 2 +- .../assistant/skills/draft-emails/skill.ts | 2 +- .../assistant/skills/meeting-prep/skill.ts | 2 +- .../assistant/skills/slack/skill.ts | 4 +-- .../core/src/application/lib/builtin-tools.ts | 4 +-- apps/x/packages/core/src/config/config.ts | 20 +++++++++++-- apps/x/packages/core/src/knowledge/README.md | 12 ++++---- .../core/src/knowledge/note_system.ts | 2 +- .../packages/core/src/knowledge/tag_system.ts | 2 +- apps/x/packages/core/src/voice/voice.ts | 7 ++--- apps/x/packages/core/src/workspace/watcher.ts | 3 +- 16 files changed, 70 insertions(+), 56 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a2230eda..74388f65 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -110,6 +110,18 @@ function markdownToHtml(markdown: string, title: string): string { ${html}` } +function resolveShellPath(filePath: string): string { + if (filePath.startsWith('~')) { + return path.join(os.homedir(), filePath.slice(1)); + } + + if (path.isAbsolute(filePath)) { + return filePath; + } + + return workspace.resolveWorkspacePath(filePath); +} + type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -271,7 +283,7 @@ function handleWorkspaceChange(event: z.infer { - let filePath = args.path; - if (filePath.startsWith('~')) { - filePath = path.join(os.homedir(), filePath.slice(1)); - } else if (!path.isAbsolute(filePath)) { - // Workspace-relative path — resolve against ~/.rowboat/ - filePath = path.join(os.homedir(), '.rowboat', filePath); - } + const filePath = resolveShellPath(args.path); const error = await shell.openPath(filePath); return { error: error || undefined }; }, 'shell:readFileBase64': async (_event, args) => { - let filePath = args.path; - if (filePath.startsWith('~')) { - filePath = path.join(os.homedir(), filePath.slice(1)); - } else if (!path.isAbsolute(filePath)) { - // Workspace-relative path — resolve against ~/.rowboat/ - filePath = path.join(os.homedir(), '.rowboat', filePath); - } + const filePath = resolveShellPath(args.path); const stat = await fs.stat(filePath); if (stat.size > 10 * 1024 * 1024) { throw new Error('File too large (>10MB)'); diff --git a/apps/x/apps/main/src/test-agent.ts b/apps/x/apps/main/src/test-agent.ts index 836deea7..738d861a 100644 --- a/apps/x/apps/main/src/test-agent.ts +++ b/apps/x/apps/main/src/test-agent.ts @@ -3,7 +3,7 @@ import { bus } from '@x/core/dist/runs/bus.js'; async function main() { const { id } = await runsCore.createRun({ - // this will expect an agent file to exist at ~/.rowboat/agents/test-agent.md + // this expects an agent file to exist at WorkDir/agents/test-agent.md agentId: 'test-agent', }); console.log(`created run: ${id}`); @@ -16,4 +16,4 @@ async function main() { console.log(`created message: ${msgId}`); } -main(); \ No newline at end of file +main(); diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 76472c90..022b21e4 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -112,10 +112,10 @@ Users can interact with the knowledge graph through you, open it directly in Obs ## How to Access the Knowledge Graph **CRITICAL PATH REQUIREMENT:** -- The workspace root is \`~/.rowboat/\` +- The workspace root is the configured workdir - The knowledge base is in the \`knowledge/\` subfolder - When using workspace tools, ALWAYS include \`knowledge/\` in the path -- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or \`path: "~/.rowboat"\` +- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or any absolute path to the workspace root - **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\` Use the builtin workspace tools to search and read the knowledge base: @@ -212,16 +212,16 @@ For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integ ${runtimeContextPrompt} ## Workspace Access & Scope -- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval. -- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands. -- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads"). +- **Inside the workspace root:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval. +- **Outside the workspace root (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands. +- **IMPORTANT:** Do NOT access files outside the workspace root unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads"). -**CRITICAL - When the user asks you to work with files outside ~/.rowboat:** +**CRITICAL - When the user asks you to work with files outside the workspace root:** - Follow the detected runtime platform above for shell syntax and filesystem path style. - On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS). - On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\\\Desktop\`). - You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths. -- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`. +- NEVER say "I can only run commands inside the workspace root" or "I don't have access to your Desktop" - just use \`executeCommand\`. - NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`. - NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder"). - NEVER ask what OS the user is on if runtime platform is already available. @@ -244,14 +244,14 @@ ${runtimeContextPrompt} - \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations. - \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance. -**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`. +**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside the workspace root, always use these instead of \`executeCommand\`. **Shell commands via \`executeCommand\`:** -- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`~/.rowboat/config/security.json\` and run immediately. +- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`config/security.json\` within the workspace root and run immediately. - Commands not on the pre-approved list will trigger a one-time approval prompt for the user — this is fine and expected, just a minor friction. Do NOT let this stop you from running commands you need. - **Never say "I can't run this command"** or ask the user to run something manually. Just call \`executeCommand\` and let the approval flow handle it. - When calling \`executeCommand\`, do NOT provide the \`cwd\` parameter unless absolutely necessary. The default working directory is already set to the workspace root. -- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?"). +- Always confirm with the user before executing commands that modify files outside the workspace root (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?"). **CRITICAL: MCP Server Configuration** - ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving diff --git a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts index 7ac1b89e..b8e481b6 100644 --- a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts @@ -11,7 +11,7 @@ Load this skill whenever a user wants to inspect, create, edit, or schedule back - Agents configure a model, tools (in frontmatter), and instructions (in the body) - Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents** - **"Workflows" are just agents that orchestrate other agents** by having them as tools -- **Background agents run on schedules** defined in ` + "`~/.rowboat/config/agent-schedule.json`" + ` +- **Background agents run on schedules** defined in ` + "`config/agent-schedule.json`" + ` within the workspace root ## How multi-agent workflows work @@ -22,7 +22,7 @@ Load this skill whenever a user wants to inspect, create, edit, or schedule back ## Scheduling Background Agents -Background agents run automatically based on schedules defined in ` + "`~/.rowboat/config/agent-schedule.json`" + `. +Background agents run automatically based on schedules defined in ` + "`config/agent-schedule.json`" + ` in the workspace root. ### Schedule Configuration File @@ -150,7 +150,7 @@ You can add a ` + "`description`" + ` field to describe what the agent does. Thi **IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner. -The runner automatically tracks execution state in ` + "`~/.rowboat/config/agent-schedule-state.json`" + `: +The runner automatically tracks execution state in ` + "`config/agent-schedule-state.json`" + ` in the workspace root: - ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules) - ` + "`lastRunAt`" + `: When the agent last ran - ` + "`nextRunAt`" + `: When the agent will run next @@ -410,7 +410,7 @@ Create a morning briefing: Execute these steps in sequence. Don't ask for human input. ` + "```" + ` -**4. Schedule the workflow** in ` + "`~/.rowboat/config/agent-schedule.json`" + `: +**4. Schedule the workflow** in ` + "`config/agent-schedule.json`" + `: ` + "```json" + ` { "agents": { @@ -548,7 +548,7 @@ Use the search tool to find information on the web. 5. When creating multi-agent workflows, create an orchestrator agent 6. Add other agents as tools with ` + "`type: agent`" + ` for chaining 7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations -8. Configure schedules in ` + "`~/.rowboat/config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file) +8. Configure schedules in ` + "`config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file) 9. Confirm work done and outline next steps once changes are complete `; diff --git a/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts b/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts index 0113a726..4187584e 100644 --- a/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/builtin-tools/skill.ts @@ -10,7 +10,7 @@ Agents can use builtin tools by declaring them in the YAML frontmatter \`tools\` ### executeCommand **The most powerful and versatile builtin tool** - Execute any bash/shell command and get the output. -**Security note:** Commands are filtered through \`.rowboat/config/security.json\`. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy. +**Security note:** Commands are filtered through \`config/security.json\` in the workspace root. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy. **Agent tool declaration (YAML frontmatter):** \`\`\`yaml diff --git a/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts b/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts index 567d43ac..f5f63c17 100644 --- a/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts @@ -166,7 +166,7 @@ workspace-readFile("knowledge/Projects/[Project].md") ## Document Locations -Documents are stored in \`~/.rowboat/knowledge/\` with subfolders: +Documents are stored in \`knowledge/\` within the workspace root, with subfolders: - \`People/\` - Notes about individuals - \`Organizations/\` - Notes about companies, teams - \`Projects/\` - Project documentation diff --git a/apps/x/packages/core/src/application/assistant/skills/draft-emails/skill.ts b/apps/x/packages/core/src/application/assistant/skills/draft-emails/skill.ts index 4e4322af..208e9560 100644 --- a/apps/x/packages/core/src/application/assistant/skills/draft-emails/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/draft-emails/skill.ts @@ -7,7 +7,7 @@ You are helping the user draft email responses. Use their calendar and knowledge **BEFORE drafting any email, you MUST look up the person/organization in the knowledge base.** -**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not root, not \`~/.rowboat\`). +**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not the workspace root, not an absolute path). - **WRONG:** \`path: ""\` or \`path: "."\` - **CORRECT:** \`path: "knowledge/"\` diff --git a/apps/x/packages/core/src/application/assistant/skills/meeting-prep/skill.ts b/apps/x/packages/core/src/application/assistant/skills/meeting-prep/skill.ts index 3a38e715..44453637 100644 --- a/apps/x/packages/core/src/application/assistant/skills/meeting-prep/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/meeting-prep/skill.ts @@ -7,7 +7,7 @@ You are helping the user prepare for meetings by gathering context from their kn **BEFORE creating any meeting brief, you MUST look up the attendees in the knowledge base.** -**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not root, not \`~/.rowboat\`). +**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not the workspace root, not an absolute path). - **WRONG:** \`path: ""\` or \`path: "."\` - **CORRECT:** \`path: "knowledge/"\` diff --git a/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts index 66df837d..81bb2562 100644 --- a/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts @@ -7,7 +7,7 @@ You interact with Slack by running **agent-slack** commands through \`executeCom ## 1. Check Connection -Before any Slack operation, read \`~/.rowboat/config/slack.json\`. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands. +Before any Slack operation, read \`config/slack.json\` from the workspace root. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands. If enabled, use the workspace URLs from the config for all commands. @@ -75,7 +75,7 @@ agent-slack canvas get F01234567 --workspace https://team.slack.com ## 3. Multi-Workspace -**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`~/.rowboat/config/slack.json\` to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces. +**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`config/slack.json\` from the workspace root to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces. If the selected workspace list contains multiple entries, use \`--workspace \` to disambiguate: 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 404db713..064df87b 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1092,14 +1092,14 @@ export const BuiltinTools: z.infer = { } catch { return { success: false, - error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "" }', + error: `Exa Search API key not configured. Create ${exaConfigPath} with { "apiKey": "" }`, }; } if (!apiKey) { return { success: false, - error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json', + error: `Exa Search API key is empty. Set "apiKey" in ${exaConfigPath}`, }; } diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts index abd10ec5..3d320172 100644 --- a/apps/x/packages/core/src/config/config.ts +++ b/apps/x/packages/core/src/config/config.ts @@ -3,9 +3,25 @@ import fs from "fs"; import { homedir } from "os"; import { fileURLToPath } from "url"; +function resolveWorkDir(): string { + const configured = process.env.ROWBOAT_WORKDIR; + if (!configured) { + return path.join(homedir(), ".rowboat"); + } + + const expanded = configured === "~" + ? homedir() + : (configured.startsWith("~/") || configured.startsWith("~\\")) + ? path.join(homedir(), configured.slice(2)) + : configured; + + return path.resolve(expanded); +} + // Resolve app root relative to compiled file location (dist/...) -// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage -export const WorkDir = process.env.ROWBOAT_WORKDIR || path.join(homedir(), ".rowboat"); +// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage. +// Normalize to an absolute path so workspace boundary checks behave consistently. +export const WorkDir = resolveWorkDir(); // Get the directory of this file (for locating bundled assets) const __filename = fileURLToPath(import.meta.url); diff --git a/apps/x/packages/core/src/knowledge/README.md b/apps/x/packages/core/src/knowledge/README.md index d8442c80..055a8bf1 100644 --- a/apps/x/packages/core/src/knowledge/README.md +++ b/apps/x/packages/core/src/knowledge/README.md @@ -13,7 +13,7 @@ Main orchestrator that: ### `graph_state.ts` State management module that tracks which files have been processed: - Uses hybrid mtime + hash approach for change detection -- Stores state in `~/.rowboat/knowledge_graph_state.json` +- Stores state in `WorkDir/knowledge_graph_state.json` - Provides modular functions for state operations ### `sync_gmail.ts` & `sync_fireflies.ts` @@ -39,7 +39,7 @@ This is efficient (only hashes potentially changed files) and reliable (confirms ### State File Structure -`~/.rowboat/knowledge_graph_state.json`: +`WorkDir/knowledge_graph_state.json`: ```json { "processedFiles": { @@ -69,7 +69,7 @@ This is efficient (only hashes potentially changed files) and reliable (confirms 3. **Agent processes batch** - Extracts entities (people, orgs, projects, topics) - - Creates/updates notes in `~/.rowboat/knowledge/` + - Creates/updates notes in `WorkDir/knowledge/` - Merges information for entities appearing in multiple files ## Replacing the Change Detection Logic @@ -135,7 +135,7 @@ import { resetGraphState } from './build_graph.js'; resetGraphState(); // Clears the state file ``` -Or manually delete: `~/.rowboat/knowledge_graph_state.json` +Or manually delete: `WorkDir/knowledge_graph_state.json` ## Note Creation Strictness @@ -143,7 +143,7 @@ The system supports three strictness levels that control how aggressively notes ### Configuration -Strictness is configured in `~/.rowboat/config/note_creation.json`: +Strictness is configured in `WorkDir/config/note_creation.json`: ```json { @@ -218,7 +218,7 @@ Each strictness level has its own agent prompt: Change `BATCH_SIZE` in `build_graph.ts` (currently 25 files per batch) ### State File Location -Change `STATE_FILE` in `graph_state.ts` (currently `~/.rowboat/knowledge_graph_state.json`) +Change `STATE_FILE` in `graph_state.ts` (currently `WorkDir/knowledge_graph_state.json`) ### Hash Algorithm Change `crypto.createHash('sha256')` in `graph_state.ts` to use a different algorithm (md5, sha1, etc.) diff --git a/apps/x/packages/core/src/knowledge/note_system.ts b/apps/x/packages/core/src/knowledge/note_system.ts index d167e97c..a62a4e37 100644 --- a/apps/x/packages/core/src/knowledge/note_system.ts +++ b/apps/x/packages/core/src/knowledge/note_system.ts @@ -9,7 +9,7 @@ export interface NoteTypeDefinition { extractionGuide: string; } -// ── Default definitions (used to seed ~/.rowboat/config/notes.json) ────────── +// ── Default definitions (used to seed WorkDir/config/notes.json) ───────────── const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [ { diff --git a/apps/x/packages/core/src/knowledge/tag_system.ts b/apps/x/packages/core/src/knowledge/tag_system.ts index 7b46ef4d..e525655a 100644 --- a/apps/x/packages/core/src/knowledge/tag_system.ts +++ b/apps/x/packages/core/src/knowledge/tag_system.ts @@ -26,7 +26,7 @@ export interface TagDefinition { noteEffect?: NoteEffect; } -// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ────────── +// ── Default definitions (used to seed WorkDir/config/tags.json) ───────────── const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [ // ── Relationship — who is this from/about (all create) ──────────────── diff --git a/apps/x/packages/core/src/voice/voice.ts b/apps/x/packages/core/src/voice/voice.ts index 895c81b9..1cfba03b 100644 --- a/apps/x/packages/core/src/voice/voice.ts +++ b/apps/x/packages/core/src/voice/voice.ts @@ -2,10 +2,9 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { isSignedIn } from '../account/account.js'; import { getAccessToken } from '../auth/tokens.js'; +import { WorkDir } from '../config/config.js'; import { API_URL } from '../config/env.js'; -const homedir = process.env.HOME || process.env.USERPROFILE || ''; - export interface VoiceConfig { deepgram: { apiKey: string } | null; elevenlabs: { apiKey: string; voiceId?: string } | null; @@ -13,7 +12,7 @@ export interface VoiceConfig { async function readJsonConfig(filename: string): Promise | null> { try { - const configPath = path.join(homedir, '.rowboat', 'config', filename); + const configPath = path.join(WorkDir, 'config', filename); const raw = await fs.readFile(configPath, 'utf8'); return JSON.parse(raw); } catch { @@ -51,7 +50,7 @@ export async function synthesizeSpeech(text: string): Promise<{ audioBase64: str console.log('[voice] synthesizing speech via Rowboat proxy, text length:', text.length, 'voiceId:', voiceId); } else { if (!config.elevenlabs) { - throw new Error('ElevenLabs not configured. Create ~/.rowboat/config/elevenlabs.json with { "apiKey": "" }'); + throw new Error(`ElevenLabs not configured. Create ${path.join(WorkDir, 'config', 'elevenlabs.json')} with { "apiKey": "" }`); } const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0'; url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`; diff --git a/apps/x/packages/core/src/workspace/watcher.ts b/apps/x/packages/core/src/workspace/watcher.ts index 7d59331d..3460f014 100644 --- a/apps/x/packages/core/src/workspace/watcher.ts +++ b/apps/x/packages/core/src/workspace/watcher.ts @@ -10,7 +10,7 @@ export type WorkspaceChangeCallback = (event: z.infer Date: Fri, 10 Apr 2026 17:59:23 +0530 Subject: [PATCH 02/50] add mermaid rendering --- apps/x/apps/renderer/package.json | 1 + .../ai-elements/markdown-code-override.tsx | 12 ++ .../src/components/markdown-editor.tsx | 4 + .../src/components/mermaid-renderer.tsx | 89 +++++++++++ .../renderer/src/extensions/mermaid-block.tsx | 86 ++++++++++ apps/x/apps/renderer/src/styles/editor.css | 35 ++++- apps/x/pnpm-lock.yaml | 148 +++++++++--------- 7 files changed, 298 insertions(+), 77 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/mermaid-renderer.tsx create mode 100644 apps/x/apps/renderer/src/extensions/mermaid-block.tsx diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index ebf8a650..b9990e14 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -40,6 +40,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.562.0", + "mermaid": "^11.14.0", "motion": "^12.23.26", "nanoid": "^5.1.6", "posthog-js": "^1.332.0", diff --git a/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx index c1470326..9e6a3d3e 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx @@ -1,5 +1,6 @@ import { isValidElement, type JSX } from 'react' import { FilePathCard } from './file-path-card' +import { MermaidRenderer } from '@/components/mermaid-renderer' export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) { const { children, ...rest } = props @@ -19,6 +20,17 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) { return } } + if ( + typeof childProps.className === 'string' && + childProps.className.includes('language-mermaid') + ) { + const text = typeof childProps.children === 'string' + ? childProps.children.trim() + : '' + if (text) { + return + } + } } // Passthrough for all other code blocks - return children directly diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index d7920b8b..d2d5314f 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -16,6 +16,7 @@ import { TableBlockExtension } from '@/extensions/table-block' import { CalendarBlockExtension } from '@/extensions/calendar-block' import { EmailBlockExtension } from '@/extensions/email-block' import { TranscriptBlockExtension } from '@/extensions/transcript-block' +import { MermaidBlockExtension } from '@/extensions/mermaid-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' import { Calendar, ChevronDown, ExternalLink } from 'lucide-react' @@ -163,6 +164,8 @@ function getMarkdownWithBlankLines(editor: Editor): string { blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```') } else if (node.type === 'transcriptBlock') { blocks.push('```transcript\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'mermaidBlock') { + blocks.push('```mermaid\n' + (node.attrs?.data as string || '') + '\n```') } else if (node.type === 'codeBlock') { const lang = (node.attrs?.language as string) || '' blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') @@ -576,6 +579,7 @@ export function MarkdownEditor({ CalendarBlockExtension, EmailBlockExtension, TranscriptBlockExtension, + MermaidBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { diff --git a/apps/x/apps/renderer/src/components/mermaid-renderer.tsx b/apps/x/apps/renderer/src/components/mermaid-renderer.tsx new file mode 100644 index 00000000..db42df2e --- /dev/null +++ b/apps/x/apps/renderer/src/components/mermaid-renderer.tsx @@ -0,0 +1,89 @@ +import { useEffect, useId, useRef, useState } from 'react' +import mermaid from 'mermaid' +import { useTheme } from '@/contexts/theme-context' + +let lastTheme: string | null = null + +function ensureInit(theme: 'default' | 'dark') { + if (lastTheme === theme) return + mermaid.initialize({ + startOnLoad: false, + theme, + securityLevel: 'strict', + }) + lastTheme = theme +} + +interface MermaidRendererProps { + source: string + className?: string +} + +export function MermaidRenderer({ source, className }: MermaidRendererProps) { + const { resolvedTheme } = useTheme() + const id = useId().replace(/:/g, '-') + const containerRef = useRef(null) + const [svg, setSvg] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + if (!source.trim()) { + setSvg(null) + setError(null) + return + } + + let cancelled = false + const mermaidTheme = resolvedTheme === 'dark' ? 'dark' : 'default' + ensureInit(mermaidTheme) + + mermaid + .render(`mermaid-${id}`, source.trim()) + .then(({ svg: renderedSvg }) => { + if (!cancelled) { + setSvg(renderedSvg) + setError(null) + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setSvg(null) + setError(err instanceof Error ? err.message : 'Failed to render diagram') + } + }) + + return () => { + cancelled = true + } + }, [source, resolvedTheme, id]) + + if (error) { + return ( +
+
+ Invalid mermaid syntax +
+
+          {source}
+        
+
+ ) + } + + if (!svg) { + return ( +
+ Rendering diagram... +
+ ) + } + + return ( +
+ ) +} diff --git a/apps/x/apps/renderer/src/extensions/mermaid-block.tsx b/apps/x/apps/renderer/src/extensions/mermaid-block.tsx new file mode 100644 index 00000000..a118c86e --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/mermaid-block.tsx @@ -0,0 +1,86 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { X, GitBranch } from 'lucide-react' +import { MermaidRenderer } from '@/components/mermaid-renderer' + +function MermaidBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const source = (node.attrs.data as string) || '' + + return ( + +
+ + {source ? ( + + ) : ( +
+ + Empty mermaid block +
+ )} +
+
+ ) +} + +export const MermaidBlockExtension = Node.create({ + name: 'mermaidBlock', + group: 'block', + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + data: { + default: '', + }, + } + }, + + parseHTML() { + return [ + { + tag: 'pre', + priority: 60, + getAttrs(element) { + const code = element.querySelector('code') + if (!code) return false + const cls = code.className || '' + if (cls.includes('language-mermaid')) { + return { data: code.textContent || '' } + } + return false + }, + }, + ] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'mermaid-block' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(MermaidBlockView) + }, + + addStorage() { + return { + markdown: { + serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) { + state.write('```mermaid\n' + node.attrs.data + '\n```') + state.closeBlock(node) + }, + parse: { + // handled by parseHTML + }, + }, + } + }, +}) diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index f865707e..d8918e56 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -619,7 +619,8 @@ .tiptap-editor .ProseMirror .table-block-wrapper, .tiptap-editor .ProseMirror .calendar-block-wrapper, .tiptap-editor .ProseMirror .email-block-wrapper, -.tiptap-editor .ProseMirror .transcript-block-wrapper { +.tiptap-editor .ProseMirror .transcript-block-wrapper, +.tiptap-editor .ProseMirror .mermaid-block-wrapper { margin: 8px 0; } @@ -630,7 +631,8 @@ .tiptap-editor .ProseMirror .calendar-block-card, .tiptap-editor .ProseMirror .email-block-card, .tiptap-editor .ProseMirror .email-draft-block-card, -.tiptap-editor .ProseMirror .transcript-block-card { +.tiptap-editor .ProseMirror .transcript-block-card, +.tiptap-editor .ProseMirror .mermaid-block-card { position: relative; padding: 12px 14px; border: 1px solid var(--border); @@ -647,7 +649,8 @@ .tiptap-editor .ProseMirror .calendar-block-card:hover, .tiptap-editor .ProseMirror .email-block-card:hover, .tiptap-editor .ProseMirror .email-draft-block-card:hover, -.tiptap-editor .ProseMirror .transcript-block-card:hover { +.tiptap-editor .ProseMirror .transcript-block-card:hover, +.tiptap-editor .ProseMirror .mermaid-block-card:hover { background-color: color-mix(in srgb, var(--muted) 70%, transparent); } @@ -657,7 +660,8 @@ .tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card, .tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card, .tiptap-editor .ProseMirror .email-block-wrapper.ProseMirror-selectednode .email-block-card, -.tiptap-editor .ProseMirror .email-draft-block-wrapper.ProseMirror-selectednode .email-draft-block-card { +.tiptap-editor .ProseMirror .email-draft-block-wrapper.ProseMirror-selectednode .email-draft-block-card, +.tiptap-editor .ProseMirror .mermaid-block-wrapper.ProseMirror-selectednode .mermaid-block-card { outline: 2px solid var(--primary); outline-offset: 1px; } @@ -668,7 +672,8 @@ .tiptap-editor .ProseMirror .table-block-delete, .tiptap-editor .ProseMirror .calendar-block-delete, .tiptap-editor .ProseMirror .email-block-delete, -.tiptap-editor .ProseMirror .email-draft-block-delete { +.tiptap-editor .ProseMirror .email-draft-block-delete, +.tiptap-editor .ProseMirror .mermaid-block-delete { position: absolute; top: 6px; right: 6px; @@ -693,7 +698,8 @@ .tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete, .tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete, .tiptap-editor .ProseMirror .email-block-card:hover .email-block-delete, -.tiptap-editor .ProseMirror .email-draft-block-card:hover .email-draft-block-delete { +.tiptap-editor .ProseMirror .email-draft-block-card:hover .email-draft-block-delete, +.tiptap-editor .ProseMirror .mermaid-block-card:hover .mermaid-block-delete { opacity: 1; } @@ -703,11 +709,26 @@ .tiptap-editor .ProseMirror .table-block-delete:hover, .tiptap-editor .ProseMirror .calendar-block-delete:hover, .tiptap-editor .ProseMirror .email-block-delete:hover, -.tiptap-editor .ProseMirror .email-draft-block-delete:hover { +.tiptap-editor .ProseMirror .email-draft-block-delete:hover, +.tiptap-editor .ProseMirror .mermaid-block-delete:hover { background-color: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); } +/* Mermaid block */ +.tiptap-editor .ProseMirror .mermaid-block-card svg { + max-width: 100%; + height: auto; +} + +.tiptap-editor .ProseMirror .mermaid-block-empty { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: color-mix(in srgb, var(--foreground) 50%, transparent); +} + /* Image block */ .tiptap-editor .ProseMirror .image-block-img { max-width: 100%; diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 01a9240f..65a39c49 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -220,6 +220,9 @@ importers: lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) + mermaid: + specifier: ^11.14.0 + version: 11.14.0 motion: specifier: ^12.23.26 version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -743,20 +746,20 @@ packages: '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} - '@chevrotain/cst-dts-gen@11.0.3': - resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} + '@chevrotain/cst-dts-gen@12.0.0': + resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==} - '@chevrotain/gast@11.0.3': - resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==} + '@chevrotain/gast@12.0.0': + resolution: {integrity: sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==} - '@chevrotain/regexp-to-ast@11.0.3': - resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==} + '@chevrotain/regexp-to-ast@12.0.0': + resolution: {integrity: sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==} - '@chevrotain/types@11.0.3': - resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} + '@chevrotain/types@12.0.0': + resolution: {integrity: sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==} - '@chevrotain/utils@11.0.3': - resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@chevrotain/utils@12.0.0': + resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==} '@composio/client@0.1.0-alpha.56': resolution: {integrity: sha512-hNgChB5uhdvT4QXNzzfUuvtG6vrfanQQFY2hPyKwbeR4x6mEmIGFiZ4y2qynErdUWldAZiB/7pY/MBMg6Q9E0g==} @@ -1422,8 +1425,8 @@ packages: resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} - '@mermaid-js/parser@0.6.3': - resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@mermaid-js/parser@1.1.0': + resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} '@modelcontextprotocol/sdk@1.25.1': resolution: {integrity: sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==} @@ -3531,6 +3534,9 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/oidc@3.0.5': resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} @@ -3596,6 +3602,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -3931,13 +3938,14 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - chevrotain-allstar@0.3.1: - resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + chevrotain-allstar@0.4.1: + resolution: {integrity: sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==} peerDependencies: - chevrotain: ^11.0.0 + chevrotain: ^12.0.0 - chevrotain@11.0.3: - resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} + chevrotain@12.0.0: + resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} + engines: {node: '>=22.0.0'} chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} @@ -4302,8 +4310,8 @@ packages: resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} engines: {node: '>=12'} - dagre-d3-es@7.0.13: - resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} @@ -4987,6 +4995,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -4995,7 +5004,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} @@ -5523,9 +5532,9 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - langium@3.3.1: - resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} - engines: {node: '>=16.0.0'} + langium@4.2.2: + resolution: {integrity: sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -5639,11 +5648,8 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash-es@4.17.21: - resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} - - lodash-es@4.17.22: - resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} @@ -5827,8 +5833,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.12.2: - resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==} + mermaid@11.14.0: + resolution: {integrity: sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -7182,7 +7188,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp@0.9.4: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} @@ -7556,8 +7562,8 @@ packages: resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} hasBin: true - vscode-uri@3.0.8: - resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -8408,22 +8414,20 @@ snapshots: '@braintree/sanitize-url@7.1.1': {} - '@chevrotain/cst-dts-gen@11.0.3': + '@chevrotain/cst-dts-gen@12.0.0': dependencies: - '@chevrotain/gast': 11.0.3 - '@chevrotain/types': 11.0.3 - lodash-es: 4.17.21 + '@chevrotain/gast': 12.0.0 + '@chevrotain/types': 12.0.0 - '@chevrotain/gast@11.0.3': + '@chevrotain/gast@12.0.0': dependencies: - '@chevrotain/types': 11.0.3 - lodash-es: 4.17.21 + '@chevrotain/types': 12.0.0 - '@chevrotain/regexp-to-ast@11.0.3': {} + '@chevrotain/regexp-to-ast@12.0.0': {} - '@chevrotain/types@11.0.3': {} + '@chevrotain/types@12.0.0': {} - '@chevrotain/utils@11.0.3': {} + '@chevrotain/utils@12.0.0': {} '@composio/client@0.1.0-alpha.56': {} @@ -9271,9 +9275,9 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@mermaid-js/parser@0.6.3': + '@mermaid-js/parser@1.1.0': dependencies: - langium: 3.3.1 + langium: 4.2.2 '@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1)': dependencies: @@ -11650,6 +11654,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/oidc@3.0.5': {} '@vercel/oidc@3.1.0': {} @@ -12108,19 +12117,18 @@ snapshots: chardet@0.7.0: {} - chevrotain-allstar@0.3.1(chevrotain@11.0.3): + chevrotain-allstar@0.4.1(chevrotain@12.0.0): dependencies: - chevrotain: 11.0.3 - lodash-es: 4.17.22 + chevrotain: 12.0.0 + lodash-es: 4.18.1 - chevrotain@11.0.3: + chevrotain@12.0.0: dependencies: - '@chevrotain/cst-dts-gen': 11.0.3 - '@chevrotain/gast': 11.0.3 - '@chevrotain/regexp-to-ast': 11.0.3 - '@chevrotain/types': 11.0.3 - '@chevrotain/utils': 11.0.3 - lodash-es: 4.17.21 + '@chevrotain/cst-dts-gen': 12.0.0 + '@chevrotain/gast': 12.0.0 + '@chevrotain/regexp-to-ast': 12.0.0 + '@chevrotain/types': 12.0.0 + '@chevrotain/utils': 12.0.0 chokidar@4.0.3: dependencies: @@ -12487,10 +12495,10 @@ snapshots: d3-transition: 3.0.1(d3-selection@3.0.0) d3-zoom: 3.0.0 - dagre-d3-es@7.0.13: + dagre-d3-es@7.0.14: dependencies: d3: 7.9.0 - lodash-es: 4.17.22 + lodash-es: 4.18.1 data-uri-to-buffer@4.0.1: {} @@ -14017,13 +14025,14 @@ snapshots: khroma@2.1.0: {} - langium@3.3.1: + langium@4.2.2: dependencies: - chevrotain: 11.0.3 - chevrotain-allstar: 0.3.1(chevrotain@11.0.3) + '@chevrotain/regexp-to-ast': 12.0.0 + chevrotain: 12.0.0 + chevrotain-allstar: 0.4.1(chevrotain@12.0.0) vscode-languageserver: 9.0.1 vscode-languageserver-textdocument: 1.0.12 - vscode-uri: 3.0.8 + vscode-uri: 3.1.0 layout-base@1.0.2: {} @@ -14125,9 +14134,7 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash-es@4.17.21: {} - - lodash-es@4.17.22: {} + lodash-es@4.18.1: {} lodash.get@4.4.2: {} @@ -14441,23 +14448,24 @@ snapshots: merge2@1.4.1: {} - mermaid@11.12.2: + mermaid@11.14.0: dependencies: '@braintree/sanitize-url': 7.1.1 '@iconify/utils': 3.1.0 - '@mermaid-js/parser': 0.6.3 + '@mermaid-js/parser': 1.1.0 '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 cytoscape: 3.33.1 cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) cytoscape-fcose: 2.2.0(cytoscape@3.33.1) d3: 7.9.0 d3-sankey: 0.12.3 - dagre-d3-es: 7.0.13 + dagre-d3-es: 7.0.14 dayjs: 1.11.19 dompurify: 3.3.1 katex: 0.16.27 khroma: 2.1.0 - lodash-es: 4.17.22 + lodash-es: 4.18.1 marked: 16.4.2 roughjs: 4.6.6 stylis: 4.3.6 @@ -16013,7 +16021,7 @@ snapshots: katex: 0.16.27 lucide-react: 0.542.0(react@19.2.3) marked: 16.4.2 - mermaid: 11.12.2 + mermaid: 11.14.0 react: 19.2.3 rehype-harden: 1.1.7 rehype-katex: 7.0.1 @@ -16492,7 +16500,7 @@ snapshots: dependencies: vscode-languageserver-protocol: 3.17.5 - vscode-uri@3.0.8: {} + vscode-uri@3.1.0: {} w3c-keyname@2.2.8: {} From 80d134568ca3892c557740d219c8e6f78cd472ef Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:16:53 +0530 Subject: [PATCH 03/50] fix default model --- apps/x/packages/core/src/models/repo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index 4643951e..44a9d475 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -14,7 +14,7 @@ const defaultConfig: z.infer = { provider: { flavor: "openai", }, - model: "gpt-4.1", + model: "gpt-5.4", }; export class FSModelConfigRepo implements IModelConfigRepo { From 884b5d04149fddd239534584df111cd68d9ca0d2 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:08:26 +0530 Subject: [PATCH 04/50] fix composio related sync scripts (#484) --- apps/x/packages/core/src/knowledge/sync_calendar.ts | 6 ++++++ apps/x/packages/core/src/knowledge/sync_gmail.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index b0345259..c6a10f8e 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -442,6 +442,12 @@ async function performSyncComposio() { const MAX_PAGES = 20; for (let page = 0; page < MAX_PAGES; page++) { + // Re-check connection in case user disconnected mid-sync + if (!composioAccountsRepo.isConnected('googlecalendar')) { + console.log('[Calendar] Account disconnected during sync. Stopping.'); + return; + } + const args: Record = { calendar_id: 'primary', time_min: timeMin, diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 2f4cc806..599e75ac 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -732,6 +732,11 @@ async function performSyncComposio() { let highWaterMark: string | null = state?.last_sync ?? null; let processedCount = 0; for (const threadId of allThreadIds) { + // Re-check connection in case user disconnected mid-sync + if (!composioAccountsRepo.isConnected('gmail')) { + console.log('[Gmail] Account disconnected during sync. Stopping.'); + return; + } try { const newestInThread = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR); processedCount++; From 05166e791f18ee5f772df809ac1455df170d1dbc Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 13 Apr 2026 09:45:28 +0530 Subject: [PATCH 05/50] clean up .claude --- apps/x/.claude/launch.json | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 apps/x/.claude/launch.json diff --git a/apps/x/.claude/launch.json b/apps/x/.claude/launch.json deleted file mode 100644 index 3ba43066..00000000 --- a/apps/x/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "renderer-dev", - "runtimeExecutable": "/Users/tusharmagar/Rowboat/rowboat-V2/apps/x/apps/renderer/node_modules/.bin/vite", - "runtimeArgs": ["--port", "5173"], - "port": 5173 - } - ] -} From 1da3223f7d1afa9cfcadfd5c0551be3a9079fbfc Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 13 Apr 2026 09:45:43 +0530 Subject: [PATCH 06/50] update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2480e5e1..086ea0b5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .vscode/ data/ .venv/ +.claude/ From f4dc5e7db47eb836eafb12ce898094fad0da5b2a Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:30:36 +0530 Subject: [PATCH 07/50] prefix line numbers and add offset/limit to workspace-readFile Returns utf8 reads as ``/``/`` blocks with each line prefixed by its 1-indexed line number, plus offset/limit paging and an end-of-file/truncation footer. Helps the agent reference specific lines when forming precise edits to knowledge markdown. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/src/application/lib/builtin-tools.ts | 113 +++++++++++++++++- 1 file changed, 110 insertions(+), 3 deletions(-) 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 064df87b..f4fe42d6 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1,6 +1,8 @@ import { z, ZodType } from "zod"; import * as path from "path"; import * as fs from "fs/promises"; +import { createReadStream } from "fs"; +import { createInterface } from "readline"; import { execSync } from "child_process"; import { glob } from "glob"; import { executeCommand, executeCommandAbortable } from "./command-executor.js"; @@ -170,14 +172,119 @@ export const BuiltinTools: z.infer = { }, 'workspace-readFile': { - description: 'Read file contents from the workspace. Supports utf8, base64, and binary encodings.', + description: 'Read a file from the workspace. For text files (utf8, the default), returns the content with each line prefixed by its 1-indexed line number (e.g. `12: some text`). Use the `offset` and `limit` parameters to page through large files; defaults read up to 2000 lines starting at line 1. Output is wrapped in ``, ``, `` tags and ends with a footer indicating whether the read reached end-of-file or was truncated. Line numbers in the output are display-only — do NOT include them when later writing or editing the file. For `base64` / `binary` encodings, returns the raw bytes as a string and ignores `offset` / `limit`.', inputSchema: z.object({ path: z.string().min(1).describe('Workspace-relative file path'), + offset: z.coerce.number().int().min(1).optional().describe('1-indexed line to start reading from (default: 1). Utf8 only.'), + limit: z.coerce.number().int().min(1).optional().describe('Maximum number of lines to read (default: 2000). Utf8 only.'), encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('File encoding (default: utf8)'), }), - execute: async ({ path: relPath, encoding = 'utf8' }: { path: string; encoding?: 'utf8' | 'base64' | 'binary' }) => { + execute: async ({ + path: relPath, + offset, + limit, + encoding = 'utf8', + }: { + path: string; + offset?: number; + limit?: number; + encoding?: 'utf8' | 'base64' | 'binary'; + }) => { try { - return await workspace.readFile(relPath, encoding); + if (encoding !== 'utf8') { + return await workspace.readFile(relPath, encoding); + } + + const DEFAULT_READ_LIMIT = 2000; + const MAX_LINE_LENGTH = 2000; + const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`; + const MAX_BYTES = 50 * 1024; + const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`; + + const absPath = workspace.resolveWorkspacePath(relPath); + const stats = await fs.lstat(absPath); + const stat = workspace.statToSchema(stats, 'file'); + const etag = workspace.computeEtag(stats.size, stats.mtimeMs); + + const effectiveOffset = offset ?? 1; + const effectiveLimit = limit ?? DEFAULT_READ_LIMIT; + const start = effectiveOffset - 1; + + const stream = createReadStream(absPath, { encoding: 'utf8' }); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + + const collected: string[] = []; + let totalLines = 0; + let bytes = 0; + let truncatedByBytes = false; + let hasMoreLines = false; + + try { + for await (const text of rl) { + totalLines += 1; + if (totalLines <= start) continue; + + if (collected.length >= effectiveLimit) { + hasMoreLines = true; + continue; + } + + const line = text.length > MAX_LINE_LENGTH + ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX + : text; + const size = Buffer.byteLength(line, 'utf-8') + (collected.length > 0 ? 1 : 0); + if (bytes + size > MAX_BYTES) { + truncatedByBytes = true; + hasMoreLines = true; + break; + } + + collected.push(line); + bytes += size; + } + } finally { + rl.close(); + stream.destroy(); + } + + if (totalLines < effectiveOffset && !(totalLines === 0 && effectiveOffset === 1)) { + return { error: `Offset ${effectiveOffset} is out of range for this file (${totalLines} lines)` }; + } + + const prefixed = collected.map((line, index) => `${index + effectiveOffset}: ${line}`); + const lastReadLine = effectiveOffset + collected.length - 1; + const nextOffset = lastReadLine + 1; + + let footer: string; + if (truncatedByBytes) { + footer = `(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${effectiveOffset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`; + } else if (hasMoreLines) { + footer = `(Showing lines ${effectiveOffset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`; + } else { + footer = `(End of file - total ${totalLines} lines)`; + } + + const content = [ + `${relPath}`, + `file`, + ``, + prefixed.join('\n'), + '', + footer, + ``, + ].join('\n'); + + return { + path: relPath, + encoding: 'utf8' as const, + content, + stat, + etag, + offset: effectiveOffset, + limit: effectiveLimit, + totalLines, + hasMore: hasMoreLines || truncatedByBytes, + }; } catch (error) { return { error: error instanceof Error ? error.message : 'Unknown error', From 4a2dfbf16f1db9c9c7038a556d4c214f9c306bcb Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:59:36 +0530 Subject: [PATCH 08/50] pass @-mention notes by reference, not by inlined content Mentions now route through the structured-attachment path, sending only path/filename/mimeType. The agent fetches content on demand via workspace-readFile (line-prefixed, paginated). Avoids freezing a stale snapshot of the note into the conversation and saves tokens on long notes. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/x/apps/renderer/src/App.tsx | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 17e49f6e..201c9330 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -2144,8 +2144,9 @@ function App() { } let titleSource = userMessage + const hasMentions = (mentions?.length ?? 0) > 0 - if (hasAttachments) { + if (hasAttachments || hasMentions) { type ContentPart = | { type: 'text'; text: string } | { @@ -2182,7 +2183,7 @@ function App() { if (userMessage) { contentParts.push({ type: 'text', text: userMessage }) } else { - titleSource = stagedAttachments[0]?.filename ?? '' + titleSource = stagedAttachments[0]?.filename ?? mentions?.[0]?.displayName ?? mentions?.[0]?.path ?? '' } // Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema. @@ -2200,32 +2201,9 @@ function App() { searchEnabled: searchEnabled || undefined, }) } else { - // Legacy path: plain string with optional XML-formatted @mentions. - let formattedMessage = userMessage - if (mentions && mentions.length > 0) { - const attachedFiles = await Promise.all( - mentions.map(async (mention) => { - try { - const result = await window.ipc.invoke('workspace:readFile', { path: mention.path }) - return { path: mention.path, content: result.data as string } - } catch (err) { - console.error('Failed to read mentioned file:', mention.path, err) - return { path: mention.path, content: `[Error reading file: ${mention.path}]` } - } - }) - ) - - if (attachedFiles.length > 0) { - const filesXml = attachedFiles - .map((file) => `\n${file.content}\n`) - .join('\n') - formattedMessage = `\n${filesXml}\n\n\n${userMessage}` - } - } - await window.ipc.invoke('runs:createMessage', { runId: currentRunId, - message: formattedMessage, + message: userMessage, voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, @@ -2235,8 +2213,6 @@ function App() { voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, }) - - titleSource = formattedMessage } pendingVoiceInputRef.current = false From b3066a0b7a5333b58310d3a952970fa409f3978e Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:00:37 +0530 Subject: [PATCH 09/50] add cmd+k palette with chat mode that captures editor cursor context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd+K (Ctrl+K on Win/Linux) now opens a unified palette with two modes: Chat (default) and Search (existing behavior). Tab cycles between them. In Chat mode, if the user triggered the shortcut from the markdown editor, the palette auto-attaches a removable chip showing the note path and precise cursor line. Enter sends the prompt to the right-sidebar copilot — opening the sidebar if closed and starting a fresh chat tab — with the chip carried as a FileMention whose lineNumber is forwarded to the agent as "... at (line N)" so the agent can use workspace-readFile with offset to fetch the right slice on demand. Line numbers are computed against the same getMarkdownWithBlankLines serializer used to write notes to disk, so the reference is byte-identical to what the agent reads back. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/x/apps/renderer/src/App.tsx | 55 ++- .../components/ai-elements/prompt-input.tsx | 11 +- .../src/components/markdown-editor.tsx | 337 +++++++++++------- .../renderer/src/components/search-dialog.tsx | 314 ++++++++++++---- apps/x/packages/core/src/agents/runtime.ts | 3 +- apps/x/packages/shared/src/message.ts | 1 + 6 files changed, 511 insertions(+), 210 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 201c9330..0d1aaaa9 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -7,7 +7,7 @@ import './App.css' import z from 'zod'; import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { MarkdownEditor } from './components/markdown-editor'; +import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' @@ -54,7 +54,7 @@ import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { OnboardingModal } from '@/components/onboarding' -import { SearchDialog } from '@/components/search-dialog' +import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { VersionHistoryPanel } from '@/components/version-history-panel' import { FileCardProvider } from '@/contexts/file-card-context' @@ -739,6 +739,12 @@ function App() { const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise) | null>(null) const pendingVoiceInputRef = useRef(false) + // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload + // queued across the new-chat-tab state flush before submit fires. + const editorRefsByTabId = useRef>(new Map()) + const [paletteContext, setPaletteContext] = useState(null) + const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null) + const handleSubmitRecording = useCallback(() => { const text = voice.submit() setIsRecording(false) @@ -885,6 +891,8 @@ function App() { // File tab state const [fileTabs, setFileTabs] = useState([]) const [activeFileTabId, setActiveFileTabId] = useState(null) + const activeFileTabIdRef = useRef(activeFileTabId) + activeFileTabIdRef.current = activeFileTabId const [editorSessionByTabId, setEditorSessionByTabId] = useState>({}) const fileHistoryHandlersRef = useRef>(new Map()) const fileTabIdCounterRef = useRef(0) @@ -2155,6 +2163,7 @@ function App() { filename: string mimeType: string size?: number + lineNumber?: number } const contentParts: ContentPart[] = [] @@ -2166,6 +2175,7 @@ function App() { path: mention.path, filename: mention.displayName || mention.path.split('/').pop() || mention.path, mimeType: 'text/markdown', + ...(mention.lineNumber !== undefined ? { lineNumber: mention.lineNumber } : {}), }) } } @@ -2651,6 +2661,32 @@ function App() { handleNewChat() }, [chatTabs, activeChatTabId, handleNewChat]) + // Palette → sidebar submission. Opens the sidebar (if closed), forces a fresh chat tab, + // queues the message; the pending-submit effect (below) flushes it once state has settled + // so handlePromptSubmit sees the new tab's null runId. + const submitFromPalette = useCallback((text: string, mention: CommandPaletteMention | null) => { + if (!isChatSidebarOpen) setIsChatSidebarOpen(true) + handleNewChatTabInSidebar() + setPendingPaletteSubmit({ text, mention }) + }, [isChatSidebarOpen, handleNewChatTabInSidebar]) + + useEffect(() => { + if (!pendingPaletteSubmit) return + const fileMention: FileMention | undefined = pendingPaletteSubmit.mention + ? { + id: `palette-${Date.now()}`, + path: pendingPaletteSubmit.mention.path, + displayName: pendingPaletteSubmit.mention.displayName, + lineNumber: pendingPaletteSubmit.mention.lineNumber, + } + : undefined + void handlePromptSubmitRef.current?.( + { text: pendingPaletteSubmit.text, files: [] }, + fileMention ? [fileMention] : undefined, + ) + setPendingPaletteSubmit(null) + }, [pendingPaletteSubmit]) + const toggleKnowledgePane = useCallback(() => { setIsRightPaneMaximized(false) setIsChatSidebarOpen(prev => !prev) @@ -3059,11 +3095,16 @@ function App() { return () => document.removeEventListener('keydown', handleKeyDown) }, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat]) - // Keyboard shortcut: Cmd+K / Ctrl+K to open search + // Keyboard shortcut: Cmd+K / Ctrl+K opens the unified palette (defaults to Chat mode). + // If an editor tab is currently active, capture cursor context so Chat mode shows the + // note + line as a removable chip. useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault() + const activeId = activeFileTabIdRef.current + const handle = activeId ? editorRefsByTabId.current.get(activeId) : null + setPaletteContext(handle?.getCursorContext() ?? null) setIsSearchOpen(true) } } @@ -4186,6 +4227,10 @@ function App() { aria-hidden={!isActive} > { + if (el) editorRefsByTabId.current.set(tab.id, el) + else editorRefsByTabId.current.delete(tab.id) + }} content={tabContent} notePath={tab.path} onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }} @@ -4505,11 +4550,13 @@ function App() { />
- { void navigateToView({ type: 'chat', runId: id }) }} + initialContext={paletteContext} + onChatSubmit={submitFromPalette} /> diff --git a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx index 98263434..467547b1 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx @@ -91,11 +91,12 @@ export type FileMention = { id: string; path: string; // "knowledge/notes.md" displayName: string; // "notes" + lineNumber?: number; // 1-indexed source-line reference (for editor-context mentions) }; export type MentionsContext = { mentions: FileMention[]; - addMention: (path: string, displayName: string) => void; + addMention: (path: string, displayName: string, lineNumber?: number) => void; removeMention: (id: string) => void; clearMentions: () => void; }; @@ -279,13 +280,13 @@ export function PromptInputProvider({ // ----- mentions state (for @ file mentions) const [mentionsList, setMentionsList] = useState([]); - const addMention = useCallback((path: string, displayName: string) => { + const addMention = useCallback((path: string, displayName: string, lineNumber?: number) => { setMentionsList((prev) => { - // Avoid duplicates - if (prev.some((m) => m.path === path)) { + // Avoid duplicates (same path AND same lineNumber — line-specific mentions are distinct) + if (prev.some((m) => m.path === path && m.lineNumber === lineNumber)) { return prev; } - return [...prev, { id: nanoid(), path, displayName }]; + return [...prev, { id: nanoid(), path, displayName, lineNumber }]; }); }, []); diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index d2d5314f..ee1f6033 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -18,7 +18,7 @@ import { EmailBlockExtension } from '@/extensions/email-block' import { TranscriptBlockExtension } from '@/extensions/transcript-block' import { MermaidBlockExtension } from '@/extensions/mermaid-block' import { Markdown } from 'tiptap-markdown' -import { useEffect, useCallback, useMemo, useRef, useState } from 'react' +import { useEffect, useCallback, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react' import { Calendar, ChevronDown, ExternalLink } from 'lucide-react' // Zero-width space used as invisible marker for blank lines @@ -54,160 +54,221 @@ function postprocessMarkdown(markdown: string): string { }).join('\n') } -// Custom function to get markdown that preserves empty paragraphs as blank lines -function getMarkdownWithBlankLines(editor: Editor): string { - const json = editor.getJSON() - if (!json.content) return '' +type JsonNode = { + type?: string + content?: JsonNode[] + text?: string + marks?: Array<{ type: string; attrs?: Record }> + attrs?: Record +} - const blocks: string[] = [] - - // Helper to convert a node to markdown text - const nodeToText = (node: { - type?: string - content?: Array<{ - type?: string - text?: string - marks?: Array<{ type: string; attrs?: Record }> - attrs?: Record - }> - attrs?: Record - }): string => { - if (!node.content) return '' - return node.content.map(child => { - if (child.type === 'text') { - let text = child.text || '' - // Apply marks (bold, italic, etc.) - if (child.marks) { - for (const mark of child.marks) { - if (mark.type === 'bold') text = `**${text}**` - else if (mark.type === 'italic') text = `*${text}*` - else if (mark.type === 'code') text = `\`${text}\`` - else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})` - } +// Convert a node's inline content (text + marks + wikiLinks + hardBreaks) to markdown text +function nodeToText(node: JsonNode): string { + if (!node.content) return '' + return node.content.map(child => { + if (child.type === 'text') { + let text = child.text || '' + if (child.marks) { + for (const mark of child.marks) { + if (mark.type === 'bold') text = `**${text}**` + else if (mark.type === 'italic') text = `*${text}*` + else if (mark.type === 'code') text = `\`${text}\`` + else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})` } - return text - } else if (child.type === 'wikiLink') { - const path = (child.attrs?.path as string) || '' - return path ? `[[${path}]]` : '' - } else if (child.type === 'hardBreak') { - return '\n' } - return '' - }).join('') - } + return text + } else if (child.type === 'wikiLink') { + const path = (child.attrs?.path as string) || '' + return path ? `[[${path}]]` : '' + } else if (child.type === 'hardBreak') { + return '\n' + } + return '' + }).join('') +} - for (const node of json.content) { - if (node.type === 'paragraph') { - const text = nodeToText(node) - // If the paragraph contains only the blank line marker or is empty, it's a blank line - if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) { - // Push empty string to represent blank line - will add extra newline when joining - blocks.push('') +// Recursively serialize a list node (one line per item; nested lists indented two spaces) +function serializeList(listNode: JsonNode, indent: number): string[] { + const lines: string[] = [] + const items = (listNode.content || []) as JsonNode[] + items.forEach((item, index) => { + const indentStr = ' '.repeat(indent) + let prefix: string + if (listNode.type === 'taskList') { + const checked = item.attrs?.checked ? 'x' : ' ' + prefix = `- [${checked}] ` + } else if (listNode.type === 'orderedList') { + prefix = `${index + 1}. ` + } else { + prefix = '- ' + } + const itemContent = (item.content || []) as JsonNode[] + let firstPara = true + itemContent.forEach(child => { + if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') { + lines.push(...serializeList(child, indent + 1)) } else { - blocks.push(text) + const text = nodeToText(child) + if (firstPara) { + lines.push(indentStr + prefix + text) + firstPara = false + } else { + lines.push(indentStr + ' ' + text) + } } - } else if (node.type === 'heading') { - const level = (node.attrs?.level as number) || 1 + }) + }) + return lines +} + +// Serialize a single top-level block to its markdown string. Empty paragraphs (or blank-marker +// paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown. +function blockToMarkdown(node: JsonNode): string { + switch (node.type) { + case 'paragraph': { const text = nodeToText(node) - blocks.push('#'.repeat(level) + ' ' + text) - } else if (node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList') { - // Recursively serialize lists to handle nested bullets - const serializeList = ( - listNode: { type?: string; content?: Array>; attrs?: Record }, - indent: number - ): string[] => { - const lines: string[] = [] - const items = (listNode.content || []) as Array<{ content?: Array>; attrs?: Record }> - items.forEach((item, index) => { - const indentStr = ' '.repeat(indent) - let prefix: string - if (listNode.type === 'taskList') { - const checked = item.attrs?.checked ? 'x' : ' ' - prefix = `- [${checked}] ` - } else if (listNode.type === 'orderedList') { - prefix = `${index + 1}. ` - } else { - prefix = '- ' - } - const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }> - let firstPara = true - itemContent.forEach(child => { - if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') { - lines.push(...serializeList(child, indent + 1)) - } else { - const text = nodeToText(child) - if (firstPara) { - lines.push(indentStr + prefix + text) - firstPara = false - } else { - lines.push(indentStr + ' ' + text) - } - } - }) - }) - return lines - } - blocks.push(serializeList(node, 0).join('\n')) - } else if (node.type === 'taskBlock') { - blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'imageBlock') { - blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'embedBlock') { - blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'chartBlock') { - blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'tableBlock') { - blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'calendarBlock') { - blocks.push('```calendar\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'emailBlock') { - blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'transcriptBlock') { - blocks.push('```transcript\n' + (node.attrs?.data as string || '{}') + '\n```') - } else if (node.type === 'mermaidBlock') { - blocks.push('```mermaid\n' + (node.attrs?.data as string || '') + '\n```') - } else if (node.type === 'codeBlock') { + if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) return '' + return text + } + case 'heading': { + const level = (node.attrs?.level as number) || 1 + return '#'.repeat(level) + ' ' + nodeToText(node) + } + case 'bulletList': + case 'orderedList': + case 'taskList': + return serializeList(node, 0).join('\n') + case 'taskBlock': + return '```task\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'imageBlock': + return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'embedBlock': + return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'chartBlock': + return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'tableBlock': + return '```table\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'calendarBlock': + return '```calendar\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'emailBlock': + return '```email\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'transcriptBlock': + return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'mermaidBlock': + return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```' + case 'codeBlock': { const lang = (node.attrs?.language as string) || '' - blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') - } else if (node.type === 'blockquote') { - const content = node.content || [] - const quoteLines = content.map(para => '> ' + nodeToText(para)) - blocks.push(quoteLines.join('\n')) - } else if (node.type === 'horizontalRule') { - blocks.push('---') - } else if (node.type === 'wikiLink') { + return '```' + lang + '\n' + nodeToText(node) + '\n```' + } + case 'blockquote': { + const content = (node.content || []) as JsonNode[] + return content.map(para => '> ' + nodeToText(para)).join('\n') + } + case 'horizontalRule': + return '---' + case 'wikiLink': { const path = (node.attrs?.path as string) || '' - blocks.push(`[[${path}]]`) - } else if (node.type === 'image') { + return `[[${path}]]` + } + case 'image': { const src = (node.attrs?.src as string) || '' const alt = (node.attrs?.alt as string) || '' - blocks.push(`![${alt}](${src})`) + return `![${alt}](${src})` } + default: + return '' } +} - // Custom join: content blocks get \n\n before them, empty blocks add \n each - // This produces: 1 empty paragraph = 3 newlines (1 blank line on disk) +// Pure helper: serialize a slice of top-level block nodes to markdown. +// Custom join: content blocks get \n\n before them, empty blocks add \n each. +// 1 empty paragraph = 3 newlines on disk (1 blank line). +function serializeBlocksToMarkdown(blocks: JsonNode[]): string { if (blocks.length === 0) return '' - let result = '' - for (let i = 0; i < blocks.length; i++) { - const block = blocks[i] + const block = blockToMarkdown(blocks[i]) const isContent = block !== '' - if (i === 0) { result = block } else if (isContent) { - // Content block: add \n\n before it (standard paragraph break) result += '\n\n' + block } else { - // Empty block: just add \n (one extra newline for blank line) result += '\n' } } - return result } + +// Custom function to get markdown that preserves empty paragraphs as blank lines +function getMarkdownWithBlankLines(editor: Editor): string { + const json = editor.getJSON() as JsonNode + if (!json.content) return '' + return serializeBlocksToMarkdown(json.content as JsonNode[]) +} + +// Compute the cursor's 1-indexed line number in the markdown that getMarkdownWithBlankLines +// would produce. Used to attach precise line-references when inserting editor-context mentions. +function getCursorContextLine(editor: Editor): number { + const $from = editor.state.selection.$from + const json = editor.getJSON() as JsonNode + const blocks = (json.content ?? []) as JsonNode[] + if (blocks.length === 0) return 1 + + const blockIndex = $from.index(0) + if (blockIndex < 0 || blockIndex >= blocks.length) return 1 + + // Line where the cursor's top-level block starts. + // Joining: prefix + '\n\n' + nextContentBlock → next block sits two lines below the prefix's last line. + let blockStartLine: number + if (blockIndex === 0) { + blockStartLine = 1 + } else { + const prefix = serializeBlocksToMarkdown(blocks.slice(0, blockIndex)) + const prefixLineCount = prefix === '' ? 0 : prefix.split('\n').length + blockStartLine = prefixLineCount + 2 + } + + return blockStartLine + computeWithinBlockOffset(blocks[blockIndex], $from) +} + +// Lines into the cursor's top-level block. 0 for the common single-line cases (paragraph/heading); +// for multi-line containers, computed against how the block serializes. +function computeWithinBlockOffset( + block: JsonNode, + $from: { parentOffset: number; depth: number; index: (depth: number) => number } +): number { + switch (block.type) { + case 'paragraph': + case 'heading': { + // Each hardBreak before the cursor moves us down one rendered line. + const offset = $from.parentOffset + let pos = 0 + let hbCount = 0 + for (const child of (block.content ?? [])) { + if (pos >= offset) break + const size = child.type === 'text' ? (child.text?.length ?? 0) : 1 + if (child.type === 'hardBreak' && pos < offset) hbCount++ + pos += size + } + return hbCount + } + case 'bulletList': + case 'orderedList': + case 'taskList': + case 'blockquote': + // Item index within the container = lines into the block (one item per line for shallow lists/quotes). + return $from.depth >= 1 ? $from.index(1) : 0 + case 'codeBlock': { + // +1 for the opening ``` fence line, plus newlines within the code text before the cursor. + const text = block.content?.[0]?.text ?? '' + const before = text.substring(0, $from.parentOffset) + return 1 + (before.match(/\n/g)?.length ?? 0) + } + default: + return 0 + } +} import { EditorToolbar } from './editor-toolbar' import { FrontmatterProperties } from './frontmatter-properties' import { WikiLink } from '@/extensions/wiki-link' @@ -439,7 +500,12 @@ const TabIndentExtension = Extension.create({ }, }) -export function MarkdownEditor({ +export interface MarkdownEditorHandle { + /** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */ + getCursorContext: () => { path: string; lineNumber: number } | null +} + +export const MarkdownEditor = forwardRef(function MarkdownEditor({ content, onChange, onPrimaryHeadingCommit, @@ -454,7 +520,7 @@ export function MarkdownEditor({ onFrontmatterChange, onExport, notePath, -}: MarkdownEditorProps) { +}, ref) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) const [activeWikiLink, setActiveWikiLink] = useState(null) @@ -789,6 +855,17 @@ export function MarkdownEditor({ }) }, [editor, wikiLinks]) + useImperativeHandle(ref, () => ({ + getCursorContext: () => { + if (!notePath || !editor) return null + try { + return { path: notePath, lineNumber: getCursorContextLine(editor) } + } catch { + return null + } + }, + }), [notePath, editor]) + const updateRowboatMentionState = useCallback(() => { if (!editor) return const { selection } = editor.state @@ -1452,4 +1529,4 @@ export function MarkdownEditor({ ) -} +}) diff --git a/apps/x/apps/renderer/src/components/search-dialog.tsx b/apps/x/apps/renderer/src/components/search-dialog.tsx index 32bca1b3..66a37802 100644 --- a/apps/x/apps/renderer/src/components/search-dialog.tsx +++ b/apps/x/apps/renderer/src/components/search-dialog.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import posthog from 'posthog-js' import * as analytics from '@/lib/analytics' -import { FileTextIcon, MessageSquareIcon } from 'lucide-react' +import { FileTextIcon, MessageSquareIcon, XIcon } from 'lucide-react' import { CommandDialog, CommandInput, @@ -22,21 +22,50 @@ interface SearchResult { } type SearchType = 'knowledge' | 'chat' +type Mode = 'chat' | 'search' function activeTabToTypes(section: ActiveSection): SearchType[] { if (section === 'knowledge') return ['knowledge'] return ['chat'] // "tasks" tab maps to chat } -interface SearchDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - onSelectFile: (path: string) => void - onSelectRun: (runId: string) => void +export type CommandPaletteContext = { + path: string + lineNumber: number } -export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) { +export type CommandPaletteMention = { + path: string + displayName: string + lineNumber?: number +} + +interface CommandPaletteProps { + open: boolean + onOpenChange: (open: boolean) => void + // Search mode + onSelectFile: (path: string) => void + onSelectRun: (runId: string) => void + // Chat mode + initialContext?: CommandPaletteContext | null + onChatSubmit: (text: string, mention: CommandPaletteMention | null) => void +} + +export function CommandPalette({ + open, + onOpenChange, + onSelectFile, + onSelectRun, + initialContext, + onChatSubmit, +}: CommandPaletteProps) { const { activeSection } = useSidebarSection() + const [mode, setMode] = useState('chat') + const [chatInput, setChatInput] = useState('') + const [contextChip, setContextChip] = useState(null) + const chatInputRef = useRef(null) + const searchInputRef = useRef(null) + const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [isSearching, setIsSearching] = useState(false) @@ -45,17 +74,45 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: ) const debouncedQuery = useDebounce(query, 250) - // Sync filter preselection when dialog opens + // On open: always reset to Chat mode (per spec — no mode persistence), sync context chip + // and reset search filters. useEffect(() => { if (open) { + setMode('chat') + setChatInput('') + setContextChip(initialContext ?? null) setActiveTypes(new Set(activeTabToTypes(activeSection))) } - }, [open, activeSection]) + }, [open, activeSection, initialContext]) + + // Tab cycles modes. Captured at document level so cmdk's internal Tab handling doesn't + // swallow it. Only fires while the dialog is open. + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return + e.preventDefault() + e.stopPropagation() + setMode(prev => (prev === 'chat' ? 'search' : 'chat')) + } + document.addEventListener('keydown', handler, true) + return () => document.removeEventListener('keydown', handler, true) + }, [open]) + + // Refocus the appropriate input on mode change so the user can start typing immediately. + useEffect(() => { + if (!open) return + const target = mode === 'chat' ? chatInputRef : searchInputRef + target.current?.focus() + }, [open, mode]) const toggleType = useCallback((type: SearchType) => { setActiveTypes(new Set([type])) }, []) + // Search query effect (only meaningful while in search mode, but the debounce keeps running + // harmlessly otherwise — empty query skips the IPC call below). useEffect(() => { if (!debouncedQuery.trim()) { setResults([]) @@ -89,11 +146,12 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: return () => { cancelled = true } }, [debouncedQuery, activeTypes]) - // Reset state when dialog closes + // Reset transient state on close. useEffect(() => { if (!open) { setQuery('') setResults([]) + setChatInput('') } }, [open]) @@ -106,6 +164,20 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: } }, [onOpenChange, onSelectFile, onSelectRun]) + const submitChat = useCallback(() => { + const text = chatInput.trim() + if (!text && !contextChip) return + const mention: CommandPaletteMention | null = contextChip + ? { + path: contextChip.path, + displayName: deriveDisplayName(contextChip.path), + lineNumber: contextChip.lineNumber, + } + : null + onChatSubmit(text, mention) + onOpenChange(false) + }, [chatInput, contextChip, onChatSubmit, onOpenChange]) + const knowledgeResults = results.filter(r => r.type === 'knowledge') const chatResults = results.filter(r => r.type === 'chat') @@ -113,76 +185,178 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: - + {/* Mode strip */}
- toggleType('knowledge')} - icon={} - label="Knowledge" - /> - toggleType('chat')} + setMode('chat')} icon={} - label="Chats" + label="Chat" /> + setMode('search')} + icon={} + label="Search" + /> + Tab to switch
- - {!query.trim() && ( - Type to search... - )} - {query.trim() && !isSearching && results.length === 0 && ( - No results found. - )} - {knowledgeResults.length > 0 && ( - - {knowledgeResults.map((result) => ( - handleSelect(result)} - > - -
- {result.title} - {result.preview} -
-
- ))} -
- )} - {chatResults.length > 0 && ( - - {chatResults.map((result) => ( - handleSelect(result)} - > - -
- {result.title} - {result.preview} -
-
- ))} -
- )} -
+ + {mode === 'chat' ? ( +
+ setChatInput(e.target.value)} + onKeyDown={(e) => { + // cmdk's Command component intercepts Enter for item selection — stop it + // before bubbling so we control the chat submit ourselves. + if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault() + e.stopPropagation() + submitChat() + } + }} + placeholder="Ask copilot anything…" + autoFocus + className="w-full bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground" + /> + {contextChip && ( +
+ + + {deriveDisplayName(contextChip.path)} + · Line {contextChip.lineNumber} + + + Enter to send +
+ )} + {!contextChip && ( +
+ Enter to send +
+ )} +
+ ) : ( + <> + +
+ toggleType('knowledge')} + icon={} + label="Knowledge" + /> + toggleType('chat')} + icon={} + label="Chats" + /> +
+ + {!query.trim() && ( + Type to search... + )} + {query.trim() && !isSearching && results.length === 0 && ( + No results found. + )} + {knowledgeResults.length > 0 && ( + + {knowledgeResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} + {chatResults.length > 0 && ( + + {chatResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} +
+ + )}
) } +// Back-compat export so existing import sites don't break in one go; thin alias to CommandPalette. +export const SearchDialog = CommandPalette + +function deriveDisplayName(path: string): string { + const base = path.split('/').pop() ?? path + return base.replace(/\.md$/, '') +} + +function ModeButton({ + active, + onClick, + icon, + label, +}: { + active: boolean + onClick: () => void + icon: React.ReactNode + label: string +}) { + return ( + + ) +} + function FilterToggle({ active, onClick, diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 34e2b401..fea801de 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -566,7 +566,8 @@ export function convertFromMessages(messages: z.infer[]): ModelM for (const part of msg.content) { if (part.type === "attachment") { const sizeStr = part.size ? `, ${formatBytes(part.size)}` : ''; - attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`); + const lineStr = part.lineNumber ? ` (line ${part.lineNumber})` : ''; + attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}${lineStr}`); } else { textSegments.push(part.text); } diff --git a/apps/x/packages/shared/src/message.ts b/apps/x/packages/shared/src/message.ts index be761853..2aefe3f3 100644 --- a/apps/x/packages/shared/src/message.ts +++ b/apps/x/packages/shared/src/message.ts @@ -41,6 +41,7 @@ export const UserAttachmentPart = z.object({ filename: z.string(), // display name ("photo.png") mimeType: z.string(), // MIME type ("image/png", "text/plain") size: z.number().optional(), // bytes + lineNumber: z.number().int().min(1).optional(), // 1-indexed line in source file (for editor-context references) }); // Any single part of a user message (text or attachment) From 2653f6170de750100bb1c6c75121996607349c9e Mon Sep 17 00:00:00 2001 From: Tushar <47842976+tusharmagar@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:15:04 +0530 Subject: [PATCH 10/50] feat(oauth): enhance Rowboat sign-in process to prevent duplicate users (#489) * feat(oauth): enhance Rowboat sign-in process to prevent duplicate users Added billing information checks during the Rowboat OAuth connection and onboarding process to ensure user and Stripe customer existence before proceeding. This change mitigates the risk of creating duplicate users due to parallel API calls. Updated error handling for better debugging in case of failures. * refactor(onboarding): remove billing info check during Rowboat OAuth connection Eliminated the billing information check that was previously in place to prevent duplicate Stripe customers during the onboarding process. This change simplifies the onboarding flow while maintaining the necessary checks for composio flags after account connection. --- apps/x/apps/main/src/oauth-handler.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 483f25ee..3bb9063b 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -11,6 +11,7 @@ import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gma import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; import { emitOAuthEvent } from './ipc.js'; +import { getBillingInfo } from '@x/core/dist/billing/billing.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; @@ -271,6 +272,17 @@ export async function connectProvider(provider: string, credentials?: { clientId triggerFirefliesSync(); } + // For Rowboat sign-in, ensure user + Stripe customer exist before + // notifying the renderer. Without this, parallel API calls from + // multiple renderer hooks race to create the user, causing duplicates. + if (provider === 'rowboat') { + try { + await getBillingInfo(); + } catch (meError) { + console.error('[OAuth] Failed to initialize user via /v1/me:', meError); + } + } + // Emit success event to renderer emitOAuthEvent({ provider, success: true }); } catch (error) { From 490b14ad58c56e2836af4c57a86987a8397f6eea Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:28:01 +0530 Subject: [PATCH 11/50] switch to claude as default --- apps/x/packages/core/src/agents/runtime.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index fea801de..36aafbd8 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -859,8 +859,8 @@ export async function* streamAgent({ const isKgAgent = knowledgeGraphAgents.includes(state.agentName!); const isInlineTaskAgent = state.agentName === "inline_task_agent"; const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model; - const defaultKgModel = signedIn ? "gpt-5.4-mini" : defaultModel; - const defaultInlineTaskModel = signedIn ? "gpt-5.4" : defaultModel; + const defaultKgModel = signedIn ? "anthropic/claude-haiku-4.5" : defaultModel; + const defaultInlineTaskModel = signedIn ? "anthropic/claude-sonnet-4.6" : defaultModel; const modelId = isInlineTaskAgent ? defaultInlineTaskModel : (isKgAgent && modelConfig.knowledgeGraphModel) From b462643e6d9930fc5aee51e4deb3b0dbe66a9510 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:08:39 +0530 Subject: [PATCH 12/50] refactor waitForRunCompletion, extractAgentResponse --- apps/x/packages/core/src/agents/utils.ts | 40 ++++++++++++++++++ .../core/src/knowledge/agent_notes.ts | 15 +------ .../core/src/knowledge/build_graph.ts | 15 +------ .../core/src/knowledge/inline_tasks.ts | 42 +------------------ .../core/src/knowledge/label_emails.ts | 15 +------ .../packages/core/src/knowledge/tag_notes.ts | 15 +------ apps/x/packages/core/src/pre_built/runner.ts | 16 +------ 7 files changed, 46 insertions(+), 112 deletions(-) create mode 100644 apps/x/packages/core/src/agents/utils.ts diff --git a/apps/x/packages/core/src/agents/utils.ts b/apps/x/packages/core/src/agents/utils.ts new file mode 100644 index 00000000..bd4c84af --- /dev/null +++ b/apps/x/packages/core/src/agents/utils.ts @@ -0,0 +1,40 @@ +import { bus } from "../runs/bus.js"; +import { fetchRun } from "../runs/runs.js"; + +/** + * Extract the assistant's final text response from a run's log. + * @param runId + * @returns The assistant's final text response or null if not found. + */ +export async function extractAgentResponse(runId: string): Promise { + const run = await fetchRun(runId); + for (let i = run.log.length - 1; i >= 0; i--) { + const event = run.log[i]; + if (event.type === 'message' && event.message.role === 'assistant') { + const content = event.message.content; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + const text = content + .filter((p) => p.type === 'text') + .map((p) => 'text' in p ? p.text : '') + .join(''); + return text || null; + } + } + } + return null; +} + +/** + * Wait for a run to complete by listening for run-processing-end event + */ +export async function waitForRunCompletion(runId: string): Promise { + return new Promise(async (resolve) => { + const unsubscribe = await bus.subscribe('*', async (event) => { + if (event.type === 'run-processing-end' && event.runId === runId) { + unsubscribe(); + resolve(); + } + }); + }); +} \ No newline at end of file diff --git a/apps/x/packages/core/src/knowledge/agent_notes.ts b/apps/x/packages/core/src/knowledge/agent_notes.ts index 5ec3e801..16307bb5 100644 --- a/apps/x/packages/core/src/knowledge/agent_notes.ts +++ b/apps/x/packages/core/src/knowledge/agent_notes.ts @@ -3,7 +3,7 @@ import path from 'path'; import { google } from 'googleapis'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage } from '../runs/runs.js'; -import { bus } from '../runs/bus.js'; +import { waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; import { loadUserConfig, updateUserEmail } from '../config/user_config.js'; import { GoogleClientFactory } from './google-client-factory.js'; @@ -190,19 +190,6 @@ function extractConversationMessages(runFilePath: string): { role: string; text: return messages; } -// --- Wait for agent run completion --- - -async function waitForRunCompletion(runId: string): Promise { - return new Promise(async (resolve) => { - const unsubscribe = await bus.subscribe('*', async (event) => { - if (event.type === 'run-processing-end' && event.runId === runId) { - unsubscribe(); - resolve(); - } - }); - }); -} - // --- User email resolution --- async function ensureUserEmail(): Promise { diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index f408a844..06fd1194 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -3,6 +3,7 @@ import path from 'path'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage } from '../runs/runs.js'; import { bus } from '../runs/bus.js'; +import { waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { loadState, @@ -185,20 +186,6 @@ async function readFileContents(filePaths: string[]): Promise<{ path: string; co return files; } -/** - * Wait for a run to complete by listening for run-processing-end event - */ -async function waitForRunCompletion(runId: string): Promise { - return new Promise(async (resolve) => { - const unsubscribe = await bus.subscribe('*', async (event) => { - if (event.type === 'run-processing-end' && event.runId === runId) { - unsubscribe(); - resolve(); - } - }); - }); -} - /** * Run note creation agent on a batch of files to extract entities and create/update notes */ diff --git a/apps/x/packages/core/src/knowledge/inline_tasks.ts b/apps/x/packages/core/src/knowledge/inline_tasks.ts index 3f7c5ffa..01d22352 100644 --- a/apps/x/packages/core/src/knowledge/inline_tasks.ts +++ b/apps/x/packages/core/src/knowledge/inline_tasks.ts @@ -4,11 +4,11 @@ import { CronExpressionParser } from 'cron-parser'; import { generateText } from 'ai'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage, fetchRun } from '../runs/runs.js'; -import { bus } from '../runs/bus.js'; import container from '../di/container.js'; import type { IModelConfigRepo } from '../models/repo.js'; import { createProvider } from '../models/models.js'; import { inlineTask } from '@x/shared'; +import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js'; const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds const INLINE_TASK_AGENT = 'inline_task_agent'; @@ -129,46 +129,6 @@ function scanDirectoryRecursive(dir: string): string[] { return files; } -/** - * Wait for a run to complete by listening for run-processing-end event - */ -async function waitForRunCompletion(runId: string): Promise { - return new Promise(async (resolve) => { - const unsubscribe = await bus.subscribe('*', async (event) => { - if (event.type === 'run-processing-end' && event.runId === runId) { - unsubscribe(); - resolve(); - } - }); - }); -} - -/** - * Extract the assistant's final text response from a run's log. - */ -async function extractAgentResponse(runId: string): Promise { - const run = await fetchRun(runId); - // Walk backwards through the log to find the last assistant message - for (let i = run.log.length - 1; i >= 0; i--) { - const event = run.log[i]; - if (event.type === 'message' && event.message.role === 'assistant') { - const content = event.message.content; - if (typeof content === 'string') { - return content; - } - // Content may be an array of parts — concatenate text parts - if (Array.isArray(content)) { - const text = content - .filter((p) => p.type === 'text') - .map((p) => (p as { type: 'text'; text: string }).text) - .join(''); - return text || null; - } - } - } - return null; -} - interface InlineTask { instruction: string; schedule: InlineTaskSchedule | null; diff --git a/apps/x/packages/core/src/knowledge/label_emails.ts b/apps/x/packages/core/src/knowledge/label_emails.ts index 68bca5a1..98b10c2f 100644 --- a/apps/x/packages/core/src/knowledge/label_emails.ts +++ b/apps/x/packages/core/src/knowledge/label_emails.ts @@ -3,6 +3,7 @@ import path from 'path'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage } from '../runs/runs.js'; import { bus } from '../runs/bus.js'; +import { waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; import { @@ -62,20 +63,6 @@ function getUnlabeledEmails(state: LabelingState): string[] { return unlabeled; } -/** - * Wait for a run to complete by listening for run-processing-end event - */ -async function waitForRunCompletion(runId: string): Promise { - return new Promise(async (resolve) => { - const unsubscribe = await bus.subscribe('*', async (event) => { - if (event.type === 'run-processing-end' && event.runId === runId) { - unsubscribe(); - resolve(); - } - }); - }); -} - /** * Label a batch of email files using the labeling agent */ diff --git a/apps/x/packages/core/src/knowledge/tag_notes.ts b/apps/x/packages/core/src/knowledge/tag_notes.ts index 39d46a3b..8fdabb86 100644 --- a/apps/x/packages/core/src/knowledge/tag_notes.ts +++ b/apps/x/packages/core/src/knowledge/tag_notes.ts @@ -3,6 +3,7 @@ import path from 'path'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage } from '../runs/runs.js'; import { bus } from '../runs/bus.js'; +import { waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; import { @@ -75,20 +76,6 @@ function getUntaggedNotes(state: NoteTaggingState): string[] { return untagged; } -/** - * Wait for a run to complete by listening for run-processing-end event - */ -async function waitForRunCompletion(runId: string): Promise { - return new Promise(async (resolve) => { - const unsubscribe = await bus.subscribe('*', async (event) => { - if (event.type === 'run-processing-end' && event.runId === runId) { - unsubscribe(); - resolve(); - } - }); - }); -} - /** * Tag a batch of note files using the tagging agent */ diff --git a/apps/x/packages/core/src/pre_built/runner.ts b/apps/x/packages/core/src/pre_built/runner.ts index 4856dd7f..c1985380 100644 --- a/apps/x/packages/core/src/pre_built/runner.ts +++ b/apps/x/packages/core/src/pre_built/runner.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage } from '../runs/runs.js'; -import { bus } from '../runs/bus.js'; +import { waitForRunCompletion } from '../agents/utils.js'; import { loadConfig, loadState, @@ -18,20 +18,6 @@ import { PREBUILT_AGENTS } from './types.js'; const CHECK_INTERVAL_MS = 60 * 1000; // Check every minute which agents need to run const PREBUILT_DIR = path.join(WorkDir, 'pre-built'); -/** - * Wait for a run to complete by listening for run-processing-end event - */ -async function waitForRunCompletion(runId: string): Promise { - return new Promise(async (resolve) => { - const unsubscribe = await bus.subscribe('*', async (event) => { - if (event.type === 'run-processing-end' && event.runId === runId) { - unsubscribe(); - resolve(); - } - }); - }); -} - /** * Run a pre-built agent by name */ From ab0147d4754437a6082674058a563f6509caddd9 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:16:10 +0530 Subject: [PATCH 13/50] refactor agent yaml frontmatter parsing --- apps/x/packages/core/src/agents/repo.ts | 59 ++++++++----------- .../src/application/lib/parse-frontmatter.ts | 27 +++++++++ 2 files changed, 53 insertions(+), 33 deletions(-) create mode 100644 apps/x/packages/core/src/application/lib/parse-frontmatter.ts diff --git a/apps/x/packages/core/src/agents/repo.ts b/apps/x/packages/core/src/agents/repo.ts index 9f036b3f..fc832e16 100644 --- a/apps/x/packages/core/src/agents/repo.ts +++ b/apps/x/packages/core/src/agents/repo.ts @@ -4,7 +4,8 @@ import { glob } from "node:fs/promises"; import path from "path"; import z from "zod"; import { Agent } from "@x/shared/dist/agent.js"; -import { parse, stringify } from "yaml"; +import { stringify } from "yaml"; +import { parseFrontmatter } from "../application/lib/parse-frontmatter.js"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const UpdateAgentSchema = Agent.omit({ name: true }); @@ -33,7 +34,10 @@ export class FSAgentsRepo implements IAgentsRepo { for (const file of matches) { try { const agent = await this.parseAgentMd(path.join(this.agentsDir, file)); - result.push(agent); + result.push({ + ...agent, + name: file.replace(/\.md$/, ""), + }); } catch (error) { console.error(`Error parsing agent ${file}: ${error instanceof Error ? error.message : String(error)}`); continue; @@ -42,44 +46,33 @@ export class FSAgentsRepo implements IAgentsRepo { return result; } - private async parseAgentMd(filePath: string): Promise> { - const raw = await fs.readFile(filePath, "utf8"); + private async parseAgentMd(filepath: string): Promise> { + const raw = await fs.readFile(filepath, "utf8"); - // strip the path prefix from the file name - // and the .md extension - const agentName = filePath - .replace(this.agentsDir + "/", "") - .replace(/\.md$/, ""); - let agent: z.infer = { - name: agentName, - instructions: raw, - }; - let content = raw; + const { frontmatter, content } = parseFrontmatter(raw); + if (frontmatter) { + const parsed = Agent + .omit({ instructions: true }) + .parse(frontmatter); - // check for frontmatter markers at start - if (raw.startsWith("---")) { - const end = raw.indexOf("\n---", 3); - - if (end !== -1) { - const fm = raw.slice(3, end).trim(); // YAML text - content = raw.slice(end + 4).trim(); // body after frontmatter - const yaml = parse(fm); - const parsed = Agent - .omit({ name: true, instructions: true }) - .parse(yaml); - agent = { - ...agent, - ...parsed, - instructions: content, - }; - } + return { + ...parsed, + instructions: content, + }; } - return agent; + return { + name: filepath, + instructions: raw, + }; } async fetch(id: string): Promise> { - return this.parseAgentMd(path.join(this.agentsDir, `${id}.md`)); + const agent = await this.parseAgentMd(path.join(this.agentsDir, `${id}.md`)); + return { + ...agent, + name: id, + }; } async create(agent: z.infer): Promise { diff --git a/apps/x/packages/core/src/application/lib/parse-frontmatter.ts b/apps/x/packages/core/src/application/lib/parse-frontmatter.ts new file mode 100644 index 00000000..19a44328 --- /dev/null +++ b/apps/x/packages/core/src/application/lib/parse-frontmatter.ts @@ -0,0 +1,27 @@ +import { parse as parseYaml } from "yaml"; + +/** + * Parse the YAML frontmatter from the input string. Returns the frontmatter and content. + * @param input - The input string to parse. + * @returns The frontmatter and content. + */ +export function parseFrontmatter(input: string): { + frontmatter: unknown | null; + content: string; +} { + if (input.startsWith("---")) { + const end = input.indexOf("\n---", 3); + + if (end !== -1) { + const fm = input.slice(3, end).trim(); // YAML text + return { + frontmatter: parseYaml(fm), + content: input.slice(end + 4).trim(), + }; + } + } + return { + frontmatter: null, + content: input, + }; +} \ No newline at end of file From e2c13f0f6f2102d1b394d7a4f52c88b3255cae49 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:51:45 +0530 Subject: [PATCH 14/50] =?UTF-8?q?Add=20tracks=20=E2=80=94=20auto-updating?= =?UTF-8?q?=20note=20blocks=20with=20scheduled=20and=20event-driven=20trig?= =?UTF-8?q?gers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track blocks are YAML-fenced sections embedded in markdown notes whose output is rewritten by a background agent. Three trigger types: manual (Run button or Copilot), scheduled (cron / window / once with a 2 min grace window), and event-driven (Gmail/Calendar sync events routed via an LLM classifier with a second-pass agent decision). Output lives between comment markers that render as editable content in the Tiptap editor so users can read and extend AI-generated content inline. Core: - Schedule and event pipelines run as independent polling loops (15s / 5s), both calling the same triggerTrackUpdate orchestrator. Events are FIFO via monotonic IDs; a per-track Set guards against duplicate runs. - Track-run agent builds three message variants (manual/timed/event) — the event variant includes a Pass 2 directive to skip updates on false positives flagged by the liberal Pass 1 router. - IPC surface: track:run/get/update/replaceYaml/delete plus tracks:events forward of the pub-sub bus to the renderer. - Gmail emits per-thread events; Calendar bundles a digest per sync. Copilot: - New `tracks` skill (auto-generated canonical schema from Zod via z.toJSONSchema) teaches block creation, editing, and proactive suggestion. - `run-track-block` tool with optional `context` parameter for backfills (e.g. seeding a new email-tracking block from existing synced emails). Renderer: - Tiptap chip (display-only) opens a rich modal with tabs, toggle, schedule details, raw YAML editor, and confirm-to-delete. All mutations go through IPC so the backend stays the single writer. - Target regions use two atom marker nodes (open/close) around real editable content — custom blocks render natively, users can add their own notes. - "Edit with Copilot" seeds a chat session with the note attached. Docs: apps/x/TRACKS.md covers product flows, technical pipeline, and a catalog of every LLM prompt involved with file+line pointers. --- CLAUDE.md | 8 + apps/x/TRACKS.md | 343 ++++++++++++ apps/x/apps/main/src/ipc.ts | 63 +++ apps/x/apps/main/src/main.ts | 13 + apps/x/apps/renderer/package.json | 1 + apps/x/apps/renderer/src/App.tsx | 23 + .../src/components/markdown-editor.tsx | 41 +- .../renderer/src/components/track-modal.tsx | 522 ++++++++++++++++++ .../renderer/src/extensions/track-block.tsx | 178 ++++++ .../renderer/src/extensions/track-target.tsx | 90 +++ .../renderer/src/hooks/use-track-status.ts | 72 +++ apps/x/apps/renderer/src/styles/editor.css | 149 +++++ .../apps/renderer/src/styles/track-modal.css | 311 +++++++++++ apps/x/packages/core/src/agents/runtime.ts | 5 + .../src/application/assistant/instructions.ts | 2 + .../src/application/assistant/skills/index.ts | 9 + .../assistant/skills/tracks/skill.ts | 318 +++++++++++ .../core/src/application/lib/builtin-tools.ts | 53 ++ .../core/src/knowledge/sync_calendar.ts | 138 +++++ .../packages/core/src/knowledge/sync_gmail.ts | 20 + .../packages/core/src/knowledge/track/bus.ts | 23 + .../core/src/knowledge/track/events.ts | 189 +++++++ .../core/src/knowledge/track/fileops.ts | 190 +++++++ .../core/src/knowledge/track/routing.ts | 118 ++++ .../core/src/knowledge/track/run-agent.ts | 65 +++ .../core/src/knowledge/track/runner.ts | 168 ++++++ .../src/knowledge/track/schedule-utils.ts | 63 +++ .../core/src/knowledge/track/scheduler.ts | 66 +++ .../core/src/knowledge/track/types.ts | 9 + apps/x/packages/shared/src/index.ts | 1 + apps/x/packages/shared/src/ipc.ts | 66 +++ apps/x/packages/shared/src/track-block.ts | 87 +++ apps/x/pnpm-lock.yaml | 3 + 33 files changed, 3405 insertions(+), 2 deletions(-) create mode 100644 apps/x/TRACKS.md create mode 100644 apps/x/apps/renderer/src/components/track-modal.tsx create mode 100644 apps/x/apps/renderer/src/extensions/track-block.tsx create mode 100644 apps/x/apps/renderer/src/extensions/track-target.tsx create mode 100644 apps/x/apps/renderer/src/hooks/use-track-status.ts create mode 100644 apps/x/apps/renderer/src/styles/track-modal.css create mode 100644 apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts create mode 100644 apps/x/packages/core/src/knowledge/track/bus.ts create mode 100644 apps/x/packages/core/src/knowledge/track/events.ts create mode 100644 apps/x/packages/core/src/knowledge/track/fileops.ts create mode 100644 apps/x/packages/core/src/knowledge/track/routing.ts create mode 100644 apps/x/packages/core/src/knowledge/track/run-agent.ts create mode 100644 apps/x/packages/core/src/knowledge/track/runner.ts create mode 100644 apps/x/packages/core/src/knowledge/track/schedule-utils.ts create mode 100644 apps/x/packages/core/src/knowledge/track/scheduler.ts create mode 100644 apps/x/packages/core/src/knowledge/track/types.ts create mode 100644 apps/x/packages/shared/src/track-block.ts diff --git a/CLAUDE.md b/CLAUDE.md index db51cb63..51a11e35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,14 @@ pnpm uses symlinks for workspace packages. Electron Forge's dependency walker ca | Workspace config | `apps/x/pnpm-workspace.yaml` | | Root scripts | `apps/x/package.json` | +## Feature Deep-Dives + +Long-form docs for specific features. Read the relevant file before making changes in that area — it has the full product flow, technical flows, and (where applicable) a catalog of the LLM prompts involved with exact file:line pointers. + +| Feature | Doc | +|---------|-----| +| Track Blocks — auto-updating note content (scheduled / event-driven / manual), Copilot skill, prompts catalog | `apps/x/TRACKS.md` | + ## Common Tasks ### LLM configuration (single provider) diff --git a/apps/x/TRACKS.md b/apps/x/TRACKS.md new file mode 100644 index 00000000..3caf9e41 --- /dev/null +++ b/apps/x/TRACKS.md @@ -0,0 +1,343 @@ +# Track Blocks + +> Living blocks of content embedded in markdown notes that auto-refresh on a schedule, when relevant events arrive, or on demand. + +A track block is a small YAML code fence plus a sibling HTML-comment region in a note. The YAML defines what to produce and when; the comment region holds the generated output, rewritten on each run. A single note can hold many independent tracks — one for weather, one for an email digest, one for a running project summary. + +**Example** (a Chicago-time track refreshed hourly): + +~~~markdown +```track +trackId: chicago-time +instruction: Show the current time in Chicago, IL in 12-hour format. +active: true +schedule: + type: cron + expression: "0 * * * *" +``` + + +2:30 PM, Central Time + +~~~ + +## Table of Contents + +1. [Product Overview](#product-overview) +2. [Architecture at a Glance](#architecture-at-a-glance) +3. [Technical Flows](#technical-flows) +4. [Schema Reference](#schema-reference) +5. [Prompts Catalog](#prompts-catalog) +6. [File Map](#file-map) +7. [Known Follow-ups](#known-follow-ups) + +--- + +## Product Overview + +### Trigger types + +A track block has exactly one of four trigger configurations. Schedule and event triggers can co-exist on a single track. + +| Trigger | When it fires | How to express it | +|---|---|---| +| **Manual** | Only when the user (or Copilot) hits Run | Omit `schedule`, leave `eventMatchCriteria` unset | +| **Scheduled — cron** | At exact cron times | `schedule: { type: cron, expression: "0 * * * *" }` | +| **Scheduled — window** | At most once per cron occurrence, only within a time-of-day window | `schedule: { type: window, cron, startTime: "09:00", endTime: "17:00" }` | +| **Scheduled — once** | Once at a future time, then never | `schedule: { type: once, runAt: "2026-04-14T09:00:00" }` | +| **Event-driven** | When a matching event arrives (e.g. new Gmail thread) | `eventMatchCriteria: "Emails about Q3 planning"` | + +Decision shorthand: cron for exact times, window for "sometime in the morning", once for one-shots, event for signal-driven updates. Combine `schedule` + `eventMatchCriteria` for a track that refreshes on a cadence **and** reacts to incoming signals. + +### Creating a track + +Three paths, all produce identical on-disk YAML: + +1. **Hand-written** — type the YAML fence directly in a note. The renderer picks it up instantly via the Tiptap `track-block` extension. +2. **Cmd+K with cursor context** — press Cmd+K anywhere in a note, say "add a track that shows Chicago weather hourly". Copilot loads the `tracks` skill and splices the block at the cursor using `workspace-edit`. +3. **Sidebar chat** — mention a note (or have it attached) and ask for a track. Copilot appends the block at the end or under the heading you name. + +### Viewing and managing a track + +The editor shows track blocks as an inline **chip** — a pill with the track ID, a clipped instruction, a color rail matching the trigger type, and a spinner while running. + +Clicking the chip opens the **track modal**, where everything happens: + +- **Header** — track ID, schedule summary (e.g. "Hourly"), and a Switch to pause/resume (flips `active`). +- **Tabs** — *What to track* (renders `instruction` as markdown), *When to run* (schedule details, shown if `schedule` is present), *Event matching* (shown if `eventMatchCriteria` is present), *Details* (ID, file, run metadata). +- **Advanced** — expandable raw-YAML editor for power users. +- **Danger zone** (Details tab) — delete with two-step confirmation; also removes the target region. +- **Footer** — *Edit with Copilot* (opens sidebar chat with the note attached) and *Run now* (triggers immediately). + +Every mutation inside the modal goes through IPC to the backend — the renderer never writes the file itself. This is the fix that prevents the editor's save cycle from clobbering backend-written fields like `lastRunAt`. + +### What Copilot can do + +- **Create, edit, and delete** track blocks (via the `tracks` skill + `workspace-edit` / `workspace-readFile`). +- **Run** a track with the `run-track-block` builtin tool. An optional `context` parameter biases this single run — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`. Common use: seed a newly-created event-driven track so its target region isn't empty waiting for the next matching event. +- **Proactively suggest** tracks when the user signals interest ("track", "monitor", "watch", "every morning"), per the trigger paragraph in `instructions.ts`. +- **Re-enter edit mode** via the modal's *Edit with Copilot* button, which seeds a new chat with the note attached and invites Copilot to load the `tracks` skill. + +### After a run + +- The **target region** (between `` markers) is rewritten by the track-run agent using the `update-track-content` tool. +- `lastRunAt`, `lastRunId`, `lastRunSummary` are updated in the YAML. +- The chip pulses while running, then displays the latest `lastRunAt`. +- Bus events (`track_run_start` / `track_run_complete`) are forwarded to the renderer via the `tracks:events` IPC channel, consumed by the `useTrackStatus` hook. + +--- + +## Architecture at a Glance + +``` +Editor chip (display-only) ──click──► TrackModal (React) + │ + ├──► IPC: track:get / update / + │ replaceYaml / delete / run + │ +Backend (main process) + ├─ Scheduler loop (15 s) ──┐ + ├─ Event processor (5 s) ──┼──► triggerTrackUpdate() ──► track-run agent + └─ Copilot tool run-track-block ──┘ │ + ▼ + update-track-content tool + │ + ▼ + target region rewritten on disk +``` + +**Single-writer invariant:** the renderer is never a file writer. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrackBlock`, `replaceTrackBlockYaml`, `deleteTrackBlock`). This avoids races with the scheduler/runner writing runtime fields. + +**Event contract:** `window.dispatchEvent(CustomEvent('rowboat:open-track-modal', { detail }))` is the sole entry point from editor chip → modal. Similarly, `rowboat:open-copilot-edit-track` opens the sidebar chat with context. + +--- + +## Technical Flows + +### 4.1 Scheduling (cron / window / once) + +- Module: `packages/core/src/knowledge/track/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`). +- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, iterate all track blocks via `fetchAll(relPath)`. +- Per-track due check: `isTrackScheduleDue(schedule, lastRunAt)` in `schedule-utils.ts`. All three schedule types enforce a **2-minute grace window** — missed schedules (app offline at trigger time) are skipped, not replayed. +- When due, fires `triggerTrackUpdate(trackId, filePath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates). +- Startup: `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`. + +### 4.2 Event pipeline + +**Producers** — any data source that should feed tracks emits events: + +- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — three call sites, each after a successful thread sync, call `createEvent({ source: 'gmail', type: 'email.synced', payload: })`. +- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync. The payload is a markdown digest of all new/updated/deleted events built by `summarizeCalendarSync()` (line 68). `publishCalendarSyncEvent()` (line ~126) wraps it with `source: 'calendar'`, `type: 'calendar.synced'`. + +**Storage** — `packages/core/src/knowledge/track/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO. + +**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event: + +1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive). +2. `listAllTracks()` scans every `.md` under `knowledge/` and collects `ParsedTrack[]`. +3. `findCandidates(event, allTracks)` in `routing.ts` runs Pass 1 LLM routing (below). +4. For each candidate, `triggerTrackUpdate(trackId, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event. +5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then `moveEventToDone()` — write to `events/done/.json`, unlink from `pending/`. + +**Pass 1 routing** (`routing.ts:73+ findCandidates`): + +- **Short-circuit**: if `event.targetTrackId` + `targetFilePath` are set (manual re-run events), skip the LLM and return that track directly. +- Filter to `active && instruction && eventMatchCriteria` tracks. +- Batches of `BATCH_SIZE = 20`. +- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `trackId` is only unique per file. +- Model resolution: gateway if signed in, local provider otherwise; prefers `knowledgeGraphModel` from config. + +**Pass 2 decision** happens inside the track-run agent (see Run flow below) — the liberal Pass 1 produces candidates, the agent vetoes false positives before touching the target region. + +### 4.3 Run flow (`triggerTrackUpdate`) + +Module: `packages/core/src/knowledge/track/runner.ts`. + +1. **Concurrency guard** — static `runningTracks: Set` keyed by `${trackId}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`. +2. **Fetch block** via `fetchAll(filePath)`, locate by `trackId`. +3. **Create agent run** — `createRun({ agentId: 'track-run' })`. +4. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger, and for `once` tracks the "done" flag is already set. +5. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`). +6. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` — three branches (see Prompts Catalog). For `'event'` trigger, the message includes the event payload and a Pass 2 decision directive. +7. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary. +8. **Compare content**: re-read the file, diff `contentBefore` vs `contentAfter`. If changed → `action: 'replace'`; else → `action: 'no_update'`. +9. **Store `lastRunSummary`** via `updateTrackBlock`. +10. **Emit `track_run_complete`** with `summary` or `error`. +11. **Cleanup**: `runningTracks.delete(key)` in a `finally` block. + +Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`. + +### 4.4 IPC surface + +| Channel | Caller → handler | Purpose | +|---|---|---| +| `track:run` | Renderer (chip/modal Run button) | Fires `triggerTrackUpdate(..., 'manual')` | +| `track:get` | Modal on open | Returns fresh YAML from disk via `fetchYaml` | +| `track:update` | Modal toggle / partial edits | `updateTrackBlock` merges a partial into on-disk YAML | +| `track:replaceYaml` | Advanced raw-YAML save | `replaceTrackBlockYaml` validates + writes full YAML | +| `track:delete` | Danger-zone confirm | `deleteTrackBlock` removes YAML fence + target region | +| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to the `useTrackStatus` hook | + +Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/track/fileops.ts`. + +### 4.5 Renderer integration + +- **Chip** — `apps/renderer/src/extensions/track-block.tsx`. Tiptap atom node. Render-only: click dispatches `CustomEvent('rowboat:open-track-modal', { detail: { trackId, filePath, initialYaml, onDeleted } })`. The `onDeleted` callback is the Tiptap `deleteNode` — the modal calls it after a successful `track:delete` IPC so the editor also drops the node and doesn't resurrect it on next save. +- **Modal** — `apps/renderer/src/components/track-modal.tsx`. Mounted once in `App.tsx`, self-listens for the open event. On open: calls `track:get` to get the authoritative YAML, not the Tiptap cached attr. All mutations go through IPC; `updateAttributes` is never called. +- **Status hook** — `apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` and maintains a `Map<"${trackId}:${filePath}", RunState>` so chips and the modal reflect live run state. +- **Edit with Copilot** — the modal's footer button dispatches `rowboat:open-copilot-edit-track`. An `useEffect` listener in `App.tsx` opens the chat sidebar via `submitFromPalette(text, mention)`, where `text` is a short opener that invites Copilot to load the `tracks` skill and `mention` attaches the note file. + +### 4.6 Copilot skill integration + +- **Skill content** — `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported as a `String.raw` template literal; injected into the Copilot system prompt when the `loadSkill('tracks')` tool is called. +- **Canonical schema** inside the skill is **auto-generated at module load**: `stringifyYaml(z.toJSONSchema(TrackBlockSchema))`. Editing `TrackBlockSchema` in `packages/shared/src/track-block.ts` propagates to the skill without manual sync. +- **Skill registration** — `packages/core/src/application/assistant/skills/index.ts` (import + entry in the `definitions` array). +- **Loading trigger** — `packages/core/src/application/assistant/instructions.ts:73` — one paragraph tells Copilot to load the skill on track / monitor / watch / "keep an eye on" / Cmd+K auto-refresh requests. +- **Builtin tools** — `packages/core/src/application/lib/builtin-tools.ts`: + - `update-track-content` — low-level: rewrite the target region between `` markers. Used mainly by the track-run agent. + - `run-track-block` — high-level: trigger a run with an optional `context`. Uses `await import("../../knowledge/track/runner.js")` inside the execute body to break a module-init cycle (`builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools`). + +### 4.7 Concurrency & FIFO guarantees + +- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once; overlapping triggers (manual + scheduled + event) return `error: 'Already running'` cleanly through IPC. +- **Backend is single writer** — renderer uses IPC for every edit; backend helpers in `fileops.ts` are the only code paths that touch the file. +- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()` = strict FIFO across events. Candidates within one event are processed sequentially (`await` in loop) so ordering holds within an event too. +- **No retry storms** — `lastRunAt` is set at the *start* of a run, not the end. A crash mid-run leaves the track marked as ran; the scheduler's next tick computes the next occurrence from that point. + +--- + +## Schema Reference + +All canonical schemas live in `packages/shared/src/track-block.ts`: + +- `TrackBlockSchema` — the YAML that goes inside a ` ```track ` fence. User-authored fields: `trackId` (kebab-case, unique within the file), `instruction`, `active`, `schedule?`, `eventMatchCriteria?`. **Runtime-managed fields (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`. +- `TrackScheduleSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' }`. +- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidates`, `runIds`, `error`) are populated when moving to `done/`. +- `Pass1OutputSchema` — the structured response the routing classifier returns: `{ candidates: { trackId, filePath }[] }`. + +Since the skill's schema block is generated from `TrackBlockSchema`, schema changes should start here — the skill, the validator in `replaceTrackBlockYaml`, and modal display logic all follow from the Zod source of truth. + +--- + +## Prompts Catalog + +Every LLM-facing prompt in the feature, with file + line pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app (`npm run dev`). + +### 1. Routing system prompt (Pass 1 classifier) + +- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; Pass 2 inside the agent catches them. +- **File**: `packages/core/src/knowledge/track/routing.ts:22–37` (`ROUTING_SYSTEM_PROMPT`). +- **Inputs**: none interpolated — constant system prompt. +- **Output**: structured `Pass1OutputSchema` — `{ candidates: { trackId, filePath }[] }`. +- **Invoked by**: `findCandidates()` at `routing.ts:73+`, per batch of 20 tracks, via `generateObject({ model, system, prompt, schema })`. + +### 2. Routing user prompt template + +- **Purpose**: formats the event and the current batch of tracks into the user message fed alongside the system prompt. +- **File**: `packages/core/src/knowledge/track/routing.ts:51–66` (`buildRoutingPrompt`). +- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `eventMatchCriteria`). +- **Output**: plain text, two sections — `## Event` and `## Track Blocks`. +- **Note**: the event `payload` is what the producer wrote. For Gmail it's a thread markdown dump; for Calendar it's a digest (see #7 below). + +### 3. Track-run agent instructions + +- **Purpose**: system prompt for the background agent that actually rewrites target regions. Sets tone ("no user present — don't ask questions"), points at the knowledge graph, and prescribes the `update-track-content` tool as the write path. +- **File**: `packages/core/src/knowledge/track/run-agent.ts:6–50` (`TRACK_RUN_INSTRUCTIONS`). +- **Inputs**: `${WorkDir}` template literal (substituted at module load). +- **Output**: free-form — agent calls tools, ends with a 1-2 sentence summary used as `lastRunSummary`. +- **Invoked by**: `buildTrackRunAgent()` at line 52, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`. + +### 4. Track-run agent message (`buildMessage`) + +- **Purpose**: the user message seeded into each track-run. Three shape variants based on `trigger`. +- **File**: `packages/core/src/knowledge/track/runner.ts:23–62`. +- **Inputs**: `filePath`, `track.trackId`, `track.instruction`, `track.content`, `track.eventMatchCriteria`, `trigger`, optional `context`, plus `localNow` / `tz`. +- **Output**: free-form — the agent decides whether to call `update-track-content`. + +Three branches: + +- **`manual`** — base message (instruction + current content + tool hint). If `context` is passed, it's appended as a `**Context:**` section. The `run-track-block` tool uses this path for both plain refreshes and context-biased backfills. +- **`timed`** — same as `manual`. Called by the scheduler with no `context`. +- **`event`** — adds a **Pass 2 decision block** (lines 45–56). Quoted verbatim: + + > **Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below) + > + > **Event match criteria for this track:** … + > + > **Event payload:** … + > + > **Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update — do NOT call `update-track-content`. Only call the tool if the event provides new or changed information that should be reflected in the track. + +### 5. Tracks skill (Copilot-facing) + +- **Purpose**: teaches Copilot everything about track blocks — anatomy, schema, schedules, event matching, insertion workflow, when to proactively suggest, when to run with context. +- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported `skill` constant. +- **Inputs**: at module load, `stringifyYaml(z.toJSONSchema(TrackBlockSchema))` is interpolated into the "Canonical Schema" section. Rebuilds propagate schema edits automatically. +- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('tracks')` fires. +- **Invoked by**: Copilot's `loadSkill` builtin tool (see `packages/core/src/application/lib/builtin-tools.ts`). Registration in `skills/index.ts`. +- **Edit guidance**: for schema-shaped changes, start at `packages/shared/src/track-block.ts` — the skill picks it up on the next build. For prose/workflow tweaks, edit the `String.raw` template. + +### 6. Copilot trigger paragraph + +- **Purpose**: tells Copilot *when* to load the `tracks` skill. +- **File**: `packages/core/src/application/assistant/instructions.ts:73`. +- **Inputs**: none; static prose. +- **Output**: part of the baseline Copilot system prompt. +- **Trigger words**: *track*, *monitor*, *watch*, *keep an eye on*, "*every morning tell me X*", "*show the current Y in this note*", "*pin live updates of Z here*", plus any Cmd+K request that implies auto-refresh. + +### 7. `run-track-block` tool — `context` parameter description + +- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run. Copilot sees this text as part of the tool's schema. +- **File**: `packages/core/src/application/lib/builtin-tools.ts:1454+` (the `run-track-block` tool definition; the `context` field's `.describe()` block is the mini-prompt). +- **Inputs**: free-form string from Copilot. +- **Output**: flows into `triggerTrackUpdate(..., 'manual')` → `buildMessage` → appended as `**Context:**` in the agent message. +- **Key use case**: backfill a newly-created event-driven track so its target region isn't empty on day 1 — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`. + +### 8. Calendar sync digest (event payload template) + +- **Purpose**: shapes what the routing classifier sees for a calendar event. Not a system prompt, but a deterministic markdown template that becomes `event.payload`. +- **File**: `packages/core/src/knowledge/sync_calendar.ts:68+` (`summarizeCalendarSync`). Wrapped by `publishCalendarSyncEvent()` at line ~126. +- **Inputs**: `newEvents`, `updatedEvents`, `deletedEventIds` gathered during a sync. +- **Output**: markdown with counts header, a `## Changed events` section (per-event block: title, ID, time, organizer, location, attendees, truncated description), and a `## Deleted event IDs` section. Capped at ~50 events with a "…and N more" line; descriptions truncated to 500 chars. +- **Why care**: the quality of Pass 1 matching depends on how clear this payload is. If the classifier misses obvious matches, this is a place to look. + +--- + +## File Map + +| Purpose | File | +|---|---| +| Zod schemas (tracks, schedules, events, Pass1) | `packages/shared/src/track-block.ts` | +| IPC channel schemas | `packages/shared/src/ipc.ts` | +| IPC handlers (main process) | `apps/main/src/ipc.ts` | +| File operations (fetch / update / replace / delete) | `packages/core/src/knowledge/track/fileops.ts` | +| Scheduler (cron / window / once) | `packages/core/src/knowledge/track/scheduler.ts` | +| Schedule due-check helper | `packages/core/src/knowledge/track/schedule-utils.ts` | +| Event producer + consumer loop | `packages/core/src/knowledge/track/events.ts` | +| Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/track/routing.ts` | +| Run orchestrator (`triggerTrackUpdate`, `buildMessage`) | `packages/core/src/knowledge/track/runner.ts` | +| Track-run agent definition | `packages/core/src/knowledge/track/run-agent.ts` | +| Track bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/track/bus.ts` | +| Track state type | `packages/core/src/knowledge/track/types.ts` | +| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` | +| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` | +| Copilot skill | `packages/core/src/application/assistant/skills/tracks/skill.ts` | +| Skill registration | `packages/core/src/application/assistant/skills/index.ts` | +| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` | +| Builtin tools (`update-track-content`, `run-track-block`) | `packages/core/src/application/lib/builtin-tools.ts` | +| Tiptap chip (editor node view) | `apps/renderer/src/extensions/track-block.tsx` | +| React modal (all UI mutations) | `apps/renderer/src/components/track-modal.tsx` | +| Status hook (`useTrackStatus`) | `apps/renderer/src/hooks/use-track-status.ts` | +| App-level listeners (modal + Copilot edit) | `apps/renderer/src/App.tsx` | +| CSS | `apps/renderer/src/styles/editor.css`, `apps/renderer/src/styles/track-modal.css` | +| Main process startup (schedulers & processors) | `apps/main/src/main.ts` | + +--- + +## Known Follow-ups + +- **Tiptap save can still re-serialize a stale node attr.** The chip+modal refactor makes every *intentional* mutation go through IPC, so toggles, raw-YAML edits, and deletes are all safe. But if the backend writes runtime fields (`lastRunAt`, `lastRunSummary`) after the note was loaded, and the user then types anywhere in the note body, Tiptap's markdown serializer writes out the cached (stale) YAML for each track block, clobbering those fields. + - **Cleanest fix**: subscribe to `trackBus` events in the renderer and refresh the corresponding node attr via `updateAttributes` before Tiptap can save. + - **Alternative**: make the markdown serializer pull fresh YAML from disk per track block at write time (async-friendly refactor). + +- **Only Gmail + Calendar produce events today.** Granola, Fireflies, and other sync services are plumbed for the pattern but don't emit yet. Adding a producer is a one-liner `createEvent({ source, type, createdAt, payload })` at the right spot in the sync flow. diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 74388f65..5a6e37f0 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -44,6 +44,14 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js'; import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; +import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js'; +import { trackBus } from '@x/core/dist/knowledge/track/bus.js'; +import { + fetchYaml, + updateTrackBlock, + replaceTrackBlockYaml, + deleteTrackBlock, +} from '@x/core/dist/knowledge/track/fileops.js'; /** * Convert markdown to a styled HTML document for PDF/DOCX export. @@ -362,6 +370,19 @@ export async function startServicesWatcher(): Promise { }); } +let tracksWatcher: (() => void) | null = null; +export function startTracksWatcher(): void { + if (tracksWatcher) return; + tracksWatcher = trackBus.subscribe((event) => { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('tracks:events', event); + } + } + }); +} + export function stopRunsWatcher(): void { if (runsWatcher) { runsWatcher(); @@ -758,6 +779,48 @@ export function setupIpcHandlers() { 'voice:synthesize': async (_event, args) => { return voice.synthesizeSpeech(args.text); }, + // Track handlers + 'track:run': async (_event, args) => { + const result = await triggerTrackUpdate(args.trackId, args.filePath); + return { success: !result.error, summary: result.summary ?? undefined, error: result.error }; + }, + 'track:get': async (_event, args) => { + try { + const yaml = await fetchYaml(args.filePath, args.trackId); + if (yaml === null) return { success: false, error: 'Track not found' }; + return { success: true, yaml }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'track:update': async (_event, args) => { + try { + await updateTrackBlock(args.filePath, args.trackId, args.updates as Record); + const yaml = await fetchYaml(args.filePath, args.trackId); + if (yaml === null) return { success: false, error: 'Track vanished after update' }; + return { success: true, yaml }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'track:replaceYaml': async (_event, args) => { + try { + await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml); + const yaml = await fetchYaml(args.filePath, args.trackId); + if (yaml === null) return { success: false, error: 'Track vanished after replace' }; + return { success: true, yaml }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'track:delete': async (_event, args) => { + try { + await deleteTrackBlock(args.filePath, args.trackId); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, // Billing handler 'billing:getInfo': async () => { return await getBillingInfo(); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 42c9f3fd..e8c6ee53 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -4,6 +4,7 @@ import { setupIpcHandlers, startRunsWatcher, startServicesWatcher, + startTracksWatcher, startWorkspaceWatcher, stopRunsWatcher, stopServicesWatcher, @@ -22,6 +23,9 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; +import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js"; +import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js"; + import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; @@ -228,6 +232,15 @@ app.whenReady().then(async () => { // start services watcher startServicesWatcher(); + // start tracks watcher + startTracksWatcher(); + + // start track scheduler (cron/window/once) + initTrackScheduler(); + + // start track event processor (consumes events/pending/, triggers matching tracks) + initTrackEventProcessor(); + // start gmail sync initGmailSync(); diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index b9990e14..4bb837c9 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -55,6 +55,7 @@ "tiptap-markdown": "^0.9.0", "tokenlens": "^1.3.1", "use-stick-to-bottom": "^1.1.1", + "yaml": "^2.8.2", "zod": "^4.2.1" }, "devDependencies": { diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 0d1aaaa9..31881fe0 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -55,6 +55,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { OnboardingModal } from '@/components/onboarding' import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog' +import { TrackModal } from '@/components/track-modal' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { VersionHistoryPanel } from '@/components/version-history-panel' import { FileCardProvider } from '@/contexts/file-card-context' @@ -2687,6 +2688,27 @@ function App() { setPendingPaletteSubmit(null) }, [pendingPaletteSubmit]) + // Listener for track-block "Edit with Copilot" events + // (dispatched by apps/renderer/src/extensions/track-block.tsx) + useEffect(() => { + const handler = (e: Event) => { + const ev = e as CustomEvent<{ + trackId?: string + filePath?: string + }> + const trackId = ev.detail?.trackId + const filePath = ev.detail?.filePath + if (!trackId || !filePath) return + const displayName = filePath.split('/').pop() ?? filePath + submitFromPalette( + `Let's work on the \`${trackId}\` track in this note. Please load the \`tracks\` skill first, then ask me what I want to change.`, + { path: filePath, displayName }, + ) + } + window.addEventListener('rowboat:open-copilot-edit-track', handler as EventListener) + return () => window.removeEventListener('rowboat:open-copilot-edit-track', handler as EventListener) + }, [submitFromPalette]) + const toggleKnowledgePane = useCallback(() => { setIsRightPaneMaximized(false) setIsChatSidebarOpen(prev => !prev) @@ -4560,6 +4582,7 @@ function App() { /> + \n\n` so the HTML block starts and ends on +// its own line. +function preprocessTrackTargets(md: string): string { + return md + .replace( + /\n?\n?/g, + (_m, id: string) => `\n\n
\n\n`, + ) + .replace( + /\n?\n?/g, + (_m, id: string) => `\n\n
\n\n`, + ) +} + // Post-process to clean up any zero-width spaces in the output function postprocessMarkdown(markdown: string): string { // Remove lines that contain only the zero-width space marker @@ -140,6 +167,12 @@ function blockToMarkdown(node: JsonNode): string { return serializeList(node, 0).join('\n') case 'taskBlock': return '```task\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'trackBlock': + return '```track\n' + (node.attrs?.data as string || '') + '\n```' + case 'trackTargetOpen': + return `` + case 'trackTargetClose': + return `` case 'imageBlock': return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' case 'embedBlock': @@ -638,6 +671,9 @@ export const MarkdownEditor = forwardRef s.split('\n').map(line => line.trimEnd()).join('\n').trim() if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) { isInternalUpdate.current = true - // Pre-process to preserve blank lines - const preprocessed = preprocessMarkdown(content) + // Pre-process to preserve blank lines, then wrap track-target comment + // regions into placeholder divs so TrackTargetExtension can pick them up. + const preprocessed = preprocessMarkdown(preprocessTrackTargets(content)) // Treat tab-open content as baseline: do not add hydration to undo history. editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run() isInternalUpdate.current = false diff --git a/apps/x/apps/renderer/src/components/track-modal.tsx b/apps/x/apps/renderer/src/components/track-modal.tsx new file mode 100644 index 00000000..8e261977 --- /dev/null +++ b/apps/x/apps/renderer/src/components/track-modal.tsx @@ -0,0 +1,522 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { z } from 'zod' +import '@/styles/track-modal.css' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { + Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap, + Trash2, ChevronDown, ChevronUp, +} from 'lucide-react' +import { parse as parseYaml } from 'yaml' +import { Streamdown } from 'streamdown' +import { TrackBlockSchema, type TrackSchedule } from '@x/shared/dist/track-block.js' +import { useTrackStatus } from '@/hooks/use-track-status' +import type { OpenTrackModalDetail } from '@/extensions/track-block' + +function formatDateTime(iso: string): string { + const d = new Date(iso) + return d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) +} + +// --------------------------------------------------------------------------- +// Schedule helpers +// --------------------------------------------------------------------------- + +const CRON_PHRASES: Record = { + '* * * * *': 'Every minute', + '*/5 * * * *': 'Every 5 minutes', + '*/15 * * * *': 'Every 15 minutes', + '*/30 * * * *': 'Every 30 minutes', + '0 * * * *': 'Hourly', + '0 */2 * * *': 'Every 2 hours', + '0 */6 * * *': 'Every 6 hours', + '0 */12 * * *': 'Every 12 hours', + '0 0 * * *': 'Daily at midnight', + '0 8 * * *': 'Daily at 8 AM', + '0 9 * * *': 'Daily at 9 AM', + '0 12 * * *': 'Daily at noon', + '0 18 * * *': 'Daily at 6 PM', + '0 9 * * 1-5': 'Weekdays at 9 AM', + '0 17 * * 1-5': 'Weekdays at 5 PM', + '0 0 * * 0': 'Sundays at midnight', + '0 0 * * 1': 'Mondays at midnight', + '0 0 1 * *': 'First of each month', +} + +function describeCron(expr: string): string { + return CRON_PHRASES[expr.trim()] ?? expr +} + +type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt' +type ScheduleSummary = { icon: ScheduleIconKind; text: string } + +function summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary { + if (!schedule) return { icon: 'bolt', text: 'Manual only' } + if (schedule.type === 'once') { + return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` } + } + if (schedule.type === 'cron') { + return { icon: 'timer', text: describeCron(schedule.expression) } + } + if (schedule.type === 'window') { + return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}–${schedule.endTime}` } + } + return { icon: 'calendar', text: 'Scheduled' } +} + +function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) { + if (icon === 'timer') return + if (icon === 'calendar' || icon === 'target') return + return +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- + +type Tab = 'what' | 'when' | 'event' | 'details' + +export function TrackModal() { + const [open, setOpen] = useState(false) + const [detail, setDetail] = useState(null) + const [yaml, setYaml] = useState('') + const [loading, setLoading] = useState(false) + const [activeTab, setActiveTab] = useState('what') + const [editingRaw, setEditingRaw] = useState(false) + const [rawDraft, setRawDraft] = useState('') + const [showAdvanced, setShowAdvanced] = useState(false) + const [confirmingDelete, setConfirmingDelete] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const textareaRef = useRef(null) + + // Listen for the open event and seed modal state. + useEffect(() => { + const handler = (e: Event) => { + const ev = e as CustomEvent + const d = ev.detail + if (!d?.trackId || !d?.filePath) return + setDetail(d) + setYaml(d.initialYaml ?? '') + setActiveTab('what') + setEditingRaw(false) + setRawDraft('') + setShowAdvanced(false) + setConfirmingDelete(false) + setError(null) + setOpen(true) + void fetchFresh(d) + } + window.addEventListener('rowboat:open-track-modal', handler as EventListener) + return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => { + try { + setLoading(true) + const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) }) + if (res?.success && res.yaml) { + setYaml(res.yaml) + } else if (res?.error) { + setError(res.error) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + }, []) + + const track = useMemo | null>(() => { + if (!yaml) return null + try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null } + }, [yaml]) + + const trackId = track?.trackId ?? detail?.trackId ?? '' + const instruction = track?.instruction ?? '' + const active = track?.active ?? true + const schedule = track?.schedule + const eventMatchCriteria = track?.eventMatchCriteria ?? '' + const lastRunAt = track?.lastRunAt ?? '' + const lastRunId = track?.lastRunId ?? '' + const lastRunSummary = track?.lastRunSummary ?? '' + const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule]) + const triggerType: 'scheduled' | 'event' | 'manual' = + schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual' + + const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : '' + + const allTrackStatus = useTrackStatus() + const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const } + const isRunning = runState.status === 'running' + + useEffect(() => { + if (editingRaw && textareaRef.current) { + textareaRef.current.focus() + textareaRef.current.setSelectionRange( + textareaRef.current.value.length, + textareaRef.current.value.length, + ) + } + }, [editingRaw]) + + const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [ + { key: 'what', label: 'What to track', visible: true }, + { key: 'when', label: 'When to run', visible: !!schedule }, + { key: 'event', label: 'Event matching', visible: !!eventMatchCriteria }, + { key: 'details', label: 'Details', visible: true }, + ] + const shown = visibleTabs.filter(t => t.visible) + + useEffect(() => { + if (!shown.some(t => t.key === activeTab)) setActiveTab('what') + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [schedule, eventMatchCriteria]) + + // ------------------------------------------------------------------------- + // IPC-backed mutations + // ------------------------------------------------------------------------- + + const runUpdate = useCallback(async (updates: Record) => { + if (!detail) return + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('track:update', { + trackId: detail.trackId, + filePath: stripKnowledgePrefix(detail.filePath), + updates, + }) + if (res?.success && res.yaml) { + setYaml(res.yaml) + } else if (res?.error) { + setError(res.error) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [detail]) + + const handleToggleActive = useCallback(() => { + void runUpdate({ active: !active }) + }, [active, runUpdate]) + + const handleRun = useCallback(async () => { + if (!detail || isRunning) return + try { + await window.ipc.invoke('track:run', { + trackId: detail.trackId, + filePath: stripKnowledgePrefix(detail.filePath), + }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, [detail, isRunning]) + + const handleSaveRaw = useCallback(async () => { + if (!detail) return + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('track:replaceYaml', { + trackId: detail.trackId, + filePath: stripKnowledgePrefix(detail.filePath), + yaml: rawDraft, + }) + if (res?.success && res.yaml) { + setYaml(res.yaml) + setEditingRaw(false) + } else if (res?.error) { + setError(res.error) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [detail, rawDraft]) + + const handleDelete = useCallback(async () => { + if (!detail) return + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('track:delete', { + trackId: detail.trackId, + filePath: stripKnowledgePrefix(detail.filePath), + }) + if (res?.success) { + // Tell the editor to remove the node so Tiptap's next save doesn't + // re-create the track block on disk. + try { detail.onDeleted() } catch { /* editor may have unmounted */ } + setOpen(false) + } else if (res?.error) { + setError(res.error) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [detail]) + + const handleEditWithCopilot = useCallback(() => { + if (!detail) return + window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', { + detail: { + trackId: detail.trackId, + filePath: detail.filePath, + }, + })) + setOpen(false) + }, [detail]) + + if (!detail) return null + + return ( + + +
+
+
+ +
+
+ + + {trackId || 'Track'} + + + + {scheduleSummary.text} + {eventMatchCriteria && triggerType === 'scheduled' && ( + · also event-driven + )} + + +
+
+
+ +
+
+ + {/* Tabs */} +
+ {shown.map(tab => ( + + ))} +
+ + {/* Body */} +
+ {loading &&
Loading latest…
} + + {activeTab === 'what' && ( +
+ {instruction + ? {instruction} + : No instruction set.} +
+ )} + + {activeTab === 'when' && schedule && ( +
+
+ + {scheduleSummary.text} +
+
+
Type
{schedule.type}
+ {schedule.type === 'cron' && ( + <> +
Expression
{schedule.expression}
+ + )} + {schedule.type === 'window' && ( + <> +
Expression
{schedule.cron}
+
Window
{schedule.startTime} – {schedule.endTime}
+ + )} + {schedule.type === 'once' && ( + <> +
Runs at
{formatDateTime(schedule.runAt)}
+ + )} +
+
+ )} + + {activeTab === 'event' && ( +
+ {eventMatchCriteria + ? {eventMatchCriteria} + : No event matching set.} +
+ )} + + {activeTab === 'details' && ( +
+
+
Track ID
{trackId}
+
File
{detail.filePath}
+
Status
{active ? 'Active' : 'Paused'}
+ {lastRunAt && (<> +
Last run
{formatDateTime(lastRunAt)}
+ )} + {lastRunId && (<> +
Run ID
{lastRunId}
+ )} + {lastRunSummary && (<> +
Summary
{lastRunSummary}
+ )} +
+
+ )} + + {/* Advanced (raw YAML) — all tabs */} +
+ + {showAdvanced && ( +
+