diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index e063bb4d..94f96663 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -41,6 +41,7 @@ import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js' import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js'; import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js'; import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js'; +import type { CodeModeManager } from '@x/core/dist/code-mode/acp/manager.js'; import * as codeGit from '@x/core/dist/code-mode/git/service.js'; import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js'; import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js'; @@ -981,6 +982,10 @@ export function setupIpcHandlers() { const service = container.resolve('codeSessionService'); return { session: await service.update(args.sessionId, args.patch) }; }, + 'codeMode:listModelOptions': async (_event, args) => { + const manager = container.resolve('codeModeManager'); + return manager.listModelOptions(args.agent); + }, 'codeSession:delete': async (_event, args) => { const service = container.resolve('codeSessionService'); disposeTerminal(args.sessionId); @@ -1015,12 +1020,22 @@ export function setupIpcHandlers() { if (!info.isGitRepo) { return { isRepo: false, branch: null, hasCommits: false, files: [] }; } - const files = await codeGit.status(session.cwd); + let files = await codeGit.status(session.cwd); + if (session.worktree && !session.worktree.removedAt && session.worktree.baseBranch) { + const branchFiles = await codeGit.changedSinceBase(session.cwd, session.worktree.baseBranch); + const byPath = new Map(branchFiles.map((file) => [file.path, file])); + for (const file of files) { + if (!byPath.has(file.path)) byPath.set(file.path, file); + } + files = [...byPath.values()]; + } return { isRepo: true, branch: info.branch, hasCommits: info.hasCommits, files }; }, 'codeSession:fileDiff': async (_event, args) => { const session = await requireCodeSession(args.sessionId); - return codeGit.fileDiff(session.cwd, args.path); + return codeGit.fileDiff(session.cwd, args.path, { + baseRef: session.worktree && !session.worktree.removedAt ? session.worktree.baseBranch : null, + }); }, 'codeSession:readdir': async (_event, args) => { const session = await requireCodeSession(args.sessionId); @@ -1615,6 +1630,7 @@ export function setupIpcHandlers() { name: args.name, instructions: args.instructions, ...(args.triggers ? { triggers: args.triggers } : {}), + ...(args.projectId ? { projectId: args.projectId } : {}), ...(args.model ? { model: args.model } : {}), ...(args.provider ? { provider: args.provider } : {}), }); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 1048d9b8..dabdb0ae 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -345,11 +345,11 @@ export async function connectProvider(provider: string, credentials?: { clientId signedInUserId = billing.userId; analyticsIdentify(billing.userId, { ...(billing.userEmail ? { email: billing.userEmail } : {}), - plan: billing.subscriptionPlan, + plan: billing.subscriptionPlanId, status: billing.subscriptionStatus, }); analyticsCapture('user_signed_in', { - plan: billing.subscriptionPlan, + plan: billing.subscriptionPlanId, status: billing.subscriptionStatus, }); } diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 09cf87bf..d8321855 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -726,6 +726,26 @@ background: rgba(0, 0, 0, 0.32); } +.gmail-compose-modal, +.gmail-compose-card { + --gm-bg-card: #ffffff; + --gm-bg-input: #f4f4f7; + --gm-bg-elevated: #ffffff; + --gm-bg-pill: #ffffff; + --gm-bg-pill-hover: #f4f4f7; + --gm-text: #27272a; + --gm-text-strong: #09090b; + --gm-text-muted: #71717a; + --gm-text-body: #3f3f46; + --gm-border: #e4e4e7; + --gm-border-strong: #d4d4d8; + --gm-accent: #7c3aed; + --gm-accent-hover: #6d28d9; + --gm-accent-fg: #ffffff; + --gm-icon-hover-bg: #f4f4f7; + --gm-placeholder: #a1a1aa; +} + .gmail-compose-modal { display: flex; flex-direction: column; @@ -927,12 +947,12 @@ width: max-content; min-width: 280px; max-width: min(440px, 100%); - background: var(--gm-bg-elevated, #1e1e1e); + background: var(--gm-bg-elevated, #ffffff); border: 1px solid var(--gm-border); border-radius: 10px; box-shadow: - 0 1px 2px rgba(0, 0, 0, 0.18), - 0 12px 32px rgba(0, 0, 0, 0.36); + 0 1px 2px rgba(24, 24, 27, 0.08), + 0 12px 32px rgba(24, 24, 27, 0.18); max-height: 296px; overflow-y: auto; overscroll-behavior: contain; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 2f4337a9..f9019ede 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -30,7 +30,7 @@ import { BgTasksView } from '@/components/bg-tasks-view'; import { EmailView } from '@/components/email-view'; import { WorkspaceView } from '@/components/workspace-view'; import { CodingRunBlock } from '@/components/coding-run'; -import { KnowledgeView } from '@/components/knowledge-view'; +import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view'; import { ChatHistoryView } from '@/components/chat-history-view'; import { HomeView } from '@/components/home-view'; import { MeetingsView } from '@/components/meetings-view'; @@ -591,7 +591,7 @@ type ViewState = | { type: 'live-notes' } | { type: 'email' } | { type: 'workspace'; path?: string } - | { type: 'knowledge-view'; folderPath?: string } + | { type: 'knowledge-view'; folderPath?: string; mode?: KnowledgeViewMode } | { type: 'chat-history' } | { type: 'home' } | { type: 'code' } @@ -602,7 +602,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type === 'file' && b.type === 'file') return a.path === b.path if (a.type === 'task' && b.type === 'task') return a.name === b.name if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '') - if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') + if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') && (a.mode ?? '') === (b.mode ?? '') return true // both graph } @@ -652,7 +652,12 @@ function parseDeepLink(input: string): ViewState | null { } case 'knowledge-view': { const folderPath = params.get('folderPath') - return { type: 'knowledge-view', folderPath: folderPath ?? undefined } + const mode = params.get('mode') + return { + type: 'knowledge-view', + folderPath: folderPath ?? undefined, + mode: mode === 'graph' || mode === 'basis' || mode === 'files' ? mode : undefined, + } } case 'chat-history': return { type: 'chat-history' } @@ -775,6 +780,7 @@ function App() { const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false) const [workspaceInitialPath, setWorkspaceInitialPath] = useState(null) const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false) + const [knowledgeViewMode, setKnowledgeViewMode] = useState('graph') // Folder being browsed inside the knowledge view (null = root overview). // Lives in ViewState so folder drill-down participates in back/forward history. const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState(null) @@ -1197,7 +1203,7 @@ function App() { if (isBgTasksTabPath(tab.path)) return 'Background tasks' if (isEmailTabPath(tab.path)) return 'Email' if (isWorkspaceTabPath(tab.path)) return 'Workspace' - if (isKnowledgeViewTabPath(tab.path)) return 'Notes' + if (isKnowledgeViewTabPath(tab.path)) return 'Brain' if (isChatHistoryTabPath(tab.path)) return 'Chat history' if (isHomeTabPath(tab.path)) return 'Home' if (isCodeTabPath(tab.path)) return 'Code' @@ -3627,14 +3633,14 @@ function App() { if (isLiveNotesOpen) return { type: 'live-notes' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined } - if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined } + if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined, mode: knowledgeViewMode } if (isChatHistoryOpen) return { type: 'chat-history' } if (isHomeOpen) return { type: 'home' } if (isCodeOpen) return { type: 'code' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId]) + }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, knowledgeViewMode, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3997,6 +4003,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(true) + setKnowledgeViewMode(view.mode ?? (view.folderPath ? 'files' : 'graph')) setKnowledgeViewFolderPath(view.folderPath ?? null) setIsChatHistoryOpen(false) setIsHomeOpen(false) @@ -4223,10 +4230,9 @@ function App() { setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) }, []) - const handleBaseSave = useCallback(async (name: string | null) => { - if (!selectedPath) return - const isDefault = selectedPath === BASES_DEFAULT_TAB_PATH - const config = baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG + const handleBaseSave = useCallback(async (path: string, name: string | null) => { + const isDefault = path === BASES_DEFAULT_TAB_PATH + const config = baseConfigByPath[path] ?? DEFAULT_BASE_CONFIG if (isDefault && name) { // Save as new base file @@ -4250,14 +4256,14 @@ function App() { // Save in place try { await window.ipc.invoke('workspace:writeFile', { - path: selectedPath, + path, data: JSON.stringify(config, null, 2), }) } catch (err) { console.error('Failed to save base:', err) } } - }, [selectedPath, baseConfigByPath, loadDirectory, navigateToView]) + }, [baseConfigByPath, loadDirectory, navigateToView]) // External search set by app-navigation tool (passed to BasesView) const [externalBaseSearch, setExternalBaseSearch] = useState(undefined) @@ -5121,8 +5127,10 @@ function App() { }, }), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile]) + const isBrainGraphOpen = isKnowledgeViewOpen && knowledgeViewMode === 'graph' + useEffect(() => { - if (!isGraphOpen) return + if (!isGraphOpen && !isBrainGraphOpen) return let cancelled = false const buildGraph = async () => { @@ -5237,7 +5245,7 @@ function App() { return () => { cancelled = true } - }, [isGraphOpen, knowledgeFilePaths]) + }, [isGraphOpen, isBrainGraphOpen, knowledgeFilePaths]) const renderConversationItem = ( item: ConversationItem, @@ -5760,12 +5768,44 @@ function App() { revealInFileManager: knowledgeActions.revealInFileManager, onOpenInNewTab: knowledgeActions.onOpenInNewTab, }} + mode={knowledgeViewMode} + onModeChange={setKnowledgeViewMode} + graphContent={( + { + navigateToFile(path) + }} + /> + )} + basisContent={( + navigateToFile(path)} + config={baseConfigByPath[BASES_DEFAULT_TAB_PATH] ?? DEFAULT_BASE_CONFIG} + onConfigChange={(cfg) => handleBaseConfigChange(BASES_DEFAULT_TAB_PATH, cfg)} + isDefaultBase + onSave={(name) => void handleBaseSave(BASES_DEFAULT_TAB_PATH, name)} + externalSearch={externalBaseSearch} + onExternalSearchConsumed={() => setExternalBaseSearch(undefined)} + actions={{ + rename: knowledgeActions.rename, + remove: knowledgeActions.remove, + copyPath: knowledgeActions.copyPath, + revealInFileManager: knowledgeActions.revealInFileManager, + }} + /> + )} folderPath={knowledgeViewFolderPath} - onNavigateFolder={(path) => { void navigateToView({ type: 'knowledge-view', folderPath: path ?? undefined }) }} + onNavigateFolder={(path) => { + setKnowledgeViewMode('files') + void navigateToView({ type: 'knowledge-view', folderPath: path ?? undefined, mode: 'files' }) + }} onOpenNote={(path) => navigateToFile(path)} - onOpenGraph={() => knowledgeActions.openGraph()} onOpenSearch={() => { setSearchDefaultScope('knowledge'); setIsSearchOpen(true) }} - onOpenBases={() => knowledgeActions.openBases()} onVoiceNoteCreated={handleVoiceNoteCreated} /> @@ -5796,7 +5836,7 @@ function App() { config={baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG} onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)} isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH} - onSave={(name) => void handleBaseSave(name)} + onSave={(name) => void handleBaseSave(selectedPath, name)} externalSearch={externalBaseSearch} onExternalSearchConsumed={() => setExternalBaseSearch(undefined)} actions={{ diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index 0641bc13..c48df79e 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -4,6 +4,7 @@ import { ListChecks, Play, Square, Loader2, Trash2, Plus, X, AlertCircle, Repeat, Clock, Zap, ChevronLeft, ChevronDown, ChevronRight, Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles, + Code2, FolderOpen, LayoutTemplate, } from 'lucide-react' import type { z } from 'zod' import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js' @@ -271,7 +272,16 @@ function TriggersEditor({ // New Task dialog // --------------------------------------------------------------------------- -type DialogMode = 'describe' | 'manual' +type DialogMode = 'describe' | 'manual' | 'templates' | 'coding' + +// Prefills for the "Coding from meetings" preset. +const CODING_PRESET = { + name: 'Implement coding items from meetings', + instructions: `After a meeting's notes are ready, scan them for coding action items (bugs to fix, features to build, concrete changes requested) for me or my team. + +Conservatively implement the clearly-scoped, self-contained ones in the configured repo using the launch-code-task tool — group related items into one session, split unrelated ones. Note ambiguous, large/architectural, or other-repo items as "needs review" instead of coding them. If nothing is actionable, do nothing.`, + eventMatchCriteria: `A meeting's notes or transcript just became available (engineering standup, planning, sprint, or technical discussion) that may contain coding action items, bugs to fix, or features to build.`, +} function NewTaskDialog({ open, @@ -295,6 +305,9 @@ function NewTaskDialog({ const [name, setName] = useState('') const [instructions, setInstructions] = useState('') const [triggers, setTriggers] = useState(undefined) + const [projectId, setProjectId] = useState(undefined) + const [projectName, setProjectName] = useState(undefined) + const [addingProject, setAddingProject] = useState(false) const [submitting, setSubmitting] = useState(false) useEffect(() => { @@ -304,11 +317,64 @@ function NewTaskDialog({ setName('') setInstructions('') setTriggers(undefined) + setProjectId(undefined) + setProjectName(undefined) } }, [open, copilotEnabled]) + // Switch into the coding preset: prefill name/instructions/trigger once. + const enterCodingMode = () => { + setMode('coding') + setName(CODING_PRESET.name) + setInstructions(CODING_PRESET.instructions) + setTriggers({ eventMatchCriteria: CODING_PRESET.eventMatchCriteria }) + } + + const pickRepo = async () => { + setAddingProject(true) + try { + const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose the repository for this task' }) + const dir = res.path + if (!dir) return + const added = await window.ipc.invoke('codeProject:add', { path: dir }) + if (!added.git?.isGitRepo) { + toast('That folder is not a git repository — coding tasks need one.', 'error') + return + } + setProjectId(added.project.id) + setProjectName(added.project.name) + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error') + } finally { + setAddingProject(false) + } + } + const canSubmitDescribe = description.trim().length > 0 && !submitting const canSubmitManual = name.trim().length > 0 && instructions.trim().length > 0 && !submitting + const canSubmitCoding = name.trim().length > 0 && instructions.trim().length > 0 && !!projectId && !submitting + + const submitCoding = async () => { + if (!canSubmitCoding) return + setSubmitting(true) + try { + const result = await window.ipc.invoke('bg-task:create', { + name: name.trim(), + instructions: instructions.trim(), + ...(triggers ? { triggers } : {}), + ...(projectId ? { projectId } : {}), + }) + if (result.success && result.slug) { + onCreated(result.slug) + } else { + toast(result.error ?? 'Failed to create task', 'error') + } + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error') + } finally { + setSubmitting(false) + } + } const submitDescribe = () => { if (!canSubmitDescribe || !onCreateWithCopilot) return @@ -359,7 +425,116 @@ function NewTaskDialog({ - {mode === 'describe' ? ( + {(mode === 'describe' || mode === 'manual') && ( + + )} + + {mode === 'templates' ? ( + <> +
+ {[ + { + id: 'coding-from-meetings', + title: 'Coding from meetings', + description: "When a meeting's notes are ready, scan them for coding action items and auto-implement them in a repo — each on its own isolated branch, with a summary.", + icon: Code2, + onSelect: enterCodingMode, + }, + ].map(preset => ( + + ))} +
+ +
+ + +
+ + ) : mode === 'coding' ? ( + <> +
+
+ + {projectName ? ( +
+ + + {projectName} + + +
+ ) : ( + + )} +

+ Code changes run full-auto in an isolated git worktree — your working checkout is never touched. +

+
+
+ + setName(e.target.value)} /> +
+
+ +