{activeSection === "knowledge" && (
From a240ff777fadddbf01415e0b916965b14a989a82 Mon Sep 17 00:00:00 2001
From: Arjun <6592213+arkml@users.noreply.github.com>
Date: Wed, 15 Apr 2026 22:48:45 +0530
Subject: [PATCH 02/35] clipboard copy in assistant works as expected
---
apps/x/apps/main/src/main.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts
index 97704225..750c7495 100644
--- a/apps/x/apps/main/src/main.ts
+++ b/apps/x/apps/main/src/main.ts
@@ -116,7 +116,7 @@ protocol.registerSchemesAsPrivileged([
},
]);
-const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture"]);
+const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture", "clipboard-read", "clipboard-sanitized-write"]);
function configureSessionPermissions(targetSession: Session): void {
targetSession.setPermissionCheckHandler((_webContents, permission) => {
From e71107320ce1952c1acdeaee6bfd5733617058b9 Mon Sep 17 00:00:00 2001
From: Arjun <6592213+arkml@users.noreply.github.com>
Date: Wed, 15 Apr 2026 23:13:49 +0530
Subject: [PATCH 03/35] improve instructions for assistant creating notes
---
.../x/apps/renderer/src/components/sidebar-content.tsx | 4 +---
.../core/src/application/assistant/instructions.ts | 3 ++-
.../application/assistant/skills/doc-collab/skill.ts | 10 +++++-----
3 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx
index 9c578dd1..fe9e9111 100644
--- a/apps/x/apps/renderer/src/components/sidebar-content.tsx
+++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx
@@ -1097,9 +1097,7 @@ function countFiles(node: TreeNode): number {
}
/** Display name overrides for top-level knowledge folders */
-const FOLDER_DISPLAY_NAMES: Record = {
- Notes: 'My Notes',
-}
+const FOLDER_DISPLAY_NAMES: Record = {}
// Tree component for file browser
function Tree({
diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts
index 32d22fd9..70bf5bb8 100644
--- a/apps/x/packages/core/src/application/assistant/instructions.ts
+++ b/apps/x/packages/core/src/application/assistant/instructions.ts
@@ -104,7 +104,8 @@ Unlike other AI assistants that start cold every session, you have access to a l
When a user asks you to prep them for a call with someone, you already know every prior decision, concerns they've raised, and commitments on both sides - because memory has been accumulating across every email and call, not reconstructed on demand.
## The Knowledge Graph
-The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into four categories:
+The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into these categories:
+- **Notes/** - Default location for user-authored notes. Create new notes here unless the user specifies a different folder.
- **People/** - Notes on individuals, tracking relationships, decisions, and commitments
- **Organizations/** - Notes on companies and teams
- **Projects/** - Notes on ongoing initiatives and workstreams
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 f5f63c17..917f1153 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
@@ -71,24 +71,24 @@ workspace-grep({ pattern: "[name]", path: "knowledge/" })
- Ask: "Which document would you like to work on?"
**Creating new documents:**
-1. Ask simply: "Shall I create [filename]?" (don't ask about location - default to \`knowledge/\` root)
+1. Ask simply: "Shall I create [filename]?" (don't ask about location - default to \`knowledge/Notes/\` unless the user specifies a different folder)
2. Create it with just a title - don't pre-populate with structure or outlines
3. Ask: "What would you like in this?"
\`\`\`
workspace-createFile({
- path: "knowledge/[Document Name].md",
+ path: "knowledge/Notes/[Document Name].md",
content: "# [Document Title]\n\n"
})
\`\`\`
**WRONG approach:**
-- "Should this be in Projects/ or Topics/?" - don't ask, just use root
+- "Should this be in Projects/ or Topics/?" - don't ask, just use \`knowledge/Notes/\`
- "Here's a proposed outline..." - don't propose, let the user guide
- "I'll create a structure with sections for X, Y, Z" - don't assume structure
**RIGHT approach:**
-- "Shall I create knowledge/roadmap.md?"
+- "Shall I create knowledge/Notes/roadmap.md?"
- *creates file with just the title*
- "Created. What would you like in this?"
@@ -167,11 +167,11 @@ workspace-readFile("knowledge/Projects/[Project].md")
## Document Locations
Documents are stored in \`knowledge/\` within the workspace root, with subfolders:
+- \`Notes/\` - **Default location for user notes. Create new notes here unless the user specifies a different folder.**
- \`People/\` - Notes about individuals
- \`Organizations/\` - Notes about companies, teams
- \`Projects/\` - Project documentation
- \`Topics/\` - Subject matter notes
-- Root level for general documents
## Rich Blocks
From ebc56b5312cbe74956bdc5f62084cf8157d673df Mon Sep 17 00:00:00 2001
From: Arjun <6592213+arkml@users.noreply.github.com>
Date: Wed, 15 Apr 2026 23:26:57 +0530
Subject: [PATCH 04/35] removed unused import
---
apps/x/apps/renderer/src/App.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx
index b8a6290d..eaf411e1 100644
--- a/apps/x/apps/renderer/src/App.tsx
+++ b/apps/x/apps/renderer/src/App.tsx
@@ -88,7 +88,7 @@ import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
import { toast } from "sonner"
import { useVoiceMode } from '@/hooks/useVoiceMode'
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
-import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
+import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
import * as analytics from '@/lib/analytics'
From 933df9c4a87f1d30e42957d8e6718ddc344b468b Mon Sep 17 00:00:00 2001
From: Arjun <6592213+arkml@users.noreply.github.com>
Date: Thu, 16 Apr 2026 16:24:26 +0530
Subject: [PATCH 05/35] assistant knows middle pane
---
apps/x/apps/main/src/ipc.ts | 2 +-
apps/x/apps/renderer/src/App.tsx | 32 +++++++++++++++++++
apps/x/packages/core/src/agents/runtime.ts | 20 ++++++++++++
.../core/src/application/lib/message-queue.ts | 9 ++++--
apps/x/packages/core/src/runs/runs.ts | 6 ++--
apps/x/packages/shared/src/ipc.ts | 12 +++++++
6 files changed, 75 insertions(+), 6 deletions(-)
diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts
index ec1a0aaa..a9de9572 100644
--- a/apps/x/apps/main/src/ipc.ts
+++ b/apps/x/apps/main/src/ipc.ts
@@ -455,7 +455,7 @@ export function setupIpcHandlers() {
return runsCore.createRun(args);
},
'runs:createMessage': async (_event, args) => {
- return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled) };
+ return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) };
},
'runs:authorizePermission': async (_event, args) => {
await runsCore.authorizePermission(args.runId, args.authorization);
diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx
index eaf411e1..14cc1140 100644
--- a/apps/x/apps/renderer/src/App.tsx
+++ b/apps/x/apps/renderer/src/App.tsx
@@ -2070,6 +2070,34 @@ function App() {
return cleanup
}, [handleRunEvent])
+ type MiddlePaneContextPayload =
+ | { kind: 'note'; path: string; content: string }
+ | { kind: 'browser'; url: string; title: string }
+ const buildMiddlePaneContext = async (): Promise => {
+ // Nothing visible in the middle pane when the right pane is maximized.
+ if (isRightPaneMaximized) return undefined
+
+ // Browser is an overlay on top of any note — when it's open, it's what the user is looking at.
+ if (isBrowserOpen) {
+ try {
+ const state = await window.ipc.invoke('browser:getState', null)
+ const activeTab = state.tabs.find((t) => t.id === state.activeTabId)
+ if (activeTab) {
+ return { kind: 'browser', url: activeTab.url, title: activeTab.title }
+ }
+ } catch {
+ // fall through to no-context if browser state is unavailable
+ }
+ return undefined
+ }
+
+ // Note case: only markdown files are meaningfully readable as context.
+ const path = selectedPathRef.current
+ if (!path || !path.endsWith('.md')) return undefined
+ const content = editorContentRef.current ?? ''
+ return { kind: 'note', path, content }
+ }
+
const handlePromptSubmit = async (
message: PromptInputMessage,
mentions?: FileMention[],
@@ -2173,12 +2201,14 @@ function App() {
// Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema.
const attachmentPayload = contentParts as unknown as string
+ const middlePaneContext = await buildMiddlePaneContext()
await window.ipc.invoke('runs:createMessage', {
runId: currentRunId,
message: attachmentPayload,
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
+ middlePaneContext,
})
analytics.chatMessageSent({
voiceInput: pendingVoiceInputRef.current || undefined,
@@ -2186,12 +2216,14 @@ function App() {
searchEnabled: searchEnabled || undefined,
})
} else {
+ const middlePaneContext = await buildMiddlePaneContext()
await window.ipc.invoke('runs:createMessage', {
runId: currentRunId,
message: userMessage,
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
+ middlePaneContext,
})
analytics.chatMessageSent({
voiceInput: pendingVoiceInputRef.current || undefined,
diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts
index 507bd0c7..f978449b 100644
--- a/apps/x/packages/core/src/agents/runtime.ts
+++ b/apps/x/packages/core/src/agents/runtime.ts
@@ -878,6 +878,10 @@ export async function* streamAgent({
let voiceInput = false;
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
+ let middlePaneContext:
+ | { kind: 'note'; path: string; content: string }
+ | { kind: 'browser'; url: string; title: string }
+ | null = null;
while (true) {
// Check abort at the top of each iteration
signal.throwIfAborted();
@@ -1005,6 +1009,9 @@ export async function* streamAgent({
if (msg.voiceOutput) {
voiceOutput = msg.voiceOutput;
}
+ // Middle pane is NOT sticky — it should reflect the state at the moment of the
+ // latest user message. If the user closed the pane between messages, clear it.
+ middlePaneContext = msg.middlePaneContext ?? null;
loopLogger.log('dequeued user message', msg.messageId);
yield* processEvent({
runId,
@@ -1051,6 +1058,19 @@ export async function* streamAgent({
if (agentNotesContext) {
instructionsWithDateTime += `\n\n${agentNotesContext}`;
}
+ // Always inject a Middle Pane section so the LLM has a clear, up-to-date signal
+ // that supersedes any earlier middle-pane mention in the conversation history.
+ const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`;
+ if (!middlePaneContext) {
+ loopLogger.log('injecting middle pane context (empty)');
+ instructionsWithDateTime += `${middlePaneHeader}**Nothing relevant is open in the middle pane right now.** The user is not looking at any note or web page. If earlier in this conversation you referenced a note or browser page as "what the user is viewing", that is no longer accurate — do not refer to it as currently open. Answer the user's latest message on its own merits.`;
+ } else if (middlePaneContext.kind === 'note') {
+ loopLogger.log('injecting middle pane context (note)', middlePaneContext.path);
+ instructionsWithDateTime += `${middlePaneHeader}The user has a note open. Its path and full content are provided below so you can reference it when relevant.\n\n**How to use this context:**\n- The user may or may not be talking about this note. Do NOT assume every message is about it.\n- Only reference or act on this note when the user's message clearly relates to it (e.g. "this note", "what I'm looking at", "here", "above", "below", or questions whose subject is plainly this note's content).\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see this note unless it is relevant to the answer.\n\n## Open note path\n${middlePaneContext.path}\n\n## Open note content\n\`\`\`\n${middlePaneContext.content}\n\`\`\``;
+ } else if (middlePaneContext.kind === 'browser') {
+ loopLogger.log('injecting middle pane context (browser)', middlePaneContext.url);
+ instructionsWithDateTime += `${middlePaneHeader}The user has the embedded browser open and is viewing a web page. Only the URL and page title are shown below — the page content itself is NOT included here. If you need the page content to answer, use the browser tools available to you to read the page.\n\n**How to use this context:**\n- The user may or may not be talking about this page. Do NOT assume every message is about it.\n- Only reference or act on this page when the user's message clearly relates to it (e.g. "this page", "this article", "what I'm looking at", "this site", "summarize this").\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see the browser unless it is relevant to the answer.\n\n## Current page\nURL: ${middlePaneContext.url}\nTitle: ${middlePaneContext.title}`;
+ }
}
if (voiceInput) {
loopLogger.log('voice input enabled, injecting voice input prompt');
diff --git a/apps/x/packages/core/src/application/lib/message-queue.ts b/apps/x/packages/core/src/application/lib/message-queue.ts
index d60b51b1..b3d2affa 100644
--- a/apps/x/packages/core/src/application/lib/message-queue.ts
+++ b/apps/x/packages/core/src/application/lib/message-queue.ts
@@ -4,6 +4,9 @@ import z from "zod";
export type UserMessageContentType = z.infer;
export type VoiceOutputMode = 'summary' | 'full';
+export type MiddlePaneContext =
+ | { kind: 'note'; path: string; content: string }
+ | { kind: 'browser'; url: string; title: string };
type EnqueuedMessage = {
messageId: string;
@@ -11,10 +14,11 @@ type EnqueuedMessage = {
voiceInput?: boolean;
voiceOutput?: VoiceOutputMode;
searchEnabled?: boolean;
+ middlePaneContext?: MiddlePaneContext;
};
export interface IMessageQueue {
- enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise;
+ enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise;
dequeue(runId: string): Promise;
}
@@ -30,7 +34,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
- async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise {
+ async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise {
if (!this.store[runId]) {
this.store[runId] = [];
}
@@ -41,6 +45,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
voiceInput,
voiceOutput,
searchEnabled,
+ middlePaneContext,
});
return id;
}
diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts
index 30c9bd67..8ea4688b 100644
--- a/apps/x/packages/core/src/runs/runs.ts
+++ b/apps/x/packages/core/src/runs/runs.ts
@@ -1,6 +1,6 @@
import z from "zod";
import container from "../di/container.js";
-import { IMessageQueue, UserMessageContentType, VoiceOutputMode } from "../application/lib/message-queue.js";
+import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } from "../application/lib/message-queue.js";
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
import { IRunsRepo } from "./repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
@@ -19,9 +19,9 @@ export async function createRun(opts: z.infer): Promise
return run;
}
-export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise {
+export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise {
const queue = container.resolve('messageQueue');
- const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled);
+ const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext);
const runtime = container.resolve('agentRuntime');
runtime.trigger(runId);
return id;
diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts
index f0de64af..cc98f4f1 100644
--- a/apps/x/packages/shared/src/ipc.ts
+++ b/apps/x/packages/shared/src/ipc.ts
@@ -137,6 +137,18 @@ const ipcSchemas = {
voiceInput: z.boolean().optional(),
voiceOutput: z.enum(['summary', 'full']).optional(),
searchEnabled: z.boolean().optional(),
+ middlePaneContext: z.discriminatedUnion('kind', [
+ z.object({
+ kind: z.literal('note'),
+ path: z.string(),
+ content: z.string(),
+ }),
+ z.object({
+ kind: z.literal('browser'),
+ url: z.string(),
+ title: z.string(),
+ }),
+ ]).optional(),
}),
res: z.object({
messageId: z.string(),
From 50df9ed1785067c15032c955e9dc6d0701948651 Mon Sep 17 00:00:00 2001
From: tusharmagar
Date: Tue, 14 Apr 2026 09:39:50 +0530
Subject: [PATCH 06/35] feat(composio): hide composio from copilot whilst the
API key is not set in isSignedIn is false
- Added a function to invalidate the Copilot instructions cache when setting the API key.
- Updated the Composio tools prompt to return an empty string if Composio is not configured, simplifying the user experience.
- Refactored the Copilot instructions to conditionally include Composio-related guidance based on configuration status, improving clarity on third-party service interactions.
- Introduced a new function to build a skill catalog string, allowing for optional exclusion of specific skills, enhancing the skill management capabilities.
---
apps/x/apps/main/src/composio-handler.ts | 1 +
.../src/application/assistant/instructions.ts | 64 ++++++++++++-------
.../src/application/assistant/skills/index.ts | 21 ++++++
3 files changed, 64 insertions(+), 22 deletions(-)
diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts
index 111eb5a5..274cfb2a 100644
--- a/apps/x/apps/main/src/composio-handler.ts
+++ b/apps/x/apps/main/src/composio-handler.ts
@@ -44,6 +44,7 @@ export async function isConfigured(): Promise<{ configured: boolean }> {
export function setApiKey(apiKey: string): { success: boolean; error?: string } {
try {
composioClient.setApiKey(apiKey);
+ invalidateCopilotInstructionsCache();
return { success: true };
} catch (error) {
return {
diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts
index 70bf5bb8..af2d7a20 100644
--- a/apps/x/packages/core/src/application/assistant/instructions.ts
+++ b/apps/x/packages/core/src/application/assistant/instructions.ts
@@ -1,4 +1,4 @@
-import { skillCatalog } from "./skills/index.js"; // eslint-disable-line @typescript-eslint/no-unused-vars -- used in template literal
+import { skillCatalog, buildSkillCatalog } from "./skills/index.js";
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
@@ -12,15 +12,7 @@ const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
*/
async function getComposioToolsPrompt(): Promise {
if (!(await isComposioConfigured())) {
- return `
-## Composio Integrations
-
-**Composio is not configured.** Composio enables integrations with third-party services like Google Sheets, GitHub, Slack, Jira, Notion, LinkedIn, and 20+ others.
-
-When the user asks to interact with any third-party service (e.g., "connect to Google Sheets", "create a GitHub issue"), do NOT attempt to write code, use shell commands, or load the composio-integration skill. Instead, let the user know that these integrations are available through Composio, and they can enable them by adding their Composio API key in **Settings > Tools Library**. They can get their key from https://app.composio.dev/settings.
-
-**Exception — Email and Calendar:** For email-related requests (reading emails, sending emails, drafting replies) or calendar-related requests (checking schedule, listing events), do NOT direct the user to Composio. Instead, tell them to connect their email and calendar in **Settings > Connected Accounts**.
-`;
+ return '';
}
const connectedToolkits = composioAccountsRepo.getConnectedToolkits();
@@ -37,7 +29,29 @@ Load the \`composio-integration\` skill when the user asks to interact with any
`;
}
-export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
+function buildStaticInstructions(composioEnabled: boolean, catalog: string): string {
+ // Conditionally include Composio-related instruction sections
+ const emailDraftSuffix = composioEnabled
+ ? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
+ : ` Do NOT load this skill for reading, fetching, or checking emails.`;
+
+ const thirdPartyBlock = composioEnabled
+ ? `\n**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
+ : '';
+
+ const toolPriority = composioEnabled
+ ? `For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
+ : `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.`;
+
+ const slackToolsLine = composioEnabled
+ ? `- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.\n`
+ : '';
+
+ const composioToolsLine = composioEnabled
+ ? `- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance.\n`
+ : '';
+
+ return `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
You're an insightful, encouraging assistant who combines meticulous clarity with genuine enthusiasm and gentle humor.
@@ -58,11 +72,9 @@ You're an insightful, encouraging assistant who combines meticulous clarity with
## What Rowboat Is
Rowboat is an agentic assistant for everyday work - emails, meetings, projects, and people. Users give you tasks like "draft a follow-up email," "prep me for this meeting," or "summarize where we are with this project." You figure out what context you need, pull from emails and meetings, and get it done.
-**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first. Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.
+**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first.${emailDraftSuffix}
-**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.
-
-**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
+${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
@@ -179,7 +191,7 @@ Use the catalog below to decide which skills to load for each user request. Befo
- Call the \`loadSkill\` tool with the skill's name or path so you can read its guidance string.
- Apply the instructions from every loaded skill while working on the request.
-\${skillCatalog}
+${catalog}
Always consult this catalog first so you load the right skills before taking action.
@@ -206,7 +218,7 @@ Always consult this catalog first so you load the right skills before taking act
## Tool Priority
-For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.
+${toolPriority}
## Execution Reminders
- Explore existing files and structure before creating new assets.
@@ -242,12 +254,11 @@ ${runtimeContextPrompt}
- \`analyzeAgent\` - Agent analysis
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
- \`loadSkill\` - Skill loading
-- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
-- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
+${slackToolsLine}- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
- \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
- \`browser-control\` - Control the embedded browser pane: open sites, inspect the live page, switch tabs, and interact with indexed page elements. **Load the \`browser-control\` skill before using this tool.**
- \`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.
+${composioToolsLine}
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside the workspace root, always use these instead of \`executeCommand\`.
@@ -294,6 +305,10 @@ For browser pages, mention the URL in plain text or use the browser-control tool
**IMPORTANT:** Only use filepath blocks for files that already exist. The card is clickable and opens the file, so it must point to a real file. If you are proposing a path for a file that hasn't been created yet (e.g., "Shall I save it at ~/Documents/report.pdf?"), use inline code (\`~/Documents/report.pdf\`) instead of a filepath block. Use the filepath block only after the file has been written/created successfully.
Never output raw file paths in plain text when they could be wrapped in a filepath block — unless the file does not exist yet.`;
+}
+
+/** Keep backward-compatible export for any external consumers */
+export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
/**
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
@@ -314,9 +329,14 @@ export function invalidateCopilotInstructionsCache(): void {
*/
export async function buildCopilotInstructions(): Promise {
if (cachedInstructions !== null) return cachedInstructions;
+ const composioEnabled = await isComposioConfigured();
+ const catalog = composioEnabled
+ ? skillCatalog
+ : buildSkillCatalog({ excludeIds: ['composio-integration'] });
+ const baseInstructions = buildStaticInstructions(composioEnabled, catalog);
const composioPrompt = await getComposioToolsPrompt();
cachedInstructions = composioPrompt
- ? CopilotInstructions + '\n' + composioPrompt
- : CopilotInstructions;
+ ? baseInstructions + '\n' + composioPrompt
+ : baseInstructions;
return cachedInstructions;
}
diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts
index d22db680..cad23177 100644
--- a/apps/x/packages/core/src/application/assistant/skills/index.ts
+++ b/apps/x/packages/core/src/application/assistant/skills/index.ts
@@ -133,6 +133,27 @@ export const skillCatalog = [
catalogSections.join("\n\n"),
].join("\n");
+/**
+ * Build a skill catalog string, optionally excluding specific skills by ID.
+ */
+export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
+ const entries = options?.excludeIds
+ ? skillEntries.filter(e => !options.excludeIds!.includes(e.id))
+ : skillEntries;
+ const sections = entries.map((entry) => [
+ `## ${entry.title}`,
+ `- **Skill file:** \`${entry.catalogPath}\``,
+ `- **Use it for:** ${entry.summary}`,
+ ].join("\n"));
+ return [
+ "# Rowboat Skill Catalog",
+ "",
+ "Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.",
+ "",
+ sections.join("\n\n"),
+ ].join("\n");
+}
+
const normalizeIdentifier = (value: string) =>
value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
From e9cdd3f6eb8fe854105b5e27fa78029030479dd6 Mon Sep 17 00:00:00 2001
From: tusharmagar
Date: Fri, 17 Apr 2026 21:28:57 +0530
Subject: [PATCH 07/35] feat(ui): add Suggested Topics feature
---
apps/x/apps/renderer/src/App.tsx | 65 +++++++-
.../src/components/sidebar-content.tsx | 15 ++
.../src/components/suggested-topics-view.tsx | 146 ++++++++++++++++++
apps/x/packages/shared/src/blocks.ts | 8 +
4 files changed, 230 insertions(+), 4 deletions(-)
create mode 100644 apps/x/apps/renderer/src/components/suggested-topics-view.tsx
diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx
index 14cc1140..a93a576f 100644
--- a/apps/x/apps/renderer/src/App.tsx
+++ b/apps/x/apps/renderer/src/App.tsx
@@ -15,6 +15,7 @@ import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-vi
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
import { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content';
+import { SuggestedTopicsView } from '@/components/suggested-topics-view';
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import {
Conversation,
@@ -129,6 +130,7 @@ const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
const TITLEBAR_BUTTONS_COLLAPSED = 4
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
+const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
const clampNumber = (value: number, min: number, max: number) =>
@@ -257,6 +259,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
}
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
+const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => {
@@ -439,6 +442,7 @@ type ViewState =
| { type: 'file'; path: string }
| { type: 'graph' }
| { type: 'task'; name: string }
+ | { type: 'suggested-topics' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false
@@ -580,6 +584,7 @@ function App() {
const [recentWikiFiles, setRecentWikiFiles] = useState([])
const [isGraphOpen, setIsGraphOpen] = useState(false)
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
+ const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@@ -875,6 +880,7 @@ function App() {
const getFileTabTitle = useCallback((tab: FileTab) => {
if (isGraphTabPath(tab.path)) return 'Graph View'
+ if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
@@ -2570,9 +2576,17 @@ function App() {
if (isGraphTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(true)
+ setIsSuggestedTopicsOpen(false)
+ return
+ }
+ if (isSuggestedTopicsTabPath(tab.path)) {
+ setSelectedPath(null)
+ setIsGraphOpen(false)
+ setIsSuggestedTopicsOpen(true)
return
}
setIsGraphOpen(false)
+ setIsSuggestedTopicsOpen(false)
setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized])
@@ -2600,6 +2614,7 @@ function App() {
setActiveFileTabId(null)
setSelectedPath(null)
setIsGraphOpen(false)
+ setIsSuggestedTopicsOpen(false)
return []
}
const idx = prev.findIndex(t => t.id === tabId)
@@ -2612,8 +2627,14 @@ function App() {
if (isGraphTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(true)
+ setIsSuggestedTopicsOpen(false)
+ } else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
+ setSelectedPath(null)
+ setIsGraphOpen(false)
+ setIsSuggestedTopicsOpen(true)
} else {
setIsGraphOpen(false)
+ setIsSuggestedTopicsOpen(false)
setSelectedPath(newActiveTab.path)
}
}
@@ -2767,10 +2788,11 @@ function App() {
const currentViewState = React.useMemo(() => {
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
+ if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId }
- }, [selectedBackgroundTask, selectedPath, isGraphOpen, runId])
+ }, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1]
@@ -2816,6 +2838,17 @@ function App() {
setActiveFileTabId(id)
}, [fileTabs])
+ const ensureSuggestedTopicsFileTab = useCallback(() => {
+ const existing = fileTabs.find((tab) => isSuggestedTopicsTabPath(tab.path))
+ if (existing) {
+ setActiveFileTabId(existing.id)
+ return
+ }
+ const id = newFileTabId()
+ setFileTabs((prev) => [...prev, { id, path: SUGGESTED_TOPICS_TAB_PATH }])
+ setActiveFileTabId(id)
+ }, [fileTabs])
+
const applyViewState = useCallback(async (view: ViewState) => {
switch (view.type) {
case 'file':
@@ -2824,6 +2857,7 @@ function App() {
// Navigating to a file dismisses the browser overlay so the file is
// visible in the middle pane.
setIsBrowserOpen(false)
+ setIsSuggestedTopicsOpen(false)
setExpandedFrom(null)
// Preserve split vs knowledge-max mode when navigating knowledge files.
// Only exit chat-only maximize, because that would hide the selected file.
@@ -2837,6 +2871,7 @@ function App() {
setSelectedBackgroundTask(null)
setSelectedPath(null)
setIsBrowserOpen(false)
+ setIsSuggestedTopicsOpen(false)
setExpandedFrom(null)
setIsGraphOpen(true)
ensureGraphFileTab()
@@ -2848,10 +2883,21 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
+ setIsSuggestedTopicsOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name)
return
+ case 'suggested-topics':
+ setSelectedPath(null)
+ setIsGraphOpen(false)
+ setIsBrowserOpen(false)
+ setExpandedFrom(null)
+ setIsRightPaneMaximized(false)
+ setSelectedBackgroundTask(null)
+ setIsSuggestedTopicsOpen(true)
+ ensureSuggestedTopicsFileTab()
+ return
case 'chat':
setSelectedPath(null)
setIsGraphOpen(false)
@@ -2860,6 +2906,7 @@ function App() {
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
+ setIsSuggestedTopicsOpen(false)
if (view.runId) {
await loadRun(view.runId)
} else {
@@ -2867,7 +2914,7 @@ function App() {
}
return
}
- }, [ensureFileTabForPath, ensureGraphFileTab, handleNewChat, isRightPaneMaximized, loadRun])
+ }, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState
@@ -4105,6 +4152,7 @@ function App() {
onToggleMeeting={() => { void handleToggleMeeting() }}
isBrowserOpen={isBrowserOpen}
onToggleBrowser={handleToggleBrowser}
+ onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
/>
- {(selectedPath || isGraphOpen) && fileTabs.length >= 1 ? (
+ {(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? (
t.id}
onSwitchTab={switchFileTab}
onCloseTab={closeFileTab}
- allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
+ allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/>
) : (
+ ) : isSuggestedTopicsOpen ? (
+
+ {
+ const prompt = `I'd like to explore the topic: ${title}. ${description}`
+ submitFromPalette(prompt, null)
+ }}
+ />
+