From efe2a93d8a801ad80444a1b150462c61992238c3 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:40:09 +0530 Subject: [PATCH 01/35] move top icons to sidebar buttons --- apps/x/apps/renderer/src/App.tsx | 102 ++---------------- .../src/components/sidebar-content.tsx | 89 +++++++++++++++ 2 files changed, 98 insertions(+), 93 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1ada0045..b8a6290d 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon, Globe } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -454,28 +454,12 @@ function FixedSidebarToggle({ onNavigateForward, canNavigateBack, canNavigateForward, - onNewChat, - onOpenSearch, - meetingState, - meetingSummarizing, - meetingAvailable, - onToggleMeeting, - isBrowserOpen, - onToggleBrowser, leftInsetPx, }: { onNavigateBack: () => void onNavigateForward: () => void canNavigateBack: boolean canNavigateForward: boolean - onNewChat: () => void - onOpenSearch: () => void - meetingState: MeetingTranscriptionState - meetingSummarizing: boolean - meetingAvailable: boolean - onToggleMeeting: () => void - isBrowserOpen: boolean - onToggleBrowser: () => void leftInsetPx: number }) { const { toggleSidebar, state } = useSidebar() @@ -493,74 +477,6 @@ function FixedSidebarToggle({ > - - - {meetingAvailable && ( - - - - - - {meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'connecting' ? 'Starting transcription...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'} - - - )} - - - - - {isBrowserOpen ? 'Close browser' : 'Open browser'} - {/* Back / Forward navigation */} {isCollapsed && ( <> @@ -4149,6 +4065,14 @@ function App() { }} backgroundTasks={backgroundTasks} selectedBackgroundTask={selectedBackgroundTask} + onNewChat={handleNewChatTab} + onOpenSearch={() => setIsSearchOpen(true)} + meetingState={meetingTranscription.state} + meetingSummarizing={meetingSummarizing} + meetingAvailable={voiceAvailable} + onToggleMeeting={() => { void handleToggleMeeting() }} + isBrowserOpen={isBrowserOpen} + onToggleBrowser={handleToggleBrowser} /> { void navigateForward() }} canNavigateBack={canNavigateBack} canNavigateForward={canNavigateForward} - onNewChat={handleNewChatTab} - onOpenSearch={() => setIsSearchOpen(true)} - meetingState={meetingTranscription.state} - meetingSummarizing={meetingSummarizing} - meetingAvailable={voiceAvailable} - onToggleMeeting={() => { void handleToggleMeeting() }} - isBrowserOpen={isBrowserOpen} - onToggleBrowser={handleToggleBrowser} leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0} /> diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 3fcb1acc..9c578dd1 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -12,11 +12,15 @@ import { FilePlus, Folder, FolderPlus, + Globe, AlertTriangle, HelpCircle, Mic, Network, Pencil, + Radio, + SearchIcon, + SquarePen, Table2, Plug, LoaderIcon, @@ -90,6 +94,7 @@ import { SettingsDialog } from "@/components/settings-dialog" import { toast } from "@/lib/toast" import { useBilling } from "@/hooks/useBilling" import { ServiceEvent } from "@x/shared/src/service-events.js" +import type { MeetingTranscriptionState } from "@/hooks/useMeetingTranscription" import z from "zod" interface TreeNode { @@ -172,6 +177,14 @@ type SidebarContentPanelProps = { tasksActions?: TasksActions backgroundTasks?: BackgroundTaskItem[] selectedBackgroundTask?: string | null + onNewChat?: () => void + onOpenSearch?: () => void + meetingState?: MeetingTranscriptionState + meetingSummarizing?: boolean + meetingAvailable?: boolean + onToggleMeeting?: () => void + isBrowserOpen?: boolean + onToggleBrowser?: () => void } & React.ComponentProps const sectionTabs: { id: ActiveSection; label: string }[] = [ @@ -395,6 +408,14 @@ export function SidebarContentPanel({ tasksActions, backgroundTasks = [], selectedBackgroundTask, + onNewChat, + onOpenSearch, + meetingState = 'idle', + meetingSummarizing = false, + meetingAvailable = false, + onToggleMeeting, + isBrowserOpen = false, + onToggleBrowser, ...props }: SidebarContentPanelProps) { const { activeSection, setActiveSection } = useSidebarSection() @@ -488,6 +509,74 @@ export function SidebarContentPanel({ ))} + {/* Quick action buttons */} +
+ {onNewChat && ( + + )} + {onOpenSearch && ( + + )} + {meetingAvailable && onToggleMeeting && ( + + )} + {onToggleBrowser && ( + + )} +
{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) + }} + /> +
) : selectedPath && isBaseFilePath(selectedPath) ? (
void isBrowserOpen?: boolean onToggleBrowser?: () => void + onOpenSuggestedTopics?: () => void } & React.ComponentProps const sectionTabs: { id: ActiveSection; label: string }[] = [ @@ -416,6 +418,7 @@ export function SidebarContentPanel({ onToggleMeeting, isBrowserOpen = false, onToggleBrowser, + onOpenSuggestedTopics, ...props }: SidebarContentPanelProps) { const { activeSection, setActiveSection } = useSidebarSection() @@ -704,6 +707,18 @@ export function SidebarContentPanel({ )}
+ {onOpenSuggestedTopics && ( + + )} + + ) +} + +interface SuggestedTopicsViewProps { + onExploreTopic: (title: string, description: string) => void +} + +export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps) { + const [topics, setTopics] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + try { + const result = await window.ipc.invoke('workspace:readFile', { + path: 'config/suggested-topics.md', + }) + if (cancelled) return + if (result.data) { + setTopics(parseTopics(result.data)) + } + } catch { + if (!cancelled) setError('No suggested topics yet. Check back once your knowledge graph has more data.') + } finally { + if (!cancelled) setLoading(false) + } + } + void load() + return () => { cancelled = true } + }, []) + + const handleExplore = useCallback( + (topic: SuggestedTopicBlock) => { + onExploreTopic(topic.title, topic.description) + }, + [onExploreTopic], + ) + + if (loading) { + return ( +
+ +
+ ) + } + + if (error || topics.length === 0) { + return ( +
+
+ +
+

+ {error ?? 'No suggested topics yet. Check back once your knowledge graph has more data.'} +

+
+ ) + } + + return ( +
+
+
+ +

Suggested Topics

+
+

+ Topics surfaced from your knowledge graph. Explore them to create new notes. +

+
+
+
+ {topics.map((topic, i) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index d94a504f..08455ac6 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -81,3 +81,11 @@ export const TranscriptBlockSchema = z.object({ }); export type TranscriptBlock = z.infer; + +export const SuggestedTopicBlockSchema = z.object({ + title: z.string(), + description: z.string(), + category: z.string().optional(), +}); + +export type SuggestedTopicBlock = z.infer; From eaab438666e0e5e94d08d1ebe42cf2ac8658b64e Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:28:57 +0530 Subject: [PATCH 08/35] feat(suggested-topics): populate and integrate suggested topics --- apps/x/apps/renderer/src/App.tsx | 117 ++++++++++--- .../src/components/sidebar-content.tsx | 29 ++-- .../src/components/suggested-topics-view.tsx | 132 ++++++++++++-- .../assistant/skills/tracks/skill.ts | 15 ++ .../core/src/knowledge/build_graph.ts | 58 ++++++- .../core/src/knowledge/note_creation.ts | 163 ++++++++++++++++-- 6 files changed, 448 insertions(+), 66 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a93a576f..836bc898 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -262,6 +262,60 @@ 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 getSuggestedTopicTargetFolder = (category?: string) => { + const normalized = category?.trim().toLowerCase() + switch (normalized) { + case 'people': + case 'person': + return 'People' + case 'organizations': + case 'organization': + return 'Organizations' + case 'projects': + case 'project': + return 'Projects' + case 'meetings': + case 'meeting': + return 'Meetings' + case 'topics': + case 'topic': + default: + return 'Topics' + } +} + +const buildSuggestedTopicExplorePrompt = ({ + title, + description, + category, +}: { + title: string + description: string + category?: string +}) => { + const folder = getSuggestedTopicTargetFolder(category) + const categoryLabel = category?.trim() || 'Topics' + return [ + 'I am exploring a suggested topic card from the Suggested Topics panel.', + 'This card may represent a person, organization, topic, or project.', + '', + 'Card context:', + `- Title: ${title}`, + `- Category: ${categoryLabel}`, + `- Description: ${description}`, + `- Target folder if we set this up: knowledge/${folder}/`, + '', + `Please start by telling me that you can set up a tracking note for "${title}" under knowledge/${folder}/.`, + 'Then briefly explain what that tracking note would monitor or refresh and ask me if you should set it up.', + 'Do not create or modify anything yet.', + 'Treat a clear confirmation from me as explicit approval to proceed.', + `If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`, + `If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`, + 'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.', + 'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.', + ].join('\n') +} + const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null const hasNumbers = Object.values(usage).some((value) => typeof value === 'number') @@ -585,7 +639,7 @@ function App() { 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 [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean; suggestedTopics: boolean } | null>(null) const [baseConfigByPath, setBaseConfigByPath] = useState>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], @@ -2664,15 +2718,16 @@ function App() { } handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen) { - setExpandedFrom({ path: selectedPath, graph: isGraphOpen }) + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { + setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) } else { setExpandedFrom(null) } setIsRightPaneMaximized(false) setSelectedPath(null) setIsGraphOpen(false) - }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen]) + setIsSuggestedTopicsOpen(false) + }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -2766,19 +2821,26 @@ function App() { const handleOpenFullScreenChat = useCallback(() => { // Remember where we came from so the close button can return - if (selectedPath || isGraphOpen) { - setExpandedFrom({ path: selectedPath, graph: isGraphOpen }) + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { + setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) } setIsRightPaneMaximized(false) setSelectedPath(null) setIsGraphOpen(false) - }, [selectedPath, isGraphOpen]) + setIsSuggestedTopicsOpen(false) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { if (expandedFrom.graph) { setIsGraphOpen(true) + setIsSuggestedTopicsOpen(false) + } else if (expandedFrom.suggestedTopics) { + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(true) } else if (expandedFrom.path) { + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -3179,7 +3241,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -3257,12 +3319,16 @@ function App() { const handleTabKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return - const rightPaneAvailable = Boolean((selectedPath || isGraphOpen) && isChatSidebarOpen) + const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen) const targetPane: ShortcutPane = rightPaneAvailable ? (isRightPaneMaximized ? 'right' : activeShortcutPane) : 'left' - const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen) - const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : selectedPath + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen) + const selectedKnowledgePath = isGraphOpen + ? GRAPH_TAB_PATH + : isSuggestedTopicsOpen + ? SUGGESTED_TOPICS_TAB_PATH + : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath ? (fileTabs.find((tab) => tab.path === selectedKnowledgePath)?.id ?? null) @@ -3316,7 +3382,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [selectedPath, isGraphOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -3341,7 +3407,7 @@ function App() { }), }, })) - if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -3463,14 +3529,14 @@ function App() { }, openGraph: () => { // From chat-only landing state, open graph directly in full knowledge view. - if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'graph' }) }, openBases: () => { - if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -4042,7 +4108,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -4084,7 +4150,7 @@ function App() { onNewChat: handleNewChatTab, onSelectRun: (runIdToLoad) => { cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4095,7 +4161,7 @@ function App() { return } // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. - if (selectedPath || isGraphOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) loadRun(runIdToLoad) return @@ -4119,14 +4185,14 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - if (selectedPath || isGraphOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { handleNewChat() } else { void navigateToView({ type: 'chat', runId: null }) } } } else if (runId === runIdToDelete) { - if (selectedPath || isGraphOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) handleNewChat() } else { @@ -4152,6 +4218,7 @@ function App() { onToggleMeeting={() => { void handleToggleMeeting() }} isBrowserOpen={isBrowserOpen} onToggleBrowser={handleToggleBrowser} + isSuggestedTopicsOpen={isSuggestedTopicsOpen} onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} /> Version history )} - {!selectedPath && !isGraphOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && ( )} + {onOpenSuggestedTopics && ( + + )} @@ -707,18 +724,6 @@ export function SidebarContentPanel({ )} - {onOpenSuggestedTopics && ( - - )} ) } interface SuggestedTopicsViewProps { - onExploreTopic: (title: string, description: string) => void + onExploreTopic: (topic: SuggestedTopicBlock) => void } export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps) { const [topics, setTopics] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [removingIndex, setRemovingIndex] = useState(null) useEffect(() => { let cancelled = false async function load() { try { - const result = await window.ipc.invoke('workspace:readFile', { - path: 'config/suggested-topics.md', - }) + let result + try { + result = await window.ipc.invoke('workspace:readFile', { + path: SUGGESTED_TOPICS_PATH, + }) + } catch { + let legacyResult: { data?: string } | null = null + let legacyPath: string | null = null + for (const path of LEGACY_SUGGESTED_TOPICS_PATHS) { + try { + legacyResult = await window.ipc.invoke('workspace:readFile', { path }) + legacyPath = path + break + } catch { + // Try next legacy location. + } + } + if (!legacyResult || !legacyPath) { + throw new Error('Suggested topics file not found') + } + await window.ipc.invoke('workspace:writeFile', { + path: SUGGESTED_TOPICS_PATH, + data: legacyResult.data, + opts: { encoding: 'utf8' }, + }) + await window.ipc.invoke('workspace:remove', { + path: legacyPath, + opts: { trash: true }, + }) + result = legacyResult + } if (cancelled) return if (result.data) { setTopics(parseTopics(result.data)) @@ -95,11 +171,30 @@ export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps return () => { cancelled = true } }, []) - const handleExplore = useCallback( - (topic: SuggestedTopicBlock) => { - onExploreTopic(topic.title, topic.description) + const handleTrack = useCallback( + async (topic: SuggestedTopicBlock, topicIndex: number) => { + if (removingIndex !== null) return + const nextTopics = topics.filter((_, idx) => idx !== topicIndex) + setRemovingIndex(topicIndex) + setError(null) + try { + await window.ipc.invoke('workspace:writeFile', { + path: SUGGESTED_TOPICS_PATH, + data: serializeTopics(nextTopics), + opts: { encoding: 'utf8' }, + }) + setTopics(nextTopics) + } catch (err) { + console.error('Failed to remove suggested topic:', err) + setError('Failed to update suggested topics. Please try again.') + return + } finally { + setRemovingIndex(null) + } + + onExploreTopic(topic) }, - [onExploreTopic], + [onExploreTopic, removingIndex, topics], ) if (loading) { @@ -131,13 +226,18 @@ export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps

Suggested Topics

- Topics surfaced from your knowledge graph. Explore them to create new notes. + Suggested notes surfaced from your knowledge graph. Track one to start a tracking note.

{topics.map((topic, i) => ( - + { void handleTrack(topic, i) }} + isRemoving={removingIndex === i} + /> ))}
diff --git a/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts b/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts index f60173f4..781c9a03 100644 --- a/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts @@ -180,6 +180,21 @@ Workflow: Ask one question: "Which note should this track live in?" Don't create a new note unless the user asks. +### Suggested Topics exploration flow + +Sometimes the user arrives from the Suggested Topics panel and gives you a prompt like: +- "I am exploring a suggested topic card from the Suggested Topics panel." +- a title, category, description, and target folder such as ` + "`" + `knowledge/Topics/` + "`" + ` or ` + "`" + `knowledge/People/` + "`" + ` + +In that flow: +1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation. +2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed. +3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists. +4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask "which note should this live in?". +5. Use the card title as the default note title / filename unless a small normalization is clearly needed. +6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note. +7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed. + ## The Exact Text to Insert Write it verbatim like this (including the blank line between fence and target): diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index 06fd1194..100af5d8 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -25,6 +25,12 @@ import { getTagDefinitions } from './tag_system.js'; const NOTES_OUTPUT_DIR = path.join(WorkDir, 'knowledge'); const NOTE_CREATION_AGENT = 'note_creation'; +const SUGGESTED_TOPICS_REL_PATH = 'suggested-topics.md'; +const SUGGESTED_TOPICS_PATH = path.join(WorkDir, 'suggested-topics.md'); +const LEGACY_SUGGESTED_TOPICS_REL_PATH = 'config/suggested-topics.md'; +const LEGACY_SUGGESTED_TOPICS_PATH = path.join(WorkDir, 'config', 'suggested-topics.md'); +const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_REL_PATH = 'knowledge/Notes/Suggested Topics.md'; +const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH = path.join(WorkDir, 'knowledge', 'Notes', 'Suggested Topics.md'); // Configuration for the graph builder service const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds @@ -88,6 +94,49 @@ function extractPathFromToolInput(input: string): string | null { } } +function ensureSuggestedTopicsFileLocation(): string { + if (fs.existsSync(SUGGESTED_TOPICS_PATH)) { + return SUGGESTED_TOPICS_PATH; + } + + const legacyCandidates: Array<{ absPath: string; relPath: string }> = [ + { absPath: LEGACY_SUGGESTED_TOPICS_PATH, relPath: LEGACY_SUGGESTED_TOPICS_REL_PATH }, + { absPath: LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH, relPath: LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_REL_PATH }, + ]; + + for (const legacy of legacyCandidates) { + if (!fs.existsSync(legacy.absPath)) { + continue; + } + + try { + fs.renameSync(legacy.absPath, SUGGESTED_TOPICS_PATH); + console.log(`[buildGraph] Moved suggested topics file from ${legacy.relPath} to ${SUGGESTED_TOPICS_REL_PATH}`); + return SUGGESTED_TOPICS_PATH; + } catch (error) { + console.error(`[buildGraph] Failed to move suggested topics file from ${legacy.relPath} to ${SUGGESTED_TOPICS_REL_PATH}:`, error); + return legacy.absPath; + } + } + + return SUGGESTED_TOPICS_PATH; +} + +function readSuggestedTopicsFile(): string { + try { + const suggestedTopicsPath = ensureSuggestedTopicsFileLocation(); + if (!fs.existsSync(suggestedTopicsPath)) { + return '_No existing suggested topics file._'; + } + + const content = fs.readFileSync(suggestedTopicsPath, 'utf-8').trim(); + return content.length > 0 ? content : '_Existing suggested topics file is empty._'; + } catch (error) { + console.error(`[buildGraph] Error reading suggested topics file:`, error); + return '_Failed to read existing suggested topics file._'; + } +} + /** * Get unprocessed voice memo files from knowledge/Voice Memos/ * Voice memos are created directly in this directory by the UI. @@ -203,6 +252,7 @@ async function createNotesFromBatch( const run = await createRun({ agentId: NOTE_CREATION_AGENT, }); + const suggestedTopicsContent = readSuggestedTopicsFile(); // Build message with index and all files in the batch let message = `Process the following ${files.length} source files and create/update obsidian notes.\n\n`; @@ -210,8 +260,9 @@ async function createNotesFromBatch( message += `- Use the KNOWLEDGE BASE INDEX below to resolve entities - DO NOT grep/search for existing notes\n`; message += `- Extract entities (people, organizations, projects, topics) from ALL files below\n`; message += `- Create or update notes in "knowledge" directory (workspace-relative paths like "knowledge/People/Name.md")\n`; + message += `- You may also create or update "${SUGGESTED_TOPICS_REL_PATH}" to maintain curated suggested-topic cards\n`; message += `- If the same entity appears in multiple files, merge the information into a single note\n`; - message += `- Use workspace tools to read existing notes (when you need full content) and write updates\n`; + message += `- Use workspace tools to read existing notes or "${SUGGESTED_TOPICS_REL_PATH}" (when you need full content) and write updates\n`; message += `- Follow the note templates and guidelines in your instructions\n\n`; // Add the knowledge base index @@ -219,6 +270,11 @@ async function createNotesFromBatch( message += knowledgeIndex; message += `\n---\n\n`; + message += `# Current Suggested Topics File\n\n`; + message += `Path: ${SUGGESTED_TOPICS_REL_PATH}\n\n`; + message += suggestedTopicsContent; + message += `\n\n---\n\n`; + // Add each file's content message += `# Source Files to Process\n\n`; files.forEach((file, idx) => { diff --git a/apps/x/packages/core/src/knowledge/note_creation.ts b/apps/x/packages/core/src/knowledge/note_creation.ts index 1d8aa32d..1740bdb7 100644 --- a/apps/x/packages/core/src/knowledge/note_creation.ts +++ b/apps/x/packages/core/src/knowledge/note_creation.ts @@ -485,9 +485,9 @@ RESOLVED (use canonical name with absolute path): - "Acme", "Acme Corp", "@acme.com" → [[Organizations/Acme Corp]] - "the pilot", "the integration" → [[Projects/Acme Integration]] -NEW ENTITIES (create notes if source passes filters): +NEW ENTITIES (create notes or suggestion cards if source passes filters): - "Jennifer" (CTO, Acme Corp) → Create [[People/Jennifer]] or [[People/Jennifer (Acme Corp)]] -- "SOC 2" → Create [[Topics/Security Compliance]] +- "SOC 2" → Add or update a suggestion card in \`suggested-topics.md\` with category \`Topics\` AMBIGUOUS (flag or skip): - "Mike" (no context) → Mention in activity only, don't create note @@ -508,8 +508,8 @@ For entities not resolved to existing notes, determine if they warrant new notes **CREATE a note for people who are:** - External (not @user.domain) -- Attendees in meetings -- Email correspondents (emails that reach this step already passed label-based filtering) +- People you directly interacted with in meetings +- Email correspondents directly participating in the thread (emails that reach this step already passed label-based filtering) - Decision makers or contacts at customers, prospects, or partners - Investors or potential investors - Candidates you are interviewing @@ -521,6 +521,7 @@ For entities not resolved to existing notes, determine if they warrant new notes - Large group meeting attendees you didn't interact with - Internal colleagues (@user.domain) - Assistants handling only logistics +- People mentioned only as third parties ("we work with X", "I can introduce you to Y") when there has been no direct interaction yet ### Role Inference @@ -579,31 +580,155 @@ For people who don't warrant their own note, add to Organization note's Contacts - Sarah Lee — Support, handled wire transfer issue \`\`\` +### Direct Interaction Test (People and Organizations) + +For **new canonical People and Organizations notes**, require **direct interaction**, not just mention. + +**Direct interaction = YES** +- The person sent the email, replied in the thread, or was directly addressed as part of the active exchange +- The person participated in the meeting, and there is evidence the user actually interacted with them or the meeting centered on them +- The organization is directly represented in the exchange by participants/senders and is part of an active first-degree relationship with the user or team +- The user is directly evaluating, selling to, buying from, partnering with, interviewing, or coordinating with that person or organization + +**Direct interaction = NO** +- Someone else mentions them in passing +- A sender says they work with someone at another company +- A sender offers to introduce the user to someone +- A company is referenced as a customer, partner, employer, competitor, or example, but nobody from that company is directly involved in the interaction +- The source only establishes a second-degree relationship, not a direct one + +**Canonical note rule:** +- For **new People/Organizations**, create the canonical note only if both are true: + 1. There is **direct interaction** + 2. The entity clears the **weekly importance test** + +If an entity seems strategically relevant but fails the direct interaction test, do **not** auto-create a canonical note. At most, create a suggestion card in \`suggested-topics.md\`. + +### Weekly Importance Test (People and Organizations only) + +For **People** and **Organizations**, the final gate for **creating a new canonical note** is an importance test: + +**Ask:** _"If I were the user, would I realistically need to look at this note on a weekly basis over the near term?"_ + +This test is mainly for **People** and **Organizations**. **Do NOT use it as the decision rule for Topic or Project suggestions.** + +**Strong YES signals:** +- Active customer, prospect, investor, partner, candidate, advisor, or strategic vendor relationship +- Repeated interaction or a likely ongoing cadence +- Decision-maker, owner, blocker, evaluator, or approver in an active process +- Material relevance to launch, sales, fundraising, hiring, compliance, product delivery, or another current priority +- The user would benefit from a durable reference note instead of repeatedly reopening raw emails or meeting transcripts + +**Strong NO signals:** +- One-off logistics, scheduling, or transactional contact +- Assistant, support rep, recruiter, or vendor rep with no ongoing strategic role +- Incidental attendee mentioned once with no leverage on current work +- Passing mention with no evidence of an ongoing relationship + +**Borderline signals:** +- Seems potentially important, but there isn't enough evidence yet that the user will need a weekly reference note +- Might become important soon, but the role, relationship, or repeated relevance is still unclear +- Important enough to track, but only through second-degree mention or an offered introduction rather than direct interaction + +**Outcome rules for new People/Organizations:** +- **Clear YES + direct interaction** → Create/update the canonical \`People/\` or \`Organizations/\` note +- **Borderline or no direct interaction, but still strategically relevant** → Do **not** create the canonical note yet; instead create or update a card in \`suggested-topics.md\` +- **Clear NO** → Skip note creation and do not add a suggestion unless the source strongly suggests near-term strategic relevance + +**When a canonical note already exists:** +- Update the existing note even if the current source is weaker; the importance test is mainly for deciding whether to create a **new** People/Organization note +- If a previously tentative person/org is now clearly important enough for a canonical note, create/update the note and remove any tentative suggestion card for that exact entity from \`suggested-topics.md\` + ## Organizations **CREATE a note if:** -- Someone from that org attended a meeting -- They're a customer, prospect, investor, or partner -- Someone from that org sent relevant personalized correspondence +- There is direct interaction with that org in the source +- They're a customer, prospect, investor, or partner in a direct first-degree interaction +- Someone from that org sent relevant personalized correspondence or joined a meeting you actually had with them +- They pass the weekly importance test above **DO NOT create for:** - Tool/service providers mentioned in passing - One-time transactional vendors - Consumer service companies +- Organizations only referenced through third-party mention or offered introductions ## Projects -**CREATE a note if:** +**If a project note already exists:** update it. + +**If no project note exists:** do **not** create a new canonical note in \`knowledge/Projects/\`. + +Instead, create or update a **suggestion card** in \`suggested-topics.md\` if the project is strong enough: - Discussed substantively in a meeting or email thread - Has a goal and timeline - Involves multiple interactions +Otherwise skip it. + +Projects do **not** use the weekly importance test above. For **new** projects, the default output is a suggestion card, not a canonical note. + ## Topics -**CREATE a note if:** +**If a topic note already exists:** update it. + +**If no topic note exists:** do **not** create a new canonical note in \`knowledge/Topics/\`. + +Instead, create or update a **suggestion card** in \`suggested-topics.md\` if the topic is strong enough: - Recurring theme discussed - Will come up again across conversations +Otherwise skip it. + +Topics do **not** use the weekly importance test above. For **new** topics, the default output is a suggestion card, not a canonical note. + +## Suggested Topics Curation + +Also maintain \`suggested-topics.md\` as a **curated shortlist** of things worth exploring next. + +Despite the filename, \`suggested-topics.md\` can contain cards for **People, Organizations, Topics, or Projects**. + +There are **two reasons** to add or update a suggestion card: + +1. **High-quality Topic/Project cards** + - Use these for topics or projects that are timely, high-leverage, strategically important, or clearly worth exploring now + - These are not a dump of every topic/project note. Be selective + - For **new** topics and projects, cards are the default output from this pipeline + +2. **Tentative People/Organization cards** + - Use these when a person or organization seems important enough to track, but you are **not 100% sure** they clear the weekly-importance test for a canonical note yet + - The card should capture why they might matter and what still needs verification + +**Do NOT add cards for:** +- Low-signal administrative or transactional entities +- Stale or completed items with no near-term relevance +- People/organizations that already have a clearly established canonical note, unless the card is about a distinct project/topic exploration rather than the entity itself + +**Card guidance:** +- For **Topics/Projects**, use category \`Topics\` or \`Projects\` +- For tentative **People/Organizations**, use category \`People\` or \`Organizations\` +- Title should be concise and canonical when possible +- Description should explain why it matters **now** +- For tentative People/Organizations, description should also mention what is still uncertain or what the user should verify + +**Curation rules:** +- Maintain a **high-quality set**, not an ever-growing backlog +- Deduplicate by normalized title +- Prefer current, actionable, recurring, or strategically important items +- Keep only the strongest **8-12 cards total** +- Preserve good existing cards unless the new source clearly supersedes them +- Remove stale cards that are no longer relevant +- If a tentative People/Organization card later becomes clearly important and you create a canonical note, remove the tentative card + +**File format for \`suggested-topics.md\`:** +\`\`\`suggestedtopic +{"title":"Security Compliance","description":"Summarize the current compliance posture, blockers, and customer implications.","category":"Topics"} +\`\`\` + +The file should start with \`# Suggested Topics\` followed by one or more blocks in that format. + +If the file does not exist, create it. If it exists, update it in place or rewrite the full file so the final result is clean, deduped, and curated. + --- # Step 6: Extract Content @@ -824,7 +949,7 @@ If new info contradicts existing: # Step 9: Write Updates -## 9a: Create and Update Notes +## 9a: Create and Update Notes and Suggested Topic Cards **IMPORTANT: Write sequentially, one file at a time.** - Generate content for exactly one note. @@ -852,6 +977,12 @@ workspace-edit({ }) \`\`\` +**For \`suggested-topics.md\`:** +- Use workspace-relative path \`suggested-topics.md\` +- Read the current file if you need the latest content +- Use \`workspace-writeFile\` to create or rewrite the file when that is simpler and cleaner +- Use \`workspace-edit\` for small targeted edits only if that keeps the file deduped and readable + ## 9b: Apply State Changes For each state change identified in Step 7, update the relevant fields. @@ -867,8 +998,9 @@ If you discovered new name variants during resolution, add them to Aliases field - Be concise: one line per activity entry - Note state changes with \`[Field → value]\` in activity - Escape quotes properly in shell commands -- Write only one file per response (no multi-file write batches) +- Write only one file per response (notes and \`suggested-topics.md\` follow the same rule) - **Always set \`Last update\`** in the Info section to the YYYY-MM-DD date of the source email or meeting. When updating an existing note, update this field to the new source event's date. +- Keep \`suggested-topics.md\` curated, deduped, and capped to the strongest 8-12 cards --- @@ -957,8 +1089,12 @@ Before completing, verify: **Filtering:** - [ ] Excluded self (user.name, user.email, @user.domain) - [ ] Applied relevance test to each person +- [ ] Applied the direct interaction test to new People/Organizations +- [ ] Applied the weekly importance test to new People/Organizations - [ ] Transactional contacts in Org Contacts, not People notes - [ ] Source correctly classified (process vs skip) +- [ ] Third-party mentions did not become new canonical People/Organizations notes +- [ ] Borderline People/Organizations became suggestion cards instead of canonical notes **Content Quality:** - [ ] Summaries describe relationship, not communication method @@ -978,8 +1114,11 @@ Before completing, verify: - [ ] All entity mentions use \`[[Folder/Name]]\` absolute links - [ ] Activity entries are reverse chronological - [ ] No duplicate activity entries +- [ ] \`suggested-topics.md\` stays deduped and curated +- [ ] High-quality Topics/Projects were added to suggested topics only when timely and useful +- [ ] New Topics/Projects were not auto-created as canonical notes - [ ] Dates are YYYY-MM-DD - [ ] Bidirectional links are consistent - [ ] New notes in correct folders `; -} \ No newline at end of file +} From 1f58c1f6cbf83abd7887452ef0c4afbc5c142fb8 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:22:03 +0530 Subject: [PATCH 09/35] fix build issue --- apps/x/apps/renderer/src/components/suggested-topics-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/components/suggested-topics-view.tsx b/apps/x/apps/renderer/src/components/suggested-topics-view.tsx index 41de2204..4440aba9 100644 --- a/apps/x/apps/renderer/src/components/suggested-topics-view.tsx +++ b/apps/x/apps/renderer/src/components/suggested-topics-view.tsx @@ -143,7 +143,7 @@ export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps // Try next legacy location. } } - if (!legacyResult || !legacyPath) { + if (!legacyResult || !legacyPath || legacyResult.data === undefined) { throw new Error('Suggested topics file not found') } await window.ipc.invoke('workspace:writeFile', { From acc655172dc1fbc0bbf32e60159125c4f7fa1521 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:10:40 +0530 Subject: [PATCH 10/35] Iframe (#502) Added iframe block --- apps/x/apps/main/src/main.ts | 9 + .../src/components/markdown-editor.tsx | 4 + .../renderer/src/extensions/iframe-block.tsx | 256 +++++++ apps/x/apps/renderer/src/styles/editor.css | 104 +++ .../assistant/skills/doc-collab/skill.ts | 14 +- .../x/packages/core/src/local-sites/server.ts | 606 +++++++++++++++++ .../core/src/local-sites/templates.ts | 625 ++++++++++++++++++ apps/x/packages/shared/src/blocks.ts | 25 + 8 files changed, 1642 insertions(+), 1 deletion(-) create mode 100644 apps/x/apps/renderer/src/extensions/iframe-block.tsx create mode 100644 apps/x/packages/core/src/local-sites/server.ts create mode 100644 apps/x/packages/core/src/local-sites/templates.ts diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 750c7495..eea21481 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -25,6 +25,7 @@ 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 { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; @@ -291,6 +292,11 @@ app.whenReady().then(async () => { // start chrome extension sync server initChromeSync(); + // start local sites server for iframe dashboards and other mini apps + initLocalSites().catch((error) => { + console.error('[LocalSites] Failed to start:', error); + }); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); @@ -309,4 +315,7 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); + shutdownLocalSites().catch((error) => { + console.error('[LocalSites] Failed to shut down cleanly:', error); + }); }); diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 3d22c646..49915dc0 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -13,6 +13,7 @@ import { TrackBlockExtension } from '@/extensions/track-block' import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target' import { ImageBlockExtension } from '@/extensions/image-block' import { EmbedBlockExtension } from '@/extensions/embed-block' +import { IframeBlockExtension } from '@/extensions/iframe-block' import { ChartBlockExtension } from '@/extensions/chart-block' import { TableBlockExtension } from '@/extensions/table-block' import { CalendarBlockExtension } from '@/extensions/calendar-block' @@ -177,6 +178,8 @@ function blockToMarkdown(node: JsonNode): string { return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' case 'embedBlock': return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'iframeBlock': + return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```' case 'chartBlock': return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```' case 'tableBlock': @@ -676,6 +679,7 @@ export const MarkdownEditor = forwardRef }; deleteNode: () => void }) { + const raw = node.attrs.data as string + let config: blocks.IframeBlock | null = null + + try { + config = blocks.IframeBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + if (!config) { + return ( + +
+ + Invalid iframe block +
+
+ ) + } + + const visibleTitle = config.title?.trim() || '' + const title = visibleTitle || 'Embedded page' + const allow = config.allow || DEFAULT_IFRAME_ALLOW + const initialHeight = config.height ?? DEFAULT_IFRAME_HEIGHT + const [frameHeight, setFrameHeight] = useState(() => readCachedIframeHeight(config.url, initialHeight)) + const [frameReady, setFrameReady] = useState(false) + const iframeRef = useRef(null) + const loadFallbackTimerRef = useRef(null) + const autoResizeReadyTimerRef = useRef(null) + const frameReadyRef = useRef(false) + + useEffect(() => { + setFrameHeight(readCachedIframeHeight(config.url, initialHeight)) + setFrameReady(false) + frameReadyRef.current = false + if (loadFallbackTimerRef.current !== null) { + window.clearTimeout(loadFallbackTimerRef.current) + loadFallbackTimerRef.current = null + } + if (autoResizeReadyTimerRef.current !== null) { + window.clearTimeout(autoResizeReadyTimerRef.current) + autoResizeReadyTimerRef.current = null + } + }, [config.url, initialHeight, raw]) + + useEffect(() => { + frameReadyRef.current = frameReady + }, [frameReady]) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const iframeWindow = iframeRef.current?.contentWindow + if (!iframeWindow || event.source !== iframeWindow) return + + const message = parseIframeHeightMessage(event) + if (!message) return + + if (loadFallbackTimerRef.current !== null) { + window.clearTimeout(loadFallbackTimerRef.current) + loadFallbackTimerRef.current = null + } + if (autoResizeReadyTimerRef.current !== null) { + window.clearTimeout(autoResizeReadyTimerRef.current) + } + writeCachedIframeHeight(config.url, message.height) + setFrameHeight((currentHeight) => ( + Math.abs(currentHeight - message.height) < HEIGHT_UPDATE_THRESHOLD ? currentHeight : message.height + )) + + if (!frameReadyRef.current) { + autoResizeReadyTimerRef.current = window.setTimeout(() => { + setFrameReady(true) + frameReadyRef.current = true + autoResizeReadyTimerRef.current = null + }, AUTO_RESIZE_SETTLE_MS) + } + } + + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [config.url]) + + useEffect(() => { + return () => { + if (loadFallbackTimerRef.current !== null) { + window.clearTimeout(loadFallbackTimerRef.current) + } + if (autoResizeReadyTimerRef.current !== null) { + window.clearTimeout(autoResizeReadyTimerRef.current) + } + } + }, []) + + return ( + +
+ + {visibleTitle &&
{visibleTitle}
} +
+ {!frameReady && ( +