mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/byok-multiple-keys
This commit is contained in:
commit
30373765e7
43 changed files with 1706 additions and 390 deletions
|
|
@ -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>('codeSessionService');
|
||||
return { session: await service.update(args.sessionId, args.patch) };
|
||||
},
|
||||
'codeMode:listModelOptions': async (_event, args) => {
|
||||
const manager = container.resolve<CodeModeManager>('codeModeManager');
|
||||
return manager.listModelOptions(args.agent);
|
||||
},
|
||||
'codeSession:delete': async (_event, args) => {
|
||||
const service = container.resolve<CodeSessionService>('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 } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
|
||||
const [knowledgeViewMode, setKnowledgeViewMode] = useState<KnowledgeViewMode>('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<string | null>(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<string | undefined>(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={(
|
||||
<GraphView
|
||||
nodes={graphData.nodes}
|
||||
edges={graphData.edges}
|
||||
isLoading={false}
|
||||
error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null}
|
||||
onSelectNode={(path) => {
|
||||
navigateToFile(path)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
basisContent={(
|
||||
<BasesView
|
||||
tree={tree}
|
||||
onSelectNote={(path) => 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}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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<Triggers | undefined>(undefined)
|
||||
const [projectId, setProjectId] = useState<string | undefined>(undefined)
|
||||
const [projectName, setProjectName] = useState<string | undefined>(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({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'describe' ? (
|
||||
{(mode === 'describe' || mode === 'manual') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('templates')}
|
||||
className="mb-4 flex w-full items-center justify-between gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-left text-[12px] hover:border-solid hover:bg-accent"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutTemplate className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">View available templates</span>
|
||||
</span>
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{mode === 'templates' ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{
|
||||
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 => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={preset.onSelect}
|
||||
className="flex w-full items-start gap-2.5 rounded-md border bg-muted/40 px-3 py-2.5 text-left hover:border-foreground/30 hover:bg-accent"
|
||||
>
|
||||
<preset.icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[12.5px] font-medium">{preset.title}</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">{preset.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
|
||||
</div>
|
||||
</>
|
||||
) : mode === 'coding' ? (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Repository</label>
|
||||
{projectName ? (
|
||||
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2">
|
||||
<span className="flex items-center gap-2 text-[13px]">
|
||||
<FolderOpen className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{projectName}</span>
|
||||
</span>
|
||||
<button type="button" onClick={pickRepo} className="text-[11px] text-muted-foreground hover:text-foreground" disabled={addingProject}>Change</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={pickRepo} disabled={addingProject}>
|
||||
{addingProject ? <Loader2 className="mr-1 size-3 animate-spin" /> : <FolderOpen className="mr-1 size-3" />}
|
||||
Choose a git repository…
|
||||
</Button>
|
||||
)}
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
Code changes run full-auto in an isolated git worktree — your working checkout is never touched.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Name</label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Instructions</label>
|
||||
<Textarea value={instructions} onChange={e => setInstructions(e.target.value)} rows={6} className="text-[12.5px] leading-relaxed" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Triggers</label>
|
||||
<TriggersEditor value={triggers} onChange={setTriggers} />
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
Prefilled to fire when a meeting's notes become available. Adjust if you want.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>Cancel</Button>
|
||||
<Button size="sm" onClick={submitCoding} disabled={!canSubmitCoding}>
|
||||
{submitting && <Loader2 className="mr-1 size-3 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : mode === 'describe' ? (
|
||||
<>
|
||||
<Textarea
|
||||
value={description}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ArrowUpRight, Bot, Mail, MessageSquare, Sparkles, Telescope } from 'luc
|
|||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { ToolConnectionsCard } from '@/components/tool-connections-card'
|
||||
|
||||
export interface ChatEmptyStateRun {
|
||||
id: string
|
||||
|
|
@ -101,6 +102,8 @@ export function ChatEmptyState({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolConnectionsCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import type { CodingAgent } from '@x/shared/src/code-mode.js'
|
||||
import type { CodeAgentModelOptions, CodeAgentOption } from '@x/shared/src/code-sessions.js'
|
||||
|
||||
// Model + effort choices for a coding agent, discovered live from the engine
|
||||
// (the same list `/model` shows) via the main process, which caches per agent.
|
||||
// We memoize the in-flight/resolved promise per agent here too so reopening the
|
||||
// picker doesn't re-hit IPC. A failed lookup resolves to empty lists so the UI
|
||||
// just falls back to the engine default.
|
||||
const EMPTY: CodeAgentModelOptions = { models: [], efforts: [] }
|
||||
const cache = new Map<CodingAgent, Promise<CodeAgentModelOptions>>()
|
||||
|
||||
export function fetchCodeAgentOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
|
||||
let pending = cache.get(agent)
|
||||
if (!pending) {
|
||||
pending = window.ipc.invoke('codeMode:listModelOptions', { agent }).catch(() => EMPTY)
|
||||
cache.set(agent, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
// Always offer a Default fallback even before options load (or if discovery fails).
|
||||
export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] {
|
||||
return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options]
|
||||
}
|
||||
|
||||
export function optionLabel(options: CodeAgentOption[], value: string | undefined): string {
|
||||
return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default'
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
|
||||
import type { CodeSession, CodeSessionStatus, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
|
||||
import { fetchCodeAgentOptions, withDefault, optionLabel } from './code-agent-options'
|
||||
import type { ApprovalPolicy } from '@x/shared/src/code-mode.js'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -31,6 +32,16 @@ const TERMINAL_HEIGHT_STORAGE_KEY = 'x:code-terminal-height'
|
|||
const TERMINAL_MIN_HEIGHT = 120
|
||||
const TERMINAL_MAX_HEIGHT = 600
|
||||
|
||||
// Remember which session was open so leaving the Code section (which unmounts
|
||||
// this view) and coming back restores the selection — and with it the chat
|
||||
// output in the right pane — instead of dropping back to the empty state.
|
||||
const SELECTED_SESSION_STORAGE_KEY = 'x:code-selected-session'
|
||||
|
||||
function readStoredSelectedSessionId(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(SELECTED_SESSION_STORAGE_KEY) || null
|
||||
}
|
||||
|
||||
function readStoredTerminalHeight(): number {
|
||||
if (typeof window === 'undefined') return 240
|
||||
const raw = Number(window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY))
|
||||
|
|
@ -44,6 +55,11 @@ const POLICY_LABEL: Record<ApprovalPolicy, string> = {
|
|||
'auto-approve-reads': 'Auto-approve reads',
|
||||
yolo: 'Auto-approve everything',
|
||||
}
|
||||
const POLICY_HEADER_LABEL: Record<ApprovalPolicy, string> = {
|
||||
ask: 'Ask',
|
||||
'auto-approve-reads': 'Auto reads',
|
||||
yolo: 'Auto all',
|
||||
}
|
||||
|
||||
export interface ActiveCodeSession {
|
||||
session: CodeSession
|
||||
|
|
@ -65,7 +81,7 @@ export function CodeView({
|
|||
onDiffOpened?: () => void
|
||||
}) {
|
||||
const { projects, sessions, statusOf, refresh } = useCodeSessions()
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(readStoredSelectedSessionId)
|
||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<CodeSession | null>(null)
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
|
|
@ -76,6 +92,11 @@ export function CodeView({
|
|||
window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight))
|
||||
}, [terminalHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSessionId) window.localStorage.setItem(SELECTED_SESSION_STORAGE_KEY, selectedSessionId)
|
||||
else window.localStorage.removeItem(SELECTED_SESSION_STORAGE_KEY)
|
||||
}, [selectedSessionId])
|
||||
|
||||
const handleTerminalDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
dragStateRef.current = { startY: e.clientY, startHeight: terminalHeight }
|
||||
|
|
@ -99,6 +120,17 @@ export function CodeView({
|
|||
const selectedStatus = selectedSession ? statusOf(selectedSession.id) : 'idle'
|
||||
const newSessionProject = projects.find((p) => p.project.id === newSessionProjectId) ?? null
|
||||
|
||||
// Live model/effort choices for the selected session's agent, for the header
|
||||
// pickers. Discovered from the engine and cached, so this is cheap to re-run.
|
||||
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
|
||||
const selectedAgent = selectedSession?.agent
|
||||
useEffect(() => {
|
||||
if (!selectedAgent) { setModelOpts({ models: [], efforts: [] }); return }
|
||||
let cancelled = false
|
||||
void fetchCodeAgentOptions(selectedAgent).then((opts) => { if (!cancelled) setModelOpts(opts) })
|
||||
return () => { cancelled = true }
|
||||
}, [selectedAgent])
|
||||
|
||||
// Tell App which session (and status) owns the right-hand chat pane.
|
||||
useEffect(() => {
|
||||
onSessionSelected?.(selectedSession ? { session: selectedSession, status: selectedStatus } : null)
|
||||
|
|
@ -147,7 +179,7 @@ export function CodeView({
|
|||
}
|
||||
}, [refresh, selectedSessionId])
|
||||
|
||||
const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex' }) => {
|
||||
const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex'; agentModel?: string; agentEffort?: string }) => {
|
||||
if (!selectedSessionId) return
|
||||
try {
|
||||
await window.ipc.invoke('codeSession:update', { sessionId: selectedSessionId, patch })
|
||||
|
|
@ -180,45 +212,97 @@ export function CodeView({
|
|||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{selectedSession ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 border-b px-4 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-start gap-x-3 gap-y-2 border-b px-4 py-2.5">
|
||||
<div className="min-w-64 flex-[1_1_360px]">
|
||||
<div className="truncate text-sm font-medium">{selectedSession.title}</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{AGENT_LABEL[selectedSession.agent]}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate font-mono" title={selectedSession.cwd}>{selectedSession.cwd}</span>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span className="shrink-0 whitespace-nowrap">{AGENT_LABEL[selectedSession.agent]}</span>
|
||||
<span className="shrink-0 text-muted-foreground/50">·</span>
|
||||
<span className="min-w-0 max-w-full flex-1 truncate font-mono" title={selectedSession.cwd}>{selectedSession.cwd}</span>
|
||||
{selectedSession.worktree && !selectedSession.worktree.removedAt && (
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-full bg-muted px-1.5 py-0.5">
|
||||
<span className="flex min-w-0 max-w-72 shrink items-center gap-1 rounded-full bg-muted px-1.5 py-0.5">
|
||||
<GitBranch className="size-3" />
|
||||
{selectedSession.worktree.branch}
|
||||
<span className="truncate">{selectedSession.worktree.branch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground">
|
||||
{POLICY_LABEL[selectedSession.policy]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((policy) => (
|
||||
<DropdownMenuItem key={policy} onClick={() => void handleUpdateSession({ policy })}>
|
||||
{POLICY_LABEL[policy]}
|
||||
{selectedSession.policy === policy && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Bot className="size-3.5" />
|
||||
Rowboat drives
|
||||
<Switch
|
||||
checked={selectedSession.mode === 'rowboat'}
|
||||
disabled={busy}
|
||||
onCheckedChange={(checked) => void handleUpdateSession({ mode: checked ? 'rowboat' : 'direct' })}
|
||||
/>
|
||||
</label>
|
||||
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
title="Coding agent model"
|
||||
>
|
||||
<span className="whitespace-nowrap">{optionLabel(modelOpts.models, selectedSession.agentModel)}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
|
||||
{withDefault(modelOpts.models).map((m) => (
|
||||
<DropdownMenuItem key={m.value} onClick={() => void handleUpdateSession({ agentModel: m.value })}>
|
||||
{m.label}
|
||||
{(selectedSession.agentModel ?? 'default') === m.value && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{modelOpts.efforts.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
title="Reasoning effort"
|
||||
>
|
||||
<span className="whitespace-nowrap">{optionLabel(modelOpts.efforts, selectedSession.agentEffort)}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{withDefault(modelOpts.efforts).map((e) => (
|
||||
<DropdownMenuItem key={e.value} onClick={() => void handleUpdateSession({ agentEffort: e.value })}>
|
||||
{e.label}
|
||||
{(selectedSession.agentEffort ?? 'default') === e.value && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
title={POLICY_LABEL[selectedSession.policy]}
|
||||
>
|
||||
<span className="whitespace-nowrap">{POLICY_HEADER_LABEL[selectedSession.policy]}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((policy) => (
|
||||
<DropdownMenuItem key={policy} onClick={() => void handleUpdateSession({ policy })}>
|
||||
{POLICY_LABEL[policy]}
|
||||
{selectedSession.policy === policy && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Bot className="size-3.5" />
|
||||
<span className="whitespace-nowrap">Rowboat drives</span>
|
||||
<Switch
|
||||
checked={selectedSession.mode === 'rowboat'}
|
||||
disabled={busy}
|
||||
onCheckedChange={(checked) => void handleUpdateSession({ mode: checked ? 'rowboat' : 'direct' })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<WorkspacePane
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionMode } from '@x/shared/src/code-sessions.js'
|
||||
import type { CodeSession, CodeSessionMode, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
|
||||
import { fetchCodeAgentOptions, withDefault } from './code-agent-options'
|
||||
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
|
@ -86,6 +87,11 @@ export function NewSessionDialog({
|
|||
const [modelOptions, setModelOptions] = useState<ModelOption[]>([])
|
||||
// 'default' = let the backend use the configured default model.
|
||||
const [modelKey, setModelKey] = useState('default')
|
||||
// The coding agent's own model + reasoning effort. 'default' leaves the
|
||||
// engine default. Choices are discovered live per agent (see effect below).
|
||||
const [agentModel, setAgentModel] = useState('default')
|
||||
const [agentEffort, setAgentEffort] = useState('default')
|
||||
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
|
||||
|
||||
const git = projectRow?.git
|
||||
const worktreeAvailable = !!git?.isGitRepo && !!git?.hasCommits
|
||||
|
|
@ -97,6 +103,8 @@ export function NewSessionDialog({
|
|||
setIsolation('in-repo')
|
||||
setMode('rowboat')
|
||||
setModelKey('default')
|
||||
setAgentModel('default')
|
||||
setAgentEffort('default')
|
||||
void loadModelOptions().then(setModelOptions)
|
||||
void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => {
|
||||
setAgentStatus(status)
|
||||
|
|
@ -108,6 +116,18 @@ export function NewSessionDialog({
|
|||
})
|
||||
}, [open])
|
||||
|
||||
// Model/effort choices are per-agent (and the saved value from one agent is
|
||||
// meaningless for the other), so reset to defaults and (re)load the live list
|
||||
// whenever the agent changes.
|
||||
useEffect(() => {
|
||||
setAgentModel('default')
|
||||
setAgentEffort('default')
|
||||
setModelOpts({ models: [], efforts: [] })
|
||||
let cancelled = false
|
||||
void fetchCodeAgentOptions(agent).then((opts) => { if (!cancelled) setModelOpts(opts) })
|
||||
return () => { cancelled = true }
|
||||
}, [agent])
|
||||
|
||||
const agentReady = (a: CodingAgent): boolean => {
|
||||
if (!agentStatus) return true
|
||||
const s = agentStatus[a]
|
||||
|
|
@ -129,6 +149,8 @@ export function NewSessionDialog({
|
|||
policy,
|
||||
isolation,
|
||||
...(picked ? { model: picked.model, provider: picked.provider } : {}),
|
||||
...(agentModel !== 'default' ? { agentModel } : {}),
|
||||
...(modelOpts.efforts.length > 0 && agentEffort !== 'default' ? { agentEffort } : {}),
|
||||
})
|
||||
onOpenChange(false)
|
||||
onCreated(res.session)
|
||||
|
|
@ -278,6 +300,41 @@ export function NewSessionDialog({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* The coding agent's own model + reasoning effort, discovered live
|
||||
from the engine and applied to the ACP session each turn (so they
|
||||
stay editable from the session header later). Effort is a separate
|
||||
axis only for Claude; Codex folds it into the model id. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Model</label>
|
||||
<Select value={agentModel} onValueChange={setAgentModel}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withDefault(modelOpts.models).map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{modelOpts.efforts.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Effort</label>
|
||||
<Select value={agentEffort} onValueChange={setAgentEffort}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withDefault(modelOpts.efforts).map((e) => (
|
||||
<SelectItem key={e.value} value={e.value}>{e.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The model only powers Rowboat's own turns; the coding agent uses its
|
||||
own configured model, so hide this entirely for direct sessions. */}
|
||||
{mode === 'rowboat' && modelOptions.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -906,7 +906,7 @@ const AI_GENERATE_SYSTEM =
|
|||
'<the email body as plain text>\n' +
|
||||
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
|
||||
'When recipient names are provided, address them naturally (e.g. "Hi <first name>,"). ' +
|
||||
'When the sender\'s name is provided, sign off with it; otherwise omit the sign-off name ' +
|
||||
'When the sender\'s first name is provided, sign off with that first name only; otherwise omit the sign-off name ' +
|
||||
'(never write a placeholder like "[Your Name]").'
|
||||
|
||||
const AI_REWRITE_SYSTEM =
|
||||
|
|
@ -931,8 +931,13 @@ function parseGeneratedEmail(text: string): { subject: string | null; body: stri
|
|||
return { subject: null, body: text }
|
||||
}
|
||||
|
||||
// Guarantee the sender's name signs off the email. If the model already ended
|
||||
// with the name (e.g. "Best,\nHarsh"), leave it; otherwise append it.
|
||||
function firstNameFromDisplayName(name: string): string {
|
||||
const trimmed = name.trim().replace(/^["']|["']$/g, '')
|
||||
return trimmed.split(/\s+/)[0] || ''
|
||||
}
|
||||
|
||||
// Guarantee the sender's first name signs off the email. If the model already
|
||||
// ended with the name (e.g. "Best,\nHarsh"), leave it; otherwise append it.
|
||||
function ensureSignature(body: string, name: string): string {
|
||||
const signer = name.trim()
|
||||
if (!signer) return body
|
||||
|
|
@ -977,7 +982,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
const [showCc, setShowCc] = useState<boolean>(initialRecipients.cc.length > 0)
|
||||
const [showBcc, setShowBcc] = useState<boolean>(false)
|
||||
const [subject, setSubject] = useState<string>(() => (thread ? composeSubject(mode, thread.subject) : ''))
|
||||
const modeLabel = isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
|
||||
const modeLabel = isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : 'Reply'
|
||||
|
||||
const initialContent = useMemo(() => {
|
||||
if (!thread) return ''
|
||||
|
|
@ -1050,6 +1055,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
|
||||
// The signed-in account's display name, used to sign off AI-generated emails.
|
||||
const [selfName, setSelfName] = useState<string>('')
|
||||
const selfFirstName = useMemo(() => firstNameFromDisplayName(selfName), [selfName])
|
||||
useEffect(() => {
|
||||
if (!isNew) return
|
||||
let cancelled = false
|
||||
|
|
@ -1104,7 +1110,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
})
|
||||
.filter(Boolean)
|
||||
if (recipientNames.length) ctx.push(`Recipient(s): ${recipientNames.join(', ')}`)
|
||||
if (selfName) ctx.push(`Sender's name (sign off as this): ${selfName}`)
|
||||
if (selfFirstName) ctx.push(`Sender's first name (sign off as this): ${selfFirstName}`)
|
||||
if (subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`)
|
||||
if (current) ctx.push(`Existing draft (revise or build on it):\n${current}`)
|
||||
prompt = `${ctx.length ? ctx.join('\n') + '\n\n' : ''}Instruction: ${instruction.trim()}`
|
||||
|
|
@ -1130,8 +1136,8 @@ const ComposeBox = memo(function ComposeBox({
|
|||
if (aiMode === 'generate') {
|
||||
const { subject: generatedSubject, body } = parseGeneratedEmail(res.text)
|
||||
if (generatedSubject) setSubject(generatedSubject)
|
||||
// Always sign off with the account name, even if the model omitted it.
|
||||
const signed = ensureSignature(body, selfName)
|
||||
// Always sign off with the account first name, even if the model omitted it.
|
||||
const signed = ensureSignature(body, selfFirstName)
|
||||
editor.chain().focus().selectAll().insertContent(plainTextToHtml(signed)).run()
|
||||
setHasGenerated(true)
|
||||
} else {
|
||||
|
|
@ -1495,10 +1501,17 @@ function ThreadDetail({
|
|||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const canReplyAll = useMemo(() => {
|
||||
const { to, cc } = buildRecipients('replyAll', thread, selfEmail)
|
||||
return cc.length > 0 || to.length > 1
|
||||
}, [thread, selfEmail])
|
||||
const replyAllRecipients = useMemo(
|
||||
() => buildRecipients('replyAll', thread, selfEmail),
|
||||
[thread, selfEmail],
|
||||
)
|
||||
const canReplyAll = replyAllRecipients.cc.length > 0 || replyAllRecipients.to.length > 1
|
||||
const replyAllButton = canReplyAll ? (
|
||||
<button type="button" onClick={() => setComposeMode('replyAll')}>
|
||||
<ReplyAll size={16} />
|
||||
Reply All
|
||||
</button>
|
||||
) : null
|
||||
|
||||
const toggleExpand = useCallback((index: number) => {
|
||||
setExpandedIndices((prev) => {
|
||||
|
|
@ -1568,16 +1581,11 @@ function ThreadDetail({
|
|||
</div>
|
||||
|
||||
<div className="gmail-thread-actions">
|
||||
{replyAllButton}
|
||||
<button type="button" onClick={() => setComposeMode('reply')}>
|
||||
<Reply size={16} />
|
||||
Reply
|
||||
</button>
|
||||
{canReplyAll && (
|
||||
<button type="button" onClick={() => setComposeMode('replyAll')}>
|
||||
<ReplyAll size={16} />
|
||||
Reply all
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setComposeMode('forward')}>
|
||||
<Forward size={16} />
|
||||
Forward
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowRight, Bot, Calendar, Clock, ExternalLink, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
|
||||
import { ArrowRight, Bot, Calendar, Clock, ExternalLink, FileText, Mail, MessageSquare, Mic, Plus, Video } from 'lucide-react'
|
||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||
import { SettingsDialog } from '@/components/settings-dialog'
|
||||
import { ToolConnectionsCard } from '@/components/tool-connections-card'
|
||||
|
||||
interface TreeNode {
|
||||
path: string
|
||||
|
|
@ -65,7 +65,6 @@ type SlackFeedMessage = {
|
|||
ts: string
|
||||
url?: string
|
||||
}
|
||||
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
|
||||
|
||||
function greeting(): string {
|
||||
const h = new Date().getHours()
|
||||
|
|
@ -187,54 +186,6 @@ function triggerMeetingCapture(event: CalEvent, openConference: boolean) {
|
|||
}
|
||||
|
||||
const CARD = 'rounded-xl border border-border bg-card p-4'
|
||||
const TOOLKIT_PREVIEW_LIMIT = 8
|
||||
|
||||
let cachedToolkitPreviews: ToolkitPreview[] | null = null
|
||||
let cachedToolkitLogosLoaded = false
|
||||
|
||||
function ToolkitPreviewIcon({
|
||||
toolkit,
|
||||
onInvalid,
|
||||
}: {
|
||||
toolkit: ToolkitPreview
|
||||
onInvalid: (slug: string) => void
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<img
|
||||
src={toolkit.logo}
|
||||
alt=""
|
||||
className="hidden"
|
||||
onLoad={(event) => {
|
||||
const img = event.currentTarget
|
||||
if (img.naturalWidth > 1 && img.naturalHeight > 1) {
|
||||
setLoaded(true)
|
||||
} else {
|
||||
onInvalid(toolkit.slug)
|
||||
}
|
||||
}}
|
||||
onError={() => onInvalid(toolkit.slug)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
title={`${toolkit.name}: ${toolkit.description}`}
|
||||
aria-label={toolkit.name}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60"
|
||||
>
|
||||
<img
|
||||
src={toolkit.logo}
|
||||
alt=""
|
||||
className="size-5 shrink-0 object-contain"
|
||||
onError={() => onInvalid(toolkit.slug)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeView({
|
||||
tree,
|
||||
|
|
@ -255,9 +206,6 @@ export function HomeView({
|
|||
const [slackMessages, setSlackMessages] = useState<SlackFeedMessage[]>([])
|
||||
const [slackError, setSlackError] = useState<string | null>(null)
|
||||
const [slackErrorKind, setSlackErrorKind] = useState<string | null>(null)
|
||||
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
|
||||
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
|
||||
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -313,40 +261,7 @@ export function HomeView({
|
|||
}
|
||||
}, [])
|
||||
|
||||
const loadConnectorLogos = useCallback(async () => {
|
||||
if (cachedToolkitLogosLoaded) return
|
||||
try {
|
||||
const configured = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configured.configured) return
|
||||
const toolkits = await window.ipc.invoke('composio:list-toolkits', {})
|
||||
const previews = toolkits.items
|
||||
.filter((toolkit) => Boolean(toolkit.meta.logo))
|
||||
.slice(0, TOOLKIT_PREVIEW_LIMIT)
|
||||
.map((toolkit) => ({
|
||||
slug: toolkit.slug,
|
||||
logo: toolkit.meta.logo,
|
||||
name: toolkit.name,
|
||||
description: toolkit.meta.description,
|
||||
}))
|
||||
cachedToolkitPreviews = previews
|
||||
setToolkitPreviews(previews)
|
||||
} catch {
|
||||
cachedToolkitPreviews = []
|
||||
} finally {
|
||||
cachedToolkitLogosLoaded = true
|
||||
setToolkitLogosLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeToolkitPreview = useCallback((slug: string) => {
|
||||
setToolkitPreviews((prev) => {
|
||||
const next = prev.filter((toolkit) => toolkit.slug !== slug)
|
||||
cachedToolkitPreviews = next
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => { void loadEvents(); void loadEmails(); void loadSlackMessages(); void loadConnectorLogos() }, [loadEvents, loadEmails, loadSlackMessages, loadConnectorLogos])
|
||||
useEffect(() => { void loadEvents(); void loadEmails(); void loadSlackMessages() }, [loadEvents, loadEmails, loadSlackMessages])
|
||||
|
||||
// Upcoming (not-yet-ended) events, soonest first.
|
||||
const upcoming = useMemo(() => {
|
||||
|
|
@ -624,41 +539,7 @@ export function HomeView({
|
|||
)}
|
||||
|
||||
{/* Tool connections */}
|
||||
<div className={CARD}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
|
||||
<Plug className="size-[14px]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13.5px] leading-snug">
|
||||
<span className="font-medium">Connect your tools.</span>
|
||||
<span className="text-muted-foreground"> Bring context from the apps you already use.</span>
|
||||
</div>
|
||||
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
|
||||
{toolkitLogosLoaded && toolkitPreviews.map((toolkit) => (
|
||||
<ToolkitPreviewIcon
|
||||
key={toolkit.slug}
|
||||
toolkit={toolkit}
|
||||
onInvalid={removeToolkitPreview}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectionsSettingsOpen(true)}
|
||||
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
|
||||
>
|
||||
Connections
|
||||
<ArrowRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsDialog
|
||||
defaultTab="connections"
|
||||
open={connectionsSettingsOpen}
|
||||
onOpenChange={setConnectionsSettingsOpen}
|
||||
/>
|
||||
<ToolConnectionsCard />
|
||||
|
||||
{/* Open chat CTA */}
|
||||
{onOpenChat && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
|
|
@ -46,17 +46,21 @@ export type KnowledgeViewActions = {
|
|||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
export type KnowledgeViewMode = 'graph' | 'basis' | 'files'
|
||||
|
||||
type KnowledgeViewProps = {
|
||||
tree: TreeNode[]
|
||||
actions: KnowledgeViewActions
|
||||
mode: KnowledgeViewMode
|
||||
onModeChange: (mode: KnowledgeViewMode) => void
|
||||
graphContent: ReactNode
|
||||
basisContent: ReactNode
|
||||
// Folder currently being browsed (null = root overview). Controlled by the
|
||||
// app so drill-down participates in the global back/forward history.
|
||||
folderPath: string | null
|
||||
onNavigateFolder: (path: string | null) => void
|
||||
onOpenNote: (path: string) => void
|
||||
onOpenGraph: () => void
|
||||
onOpenSearch: () => void
|
||||
onOpenBases: () => void
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -145,12 +149,14 @@ function displayName(node: TreeNode): string {
|
|||
export function KnowledgeView({
|
||||
tree,
|
||||
actions,
|
||||
mode,
|
||||
onModeChange,
|
||||
graphContent,
|
||||
basisContent,
|
||||
folderPath,
|
||||
onNavigateFolder,
|
||||
onOpenNote,
|
||||
onOpenGraph,
|
||||
onOpenSearch,
|
||||
onOpenBases,
|
||||
onVoiceNoteCreated,
|
||||
}: KnowledgeViewProps) {
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
||||
|
|
@ -184,27 +190,46 @@ export function KnowledgeView({
|
|||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-border px-8 py-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Notes</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Brain</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '}
|
||||
{folders.length === 1 ? 'folder' : 'folders'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="inline-flex overflow-hidden rounded-lg border border-border bg-background">
|
||||
<ViewModeButton
|
||||
icon={Network}
|
||||
label="Graph"
|
||||
active={mode === 'graph'}
|
||||
onClick={() => onModeChange('graph')}
|
||||
/>
|
||||
<ViewModeButton
|
||||
icon={Table2}
|
||||
label="Base"
|
||||
active={mode === 'basis'}
|
||||
onClick={() => onModeChange('basis')}
|
||||
/>
|
||||
<ViewModeButton
|
||||
icon={FileText}
|
||||
label="Files"
|
||||
active={mode === 'files'}
|
||||
onClick={() => onModeChange('files')}
|
||||
/>
|
||||
</div>
|
||||
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
|
||||
<SecondaryButton icon={SearchIcon} label="Search" onClick={onOpenSearch} />
|
||||
<SecondaryButton icon={Network} label="Graph" onClick={onOpenGraph} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => actions.createNote(currentFolder?.path)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<FilePlus className="size-4" />
|
||||
<span>New note</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'graph' ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{graphContent}
|
||||
</div>
|
||||
) : mode === 'basis' ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{basisContent}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-3xl px-8 py-6">
|
||||
{currentFolder ? (
|
||||
|
|
@ -267,11 +292,12 @@ export function KnowledgeView({
|
|||
<QuickActions
|
||||
actions={actions}
|
||||
currentFolder={currentFolder}
|
||||
onOpenBases={onOpenBases}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onFolderCreated={setRenameTarget}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -279,12 +305,12 @@ export function KnowledgeView({
|
|||
function QuickActions({
|
||||
actions,
|
||||
currentFolder,
|
||||
onOpenBases,
|
||||
onOpenSearch,
|
||||
onFolderCreated,
|
||||
}: {
|
||||
actions: KnowledgeViewActions
|
||||
currentFolder: TreeNode | null
|
||||
onOpenBases: () => void
|
||||
onOpenSearch: () => void
|
||||
onFolderCreated: (path: string) => void
|
||||
}) {
|
||||
// Inside a folder these target that folder; at the root they target knowledge/.
|
||||
|
|
@ -294,6 +320,7 @@ function QuickActions({
|
|||
<SectionHeader label="Quick actions" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<QuickAction icon={FilePlus} label="New note" onClick={() => actions.createNote(parent)} />
|
||||
<QuickAction icon={SearchIcon} label="Search" onClick={onOpenSearch} />
|
||||
<QuickAction
|
||||
icon={FolderPlus}
|
||||
label="New folder"
|
||||
|
|
@ -304,7 +331,6 @@ function QuickActions({
|
|||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
<QuickAction icon={Table2} label="Open as base" onClick={onOpenBases} />
|
||||
<QuickAction
|
||||
icon={FolderOpen}
|
||||
label={`Reveal in ${getFileManagerName()}`}
|
||||
|
|
@ -315,20 +341,26 @@ function QuickActions({
|
|||
)
|
||||
}
|
||||
|
||||
function SecondaryButton({
|
||||
function ViewModeButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
icon: typeof SearchIcon
|
||||
label: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors',
|
||||
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{label}</span>
|
||||
|
|
@ -532,7 +564,7 @@ function FolderDetail({
|
|||
onClick={() => onNavigate(null)}
|
||||
className="rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Notes
|
||||
Brain
|
||||
</button>
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={c.path} className="flex min-w-0 items-center gap-1.5">
|
||||
|
|
|
|||
|
|
@ -17,17 +17,12 @@ import {
|
|||
import { Separator } from "@/components/ui/separator"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { toast } from "sonner"
|
||||
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
|
||||
import { getBillingPlanData, type BillingUsageBucket } from "@x/shared/dist/billing.js"
|
||||
|
||||
interface AccountSettingsProps {
|
||||
dialogOpen: boolean
|
||||
}
|
||||
|
||||
function formatPlanName(plan: string | null | undefined) {
|
||||
if (!plan) return 'No Plan'
|
||||
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan`
|
||||
}
|
||||
|
||||
function CreditUsageBar({ label, bucket, helper }: {
|
||||
label: string
|
||||
bucket: BillingUsageBucket
|
||||
|
|
@ -62,7 +57,8 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
const [connecting, setConnecting] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
|
||||
const hasPaidSubscription = billing?.subscriptionPlan === 'starter' || billing?.subscriptionPlan === 'pro'
|
||||
const currentPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
|
||||
const hasPaidSubscription = currentPlan?.category === 'starter' || currentPlan?.category === 'pro'
|
||||
|
||||
const checkConnection = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -197,7 +193,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{formatPlanName(billing.subscriptionPlan)}
|
||||
{currentPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
|
||||
</p>
|
||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
|
||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
|
|
@ -209,12 +205,12 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
|||
})() : billing.subscriptionStatus ? (
|
||||
<p className="text-xs text-muted-foreground capitalize">{billing.subscriptionStatus}</p>
|
||||
) : null}
|
||||
{!billing.subscriptionPlan && (
|
||||
{!billing.subscriptionPlanId && (
|
||||
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
|
||||
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
|
||||
{!billing.subscriptionPlanId ? 'Subscribe' : currentPlan?.category === 'free' ? 'Upgrade' : 'Change plan'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import { SettingsDialog } from "@/components/settings-dialog"
|
|||
import { extractConferenceLink } from "@/lib/calendar-event"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { toast } from "@/lib/toast"
|
||||
import { getBillingPlanData } from "@x/shared/dist/billing.js"
|
||||
import { ServiceEvent } from "@x/shared/src/service-events.js"
|
||||
import z from "zod"
|
||||
|
||||
|
|
@ -91,11 +92,6 @@ type KnowledgeActions = {
|
|||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
function formatBillingPlanName(plan: string | null | undefined) {
|
||||
if (!plan) return 'No plan'
|
||||
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} plan`
|
||||
}
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
const diffMs = Math.max(0, Date.now() - ms)
|
||||
const min = Math.floor(diffMs / 60000)
|
||||
|
|
@ -437,6 +433,7 @@ export function SidebarContentPanel({
|
|||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const { billing } = useBilling(isRowboatConnected)
|
||||
const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
|
||||
|
||||
// Nav previews: unread important emails + next upcoming meetings (top 2 each).
|
||||
const [unreadEmailCount, setUnreadEmailCount] = useState(0)
|
||||
|
|
@ -819,7 +816,7 @@ export function SidebarContentPanel({
|
|||
>
|
||||
<FileText className="size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">Knowledge</span>
|
||||
<span className="truncate">Brain</span>
|
||||
{knowledgeUpdatedLabel && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">{knowledgeUpdatedLabel}</span>
|
||||
)}
|
||||
|
|
@ -921,7 +918,7 @@ export function SidebarContentPanel({
|
|||
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-medium capitalize text-sidebar-foreground">
|
||||
{formatBillingPlanName(billing.subscriptionPlan)}
|
||||
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
|
||||
</span>
|
||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
|
||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
|
|
@ -936,7 +933,7 @@ export function SidebarContentPanel({
|
|||
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
|
||||
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
|
||||
>
|
||||
{!billing.subscriptionPlan || billing.subscriptionPlan === 'free' || billing.subscriptionPlan === 'starter' ? 'Upgrade' : 'Manage'}
|
||||
{!billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
138
apps/x/apps/renderer/src/components/tool-connections-card.tsx
Normal file
138
apps/x/apps/renderer/src/components/tool-connections-card.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ArrowRight, Plug } from 'lucide-react'
|
||||
|
||||
import { SettingsDialog } from '@/components/settings-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
|
||||
|
||||
const TOOLKIT_PREVIEW_LIMIT = 8
|
||||
|
||||
let cachedToolkitPreviews: ToolkitPreview[] | null = null
|
||||
let cachedToolkitLogosLoaded = false
|
||||
|
||||
function ToolkitPreviewIcon({
|
||||
toolkit,
|
||||
onInvalid,
|
||||
}: {
|
||||
toolkit: ToolkitPreview
|
||||
onInvalid: (slug: string) => void
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<img
|
||||
src={toolkit.logo}
|
||||
alt=""
|
||||
className="hidden"
|
||||
onLoad={(event) => {
|
||||
const img = event.currentTarget
|
||||
if (img.naturalWidth > 1 && img.naturalHeight > 1) {
|
||||
setLoaded(true)
|
||||
} else {
|
||||
onInvalid(toolkit.slug)
|
||||
}
|
||||
}}
|
||||
onError={() => onInvalid(toolkit.slug)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
title={`${toolkit.name}: ${toolkit.description}`}
|
||||
aria-label={toolkit.name}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60"
|
||||
>
|
||||
<img
|
||||
src={toolkit.logo}
|
||||
alt=""
|
||||
className="size-5 shrink-0 object-contain"
|
||||
onError={() => onInvalid(toolkit.slug)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolConnectionsCard({ className }: { className?: string }) {
|
||||
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
|
||||
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
|
||||
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
|
||||
|
||||
const loadConnectorLogos = useCallback(async () => {
|
||||
if (cachedToolkitLogosLoaded) return
|
||||
try {
|
||||
const configured = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configured.configured) return
|
||||
const toolkits = await window.ipc.invoke('composio:list-toolkits', {})
|
||||
const previews = toolkits.items
|
||||
.filter((toolkit) => Boolean(toolkit.meta.logo))
|
||||
.slice(0, TOOLKIT_PREVIEW_LIMIT)
|
||||
.map((toolkit) => ({
|
||||
slug: toolkit.slug,
|
||||
logo: toolkit.meta.logo,
|
||||
name: toolkit.name,
|
||||
description: toolkit.meta.description,
|
||||
}))
|
||||
cachedToolkitPreviews = previews
|
||||
setToolkitPreviews(previews)
|
||||
} catch {
|
||||
cachedToolkitPreviews = []
|
||||
} finally {
|
||||
cachedToolkitLogosLoaded = true
|
||||
setToolkitLogosLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeToolkitPreview = useCallback((slug: string) => {
|
||||
setToolkitPreviews((prev) => {
|
||||
const next = prev.filter((toolkit) => toolkit.slug !== slug)
|
||||
cachedToolkitPreviews = next
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadConnectorLogos()
|
||||
}, [loadConnectorLogos])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('rounded-xl border border-border bg-card p-4', className)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
|
||||
<Plug className="size-[14px]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13.5px] leading-snug">
|
||||
<span className="text-muted-foreground">Bring context from and take action in the apps you already use.</span>
|
||||
</div>
|
||||
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
|
||||
{toolkitLogosLoaded && toolkitPreviews.map((toolkit) => (
|
||||
<ToolkitPreviewIcon
|
||||
key={toolkit.slug}
|
||||
toolkit={toolkit}
|
||||
onInvalid={removeToolkitPreview}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectionsSettingsOpen(true)}
|
||||
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
|
||||
>
|
||||
Connections
|
||||
<ArrowRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsDialog
|
||||
defaultTab="connections"
|
||||
open={connectionsSettingsOpen}
|
||||
onOpenChange={setConnectionsSettingsOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ export async function identifyIfSignedIn(): Promise<void> {
|
|||
if (!billing.userId) return;
|
||||
identify(billing.userId, {
|
||||
...(billing.userEmail ? { email: billing.userEmail } : {}),
|
||||
plan: billing.subscriptionPlan,
|
||||
plan: billing.subscriptionPlanId,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -158,6 +158,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 the user's **Brain**. If the user says "my brain", "the brain", "look into your brain", "check my brain", "Brain", or similar, they mean the knowledge graph stored in \`knowledge/\`. Treat "Brain" and "knowledge graph" as the same thing.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ Subject: Re: {original_subject}
|
|||
**Drafting Guidelines:**
|
||||
- Draft ONE email - do not offer multiple versions or options unless explicitly asked
|
||||
- Be concise and professional
|
||||
- If you include a sign-off name, use only the user's first name, never their full name
|
||||
- For scheduling: propose specific times based on calendar availability
|
||||
- For inquiries: answer directly or indicate what info is needed
|
||||
- Reference any relevant context from memory naturally - show you remember past interactions
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z, ZodType } from "zod";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import * as fs from "fs/promises";
|
||||
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
||||
import { agentSlackShimEnv } from "../../slack/agent-slack-exec.js";
|
||||
|
|
@ -20,6 +21,8 @@ import type { CodeModeManager } from "../../code-mode/acp/manager.js";
|
|||
import type { CodePermissionRegistry } from "../../code-mode/acp/permission-registry.js";
|
||||
import { ICodeModeConfigRepo } from "../../code-mode/repo.js";
|
||||
import type { ApprovalPolicy } from "@x/shared/dist/code-mode.js";
|
||||
import type { ICodeProjectsRepo } from "../../code-mode/projects/repo.js";
|
||||
import * as gitService from "../../code-mode/git/service.js";
|
||||
|
||||
// Inputs for the bg-task builtin tools. Reuse the canonical schema field
|
||||
// descriptions; only `triggers` gets a tighter contextual override (the
|
||||
|
|
@ -32,6 +35,9 @@ const CreateBackgroundTaskInput = BackgroundTaskSchema.pick({
|
|||
provider: true,
|
||||
}).extend({
|
||||
triggers: TriggersSchema.optional().describe('All three sub-fields (cronExpr, windows, eventMatchCriteria) are independently optional — mix freely. No triggers at all = manual-only (user clicks Run).'),
|
||||
projectDir: z.string().optional().describe(
|
||||
"Set this ONLY when the user wants the task to WRITE CODE. An absolute path (or ~/…) to a LOCAL GIT REPOSITORY with at least one commit. It turns this into a *coding task*: each run scans the trigger source for actionable items and implements them autonomously in isolated git worktrees off this repo — never touching the user's checkout. Extract the directory from the user's request (e.g. 'use ~/Work/space/test as the work directory'). Omit for ordinary output/action tasks.",
|
||||
),
|
||||
});
|
||||
|
||||
const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
|
||||
|
|
@ -44,7 +50,43 @@ const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
|
|||
}).partial().extend({
|
||||
slug: z.string().describe('The slug of the task to update (the folder name under bg-tasks/).'),
|
||||
triggers: TriggersSchema.optional().describe('Replace the triggers object. To remove all triggers (make manual-only) pass an empty object.'),
|
||||
projectDir: z.string().optional().describe("Point an existing task at a code repo (or change which one) to make it a coding task. Absolute path or ~/… to a local git repository with at least one commit. Same rules as on create."),
|
||||
});
|
||||
|
||||
// Turn a user-supplied directory into a registered code project id. Reuses the
|
||||
// same idempotent registry the Code-section picker writes to (add() validates the
|
||||
// dir exists & is a directory, and dedupes by resolved path). Returns a soft
|
||||
// `warning` — not an error — when the repo isn't yet worktree-ready, so the task
|
||||
// still gets created and the copilot can tell the user what to fix.
|
||||
function expandHome(p: string): string {
|
||||
const t = p.trim();
|
||||
if (t === '~') return os.homedir();
|
||||
if (t.startsWith('~/') || t.startsWith(`~${path.sep}`)) return path.join(os.homedir(), t.slice(2));
|
||||
return t;
|
||||
}
|
||||
|
||||
async function resolveCodeProject(dirPath: string): Promise<
|
||||
{ ok: true; projectId: string; path: string; warning?: string } | { ok: false; error: string }
|
||||
> {
|
||||
const abs = path.resolve(expandHome(dirPath));
|
||||
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
|
||||
let project: Awaited<ReturnType<ICodeProjectsRepo['add']>>;
|
||||
try {
|
||||
project = await projectsRepo.add(abs);
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Could not use '${dirPath}' as a code directory: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
// Worktree isolation needs a real git repo with at least one commit
|
||||
// (codeSessionService.create throws otherwise). Surface it now as a soft
|
||||
// warning rather than letting the next run fail silently.
|
||||
let warning: string | undefined;
|
||||
try {
|
||||
const info = await gitService.repoInfo(project.path);
|
||||
if (!info.isGitRepo) warning = `${project.path} is not a git repository yet — run \`git init\` and make a commit, or the coding sessions will fail.`;
|
||||
else if (!info.hasCommits) warning = `${project.path} has no commits yet — make an initial commit, or the coding sessions will fail.`;
|
||||
} catch { /* best effort — worktree creation will surface it later */ }
|
||||
return { ok: true, projectId: project.id, path: project.path, ...(warning ? { warning } : {}) };
|
||||
}
|
||||
import { ensureLoaded as ensureBrowserSkillsLoaded, readSkillContent as readBrowserSkillContent, refreshFromRemote as refreshBrowserSkills } from "../browser-skills/index.js";
|
||||
import type { ToolContext } from "./exec-tool.js";
|
||||
import { generateText } from "ai";
|
||||
|
|
@ -1490,15 +1532,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
inputSchema: CreateBackgroundTaskInput,
|
||||
execute: async (input: z.infer<typeof CreateBackgroundTaskInput>) => {
|
||||
try {
|
||||
let projectId: string | undefined;
|
||||
let warning: string | undefined;
|
||||
if (input.projectDir) {
|
||||
const r = await resolveCodeProject(input.projectDir);
|
||||
if (!r.ok) return { success: false, error: r.error };
|
||||
projectId = r.projectId;
|
||||
warning = r.warning;
|
||||
}
|
||||
const { createTask } = await import("../../background-tasks/fileops.js");
|
||||
const result = await createTask({
|
||||
name: input.name,
|
||||
instructions: input.instructions,
|
||||
...(input.triggers ? { triggers: input.triggers } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.provider ? { provider: input.provider } : {}),
|
||||
});
|
||||
return { success: true, slug: result.slug };
|
||||
return { success: true, slug: result.slug, ...(warning ? { warning } : {}) };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
|
|
@ -1511,9 +1562,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => {
|
||||
try {
|
||||
const { patchTask } = await import("../../background-tasks/fileops.js");
|
||||
const { slug, ...partial } = input;
|
||||
const { slug, projectDir, ...partial } = input;
|
||||
let warning: string | undefined;
|
||||
if (projectDir) {
|
||||
const r = await resolveCodeProject(projectDir);
|
||||
if (!r.ok) return { success: false, error: r.error };
|
||||
(partial as { projectId?: string }).projectId = r.projectId;
|
||||
warning = r.warning;
|
||||
}
|
||||
const result = await patchTask(slug, partial);
|
||||
return { success: true, task: result };
|
||||
return { success: true, task: result, ...(warning ? { warning } : {}) };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
|
|
@ -1549,6 +1607,35 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'launch-code-task': {
|
||||
description: "Launch an autonomous coding session that implements a unit of work in the bg-task's pinned code repo. ONLY usable from a coding background task (one with a configured code project). The session runs full-auto in its own isolated git worktree/branch — it never touches the user's checkout — and runs asynchronously: this returns as soon as the session is created, so you can launch several (one per group of related items) in the same run. The tool writes and later updates a row under a `## Code Sessions` section in the task's index.md — do NOT edit that section yourself. Write an excellent, fully self-contained `prompt`: the coding agent has no other context and no human to ask. Group related items into one call; split unrelated items into separate calls.",
|
||||
inputSchema: z.object({
|
||||
taskSlug: z.string().describe("The slug of THIS background task (it's in your run message, e.g. 'implement-meeting-items'). Used to find the pinned repo and to update index.md."),
|
||||
meeting: z.string().min(1).describe("The name/title of the meeting these items came from (e.g. 'Eng Sync — 2026-06-18'). Sessions are grouped under this heading in index.md so the user can see which meeting each change came from."),
|
||||
title: z.string().min(1).max(120).describe("Short human title for this unit of work — one line in index.md (e.g. 'Add retry to upload client')."),
|
||||
items: z.string().min(1).describe("Brief description of the action item(s) this session implements, for the summary row (e.g. 'Fix flaky upload + add retry; raised in standup')."),
|
||||
prompt: z.string().min(1).describe("The full, self-contained coding instruction. Include the concrete goal, relevant context from the meeting, any files/areas to look at, and what 'done' means. The agent runs autonomously with no human — be specific and complete."),
|
||||
context: z.string().optional().describe("Optional extra context, e.g. the relevant excerpt from the meeting."),
|
||||
}),
|
||||
execute: async (input: { taskSlug: string; meeting: string; title: string; items: string; prompt: string; context?: string }, ctx?: ToolContext) => {
|
||||
try {
|
||||
const { launchCodeTask } = await import("../../background-tasks/code-sessions.js");
|
||||
const result = await launchCodeTask({
|
||||
taskSlug: input.taskSlug,
|
||||
meeting: input.meeting,
|
||||
title: input.title,
|
||||
items: input.items,
|
||||
prompt: input.prompt,
|
||||
...(input.context ? { context: input.context } : {}),
|
||||
...(ctx?.runId ? { runId: ctx.runId } : {}),
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'notify-user': {
|
||||
description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.",
|
||||
inputSchema: z.object({
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ On every run: perform the action using the appropriate tool (Slack, email, web-f
|
|||
|
||||
If your instructions imply BOTH ("summarize and email it"), do both per run.
|
||||
|
||||
CODE MODE — implement code via isolated sessions.
|
||||
Only available when the run message contains a **"# Coding task"** block (the task is pinned to a code repository). In that case:
|
||||
- Detect actionable coding items from the source (e.g. the meeting notes named in the trigger), conservatively. Only implement clearly-scoped, self-contained items. Ambiguous, large/architectural, or other-repo items → list them in \`index.md\` as "needs review"; do not code them.
|
||||
- Group related items, then call \`launch-code-task\` once per group (\`taskSlug\` is your own slug). It runs full-auto in an isolated worktree and **owns the \`## Code Sessions\` section of \`index.md\`** — never edit those rows yourself. Write a complete, self-contained \`prompt\`: the coding agent has no other context and no human to ask.
|
||||
- If nothing is actionable, launch nothing and say so in your summary.
|
||||
|
||||
# Triggers
|
||||
|
||||
The run message tells you which trigger fired and how to interpret it:
|
||||
|
|
@ -76,11 +82,21 @@ The workspace lives at \`${WorkDir}\`.
|
|||
`;
|
||||
|
||||
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
|
||||
// A running bg-task must not manage bg-tasks: re-running itself risks a
|
||||
// recursive cascade, and patch/create can clobber its own task.yaml (a weak
|
||||
// model has done exactly this, dropping the pinned projectId). It implements
|
||||
// code via `launch-code-task`, not by editing task specs.
|
||||
const EXCLUDED = new Set([
|
||||
'executeCommand', // headless: no interactive approval
|
||||
'code_agent_run', // headless: needs interactive permission UI
|
||||
'run-background-task-agent',
|
||||
'create-background-task',
|
||||
'patch-background-task',
|
||||
]);
|
||||
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
for (const name of Object.keys(BuiltinTools)) {
|
||||
// code_agent_run requires an interactive UI for permission approvals — skip it
|
||||
// here (headless) so it can't hang on an approval no one can answer.
|
||||
if (name === 'executeCommand' || name === 'code_agent_run') continue;
|
||||
if (EXCLUDED.has(name)) continue;
|
||||
tools[name] = { type: 'builtin', name };
|
||||
}
|
||||
|
||||
|
|
|
|||
333
apps/x/packages/core/src/background-tasks/code-sessions.ts
Normal file
333
apps/x/packages/core/src/background-tasks/code-sessions.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import fs from 'fs/promises';
|
||||
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
|
||||
import type { GitStatusFile } from '@x/shared/dist/code-sessions.js';
|
||||
import container from '../di/container.js';
|
||||
import type { CodeSessionService } from '../code-mode/sessions/service.js';
|
||||
import type { ICodeProjectsRepo } from '../code-mode/projects/repo.js';
|
||||
import * as gitService from '../code-mode/git/service.js';
|
||||
import { extractAgentResponse } from '../agents/utils.js';
|
||||
import { withFileLock } from '../knowledge/file-lock.js';
|
||||
import { fetchTask, taskIndexPath } from './fileops.js';
|
||||
|
||||
const log = new PrefixLogger('BgTask:Code');
|
||||
|
||||
// A code session that hangs (engine wedged, never settles) shouldn't pin a
|
||||
// "running…" row forever. After this long we finalize from whatever the
|
||||
// worktree shows and tell the user to check the session.
|
||||
const MAX_WATCH_MS = 90 * 60 * 1000;
|
||||
|
||||
// A single bg-task run must not spawn an unbounded fleet of code sessions — a
|
||||
// weak model has called this 11+ times in one run. Cap per agent run.
|
||||
const MAX_LAUNCHES_PER_RUN = 5;
|
||||
const launchesPerRun = new Map<string, number>();
|
||||
|
||||
export interface LaunchCodeTaskArgs {
|
||||
/** The bg-task slug — used to find the pinned projectId and to write index.md. */
|
||||
taskSlug: string;
|
||||
/** The meeting these items came from — sessions are grouped under it in index.md. */
|
||||
meeting: string;
|
||||
/** Short human title for this unit of work (one row in index.md). */
|
||||
title: string;
|
||||
/** Short description of the item(s) being implemented (for the row). */
|
||||
items: string;
|
||||
/** The detailed, task-specific coding instruction written by the agent. */
|
||||
prompt: string;
|
||||
/** Optional extra context (e.g. the relevant meeting excerpt). */
|
||||
context?: string;
|
||||
/** The bg-task agent's runId — used to cap launches per run. */
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
export interface LaunchCodeTaskResult {
|
||||
success: boolean;
|
||||
sessionId?: string;
|
||||
branch?: string;
|
||||
worktreePath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Wrap the agent-authored task body in a robust autonomous-coding scaffold so
|
||||
// every launch gets a strong, self-contained first message regardless of how
|
||||
// the agent phrased its part. The session runs full-auto (yolo) with no human.
|
||||
function buildCodePrompt(args: { prompt: string; branch: string; context?: string }): string {
|
||||
const { prompt, branch, context } = args;
|
||||
return `You are an autonomous coding agent. There is NO human present to answer questions, approve steps, or review mid-way — make reasonable decisions and drive the task to a complete, working result on your own.
|
||||
|
||||
${context ? `## Context\n${context}\n\n` : ''}## Task
|
||||
${prompt}
|
||||
|
||||
## Operating rules
|
||||
- You are on an isolated branch/worktree (\`${branch}\`). Work only within this repository; your changes never touch the user's main checkout.
|
||||
- Implement the task end-to-end. Do not stop half-way, leave TODOs/stubs, or defer work back to the user.
|
||||
- Before you start, briefly explore the repo to match its existing conventions, structure, and style.
|
||||
- After implementing, VERIFY: run the project's build / typecheck / lint and any directly relevant tests. Fix anything you break.
|
||||
- Make small, logically-scoped git commits with clear messages as you go.
|
||||
- Stay in scope — don't refactor unrelated code or make sweeping changes the task didn't ask for.
|
||||
- If the task is genuinely ambiguous or blocked (missing dependency, contradictory requirement), make the safest reasonable partial progress and clearly flag what's blocked in your final summary — never guess in a way that could be destructive.
|
||||
|
||||
## When done
|
||||
Finish your response with a section titled exactly \`## Summary\` as the LAST thing you write — nothing after it. Under it, put 2–5 short bullet points only: what you changed, which files/areas, how you verified it, and any follow-ups or blockers. No narration or preamble inside the summary (no "I then…", "Let me…") — just the facts. This section is shown to the user verbatim, so keep it clean and self-contained.`;
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// The code agent's final message is mostly streamed narration ("Let me view it
|
||||
// in context…"). We instruct it to end with a `## Summary` section — extract just
|
||||
// that. Fall back to the last paragraph if it didn't comply.
|
||||
const SUMMARY_MAX_CHARS = 900;
|
||||
function cleanSummary(text: string): string {
|
||||
if (!text) return '';
|
||||
let body: string;
|
||||
const idx = text.toLowerCase().lastIndexOf('## summary');
|
||||
if (idx >= 0) {
|
||||
body = text.slice(idx + '## summary'.length).trim();
|
||||
} else {
|
||||
const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
|
||||
body = paras.length ? paras[paras.length - 1] : text.trim();
|
||||
}
|
||||
// Drop empty lines and any leftover heading markers; keep bullet structure.
|
||||
const lines = body.split('\n').map((l) => l.replace(/^#+\s*/, '').trimEnd()).filter((l) => l.trim() !== '');
|
||||
let out = lines.join('\n').trim();
|
||||
if (out.length > SUMMARY_MAX_CHARS) out = out.slice(0, SUMMARY_MAX_CHARS).trimEnd() + '…';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Render a summary as a clean markdown blockquote, preserving its bullet lines.
|
||||
function quoteSummary(summary: string): string[] {
|
||||
const cleaned = cleanSummary(summary);
|
||||
if (!cleaned) return [];
|
||||
return ['', ...cleaned.split('\n').map((l) => (l.trim() ? `> ${l.trim()}` : '>'))];
|
||||
}
|
||||
|
||||
const SECTION_HEADING = '## Code Sessions';
|
||||
|
||||
function startMarker(id: string): string { return `<!-- cs-start:${id} -->`; }
|
||||
function endMarker(id: string): string { return `<!-- cs-end:${id} -->`; }
|
||||
|
||||
function meetingHeading(meeting: string): string {
|
||||
return `### 📅 ${meeting}`;
|
||||
}
|
||||
|
||||
function runningBlock(args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string }): string {
|
||||
const { sessionId, title, items, branch, worktreePath } = args;
|
||||
return [
|
||||
startMarker(sessionId),
|
||||
`#### ⏳ ${title}`,
|
||||
`- **Items:** ${items}`,
|
||||
`- **Branch:** \`${branch}\``,
|
||||
`- **Worktree:** \`${worktreePath}\``,
|
||||
`- **Session:** \`${sessionId}\` _(running…)_`,
|
||||
endMarker(sessionId),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Append a "running" block for a freshly launched session, grouped under its
|
||||
// meeting's heading inside the Code Sessions section (creating section/heading as
|
||||
// needed). Serialized via the index.md file lock so concurrent launches don't
|
||||
// clobber each other.
|
||||
async function appendRunningBlock(slug: string, meeting: string, block: string): Promise<void> {
|
||||
const indexPath = taskIndexPath(slug);
|
||||
await withFileLock(indexPath, async () => {
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(indexPath, 'utf-8');
|
||||
} catch {
|
||||
content = '';
|
||||
}
|
||||
if (!content.includes(SECTION_HEADING)) {
|
||||
const sep = content.endsWith('\n') || content === '' ? '' : '\n';
|
||||
content += `${sep}\n${SECTION_HEADING}\n`;
|
||||
}
|
||||
|
||||
const heading = meetingHeading(meeting);
|
||||
const lines = content.split('\n');
|
||||
const headingIdx = lines.findIndex((l) => l.trim() === heading);
|
||||
if (headingIdx === -1) {
|
||||
// New meeting group — append heading + block at the end.
|
||||
if (!content.endsWith('\n')) content += '\n';
|
||||
content += `\n${heading}\n\n${block}\n`;
|
||||
} else {
|
||||
// Existing meeting — insert this block right after the heading so
|
||||
// sessions stay grouped (newest first within the group).
|
||||
lines.splice(headingIdx + 1, 0, '', block);
|
||||
content = lines.join('\n');
|
||||
}
|
||||
await fs.writeFile(indexPath, content, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
// Replace a session's block in place once its run settles.
|
||||
async function finalizeBlock(slug: string, sessionId: string, block: string): Promise<void> {
|
||||
const indexPath = taskIndexPath(slug);
|
||||
await withFileLock(indexPath, async () => {
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(indexPath, 'utf-8');
|
||||
} catch {
|
||||
return; // nothing to finalize against
|
||||
}
|
||||
const re = new RegExp(`${escapeRegExp(startMarker(sessionId))}[\\s\\S]*?${escapeRegExp(endMarker(sessionId))}`);
|
||||
if (re.test(content)) {
|
||||
content = content.replace(re, block);
|
||||
} else {
|
||||
// The running block went missing (manual edit?) — append the final one.
|
||||
if (!content.endsWith('\n')) content += '\n';
|
||||
content += `\n${block}\n`;
|
||||
}
|
||||
await fs.writeFile(indexPath, content, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
// Once the code turn settles, summarize from the worktree diff + the agent's
|
||||
// final message and rewrite the row.
|
||||
async function finalizeFromResult(
|
||||
slug: string,
|
||||
args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string; baseBranch?: string; timedOut?: boolean; error?: string },
|
||||
): Promise<void> {
|
||||
const { sessionId, title, items, branch, worktreePath, baseBranch, timedOut, error } = args;
|
||||
|
||||
let summary = '';
|
||||
try {
|
||||
summary = (await extractAgentResponse(sessionId)) ?? '';
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// Count everything the session changed since it forked — including commits
|
||||
// (the autonomous scaffold tells the agent to commit, so working-tree status
|
||||
// alone would read as "no changes"). Fall back to working-tree status if we
|
||||
// don't know the base.
|
||||
let files: GitStatusFile[] = [];
|
||||
try {
|
||||
files = baseBranch
|
||||
? await gitService.changedSinceBase(worktreePath, baseBranch)
|
||||
: await gitService.status(worktreePath);
|
||||
} catch { /* worktree may be gone */ }
|
||||
|
||||
const ins = files.reduce((a, f) => a + (f.insertions ?? 0), 0);
|
||||
const del = files.reduce((a, f) => a + (f.deletions ?? 0), 0);
|
||||
|
||||
let heading: string;
|
||||
let status: string;
|
||||
if (error) {
|
||||
heading = `#### ❌ ${title}`;
|
||||
status = `Failed — ${error}`;
|
||||
} else if (timedOut) {
|
||||
heading = `#### ⌛ ${title}`;
|
||||
status = `Timed out — open the session to check progress`;
|
||||
} else if (files.length > 0) {
|
||||
heading = `#### ✅ ${title}`;
|
||||
status = `Implemented — ${files.length} file(s) changed (+${ins} / -${del})`;
|
||||
} else {
|
||||
heading = `#### ⚠️ ${title}`;
|
||||
status = `No file changes — open the session for details`;
|
||||
}
|
||||
|
||||
const fileLines = files.slice(0, 25).map((f) => ` - \`${f.path}\` (${f.state})`);
|
||||
const more = files.length > 25 ? [` - …and ${files.length - 25} more`] : [];
|
||||
|
||||
const block = [
|
||||
startMarker(sessionId),
|
||||
heading,
|
||||
`- **Items:** ${items}`,
|
||||
`- **Branch:** \`${branch}\``,
|
||||
`- **Session:** \`${sessionId}\``,
|
||||
`- **Status:** ${status}`,
|
||||
...(files.length > 0 ? ['- **Files:**', ...fileLines, ...more] : []),
|
||||
...quoteSummary(summary),
|
||||
endMarker(sessionId),
|
||||
].join('\n');
|
||||
|
||||
await finalizeBlock(slug, sessionId, block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a coding session for a bg-task, asynchronously.
|
||||
*
|
||||
* Creates an isolated worktree session (yolo, direct, claude), fires the prompt
|
||||
* without waiting, writes a "running" row into the task's index.md, and detaches
|
||||
* a watcher that finalizes the row once the turn settles. Returns as soon as the
|
||||
* session exists so the bg-task agent can launch more groups (or finish).
|
||||
*/
|
||||
export async function launchCodeTask(args: LaunchCodeTaskArgs): Promise<LaunchCodeTaskResult> {
|
||||
const { taskSlug, meeting, title, items, prompt, context, runId } = args;
|
||||
|
||||
// Per-run launch cap — stop a runaway agent from spawning a session fleet.
|
||||
if (runId) {
|
||||
const used = launchesPerRun.get(runId) ?? 0;
|
||||
if (used >= MAX_LAUNCHES_PER_RUN) {
|
||||
return { success: false, error: `Launch cap reached (${MAX_LAUNCHES_PER_RUN} code sessions per run). Group remaining items instead of launching more.` };
|
||||
}
|
||||
launchesPerRun.set(runId, used + 1);
|
||||
}
|
||||
|
||||
const task = await fetchTask(taskSlug);
|
||||
if (!task) {
|
||||
return { success: false, error: `Background task '${taskSlug}' not found.` };
|
||||
}
|
||||
if (!task.projectId) {
|
||||
return { success: false, error: `Task '${taskSlug}' has no configured code project (repo). Set one to use launch-code-task.` };
|
||||
}
|
||||
|
||||
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
|
||||
const project = await projectsRepo.get(task.projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: `Configured code project '${task.projectId}' is no longer registered.` };
|
||||
}
|
||||
|
||||
const codeSessionService = container.resolve<CodeSessionService>('codeSessionService');
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await codeSessionService.create({
|
||||
projectId: project.id,
|
||||
title,
|
||||
agent: 'claude',
|
||||
mode: 'direct',
|
||||
policy: 'yolo',
|
||||
isolation: 'worktree',
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: `Could not create code session: ${msg}` };
|
||||
}
|
||||
|
||||
const branch = session.worktree?.branch ?? 'rowboat/' + session.id;
|
||||
const baseBranch = session.worktree?.baseBranch ?? undefined;
|
||||
const worktreePath = session.cwd;
|
||||
|
||||
await appendRunningBlock(taskSlug, meeting, runningBlock({
|
||||
sessionId: session.id, title, items, branch, worktreePath,
|
||||
}));
|
||||
|
||||
const wrapped = buildCodePrompt({ prompt, branch, ...(context ? { context } : {}) });
|
||||
|
||||
log.log(`${taskSlug} — launched session ${session.id} on ${branch}`);
|
||||
|
||||
// Detached: drive the turn to completion, then finalize the index.md row.
|
||||
// `sendMessage` resolves when the turn settles (it awaits the engine and
|
||||
// never rejects on engine errors), so we don't need a separate completion
|
||||
// subscription — but we still cap it with a timeout so a wedged engine can't
|
||||
// pin the row at "running" forever.
|
||||
void (async () => {
|
||||
let timedOut = false;
|
||||
try {
|
||||
await Promise.race([
|
||||
codeSessionService.sendMessage(session.id, wrapped),
|
||||
new Promise<void>((resolve) => setTimeout(() => { timedOut = true; resolve(); }, MAX_WATCH_MS)),
|
||||
]);
|
||||
} catch (err) {
|
||||
log.log(`${taskSlug} — session ${session.id} errored: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
try {
|
||||
await finalizeFromResult(taskSlug, {
|
||||
sessionId: session.id, title, items, branch, worktreePath, timedOut,
|
||||
...(baseBranch ? { baseBranch } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
log.log(`${taskSlug} — finalize failed for ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
})();
|
||||
|
||||
return { success: true, sessionId: session.id, branch, worktreePath };
|
||||
}
|
||||
|
|
@ -97,6 +97,7 @@ export interface CreateTaskInput {
|
|||
name: string;
|
||||
instructions: string;
|
||||
triggers?: BackgroundTask['triggers'];
|
||||
projectId?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
|
@ -136,6 +137,7 @@ export async function createTask(input: CreateTaskInput): Promise<{ slug: string
|
|||
instructions: input.instructions,
|
||||
active: true,
|
||||
...(input.triggers ? { triggers: input.triggers } : {}),
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.provider ? { provider: input.provider } : {}),
|
||||
createdAt: new Date().toISOString(),
|
||||
|
|
@ -194,6 +196,7 @@ export async function listTasks(opts: ListTasksOptions = {}): Promise<ListTasksR
|
|||
instructions: task.instructions,
|
||||
active: task.active,
|
||||
...(task.triggers ? { triggers: task.triggers } : {}),
|
||||
...(task.projectId ? { projectId: task.projectId } : {}),
|
||||
createdAt: task.createdAt,
|
||||
...(task.lastAttemptAt ? { lastAttemptAt: task.lastAttemptAt } : {}),
|
||||
...(task.lastRunId ? { lastRunId: task.lastRunId } : {}),
|
||||
|
|
|
|||
|
|
@ -31,11 +31,31 @@ const BG_TASK_EVENT_DECISION_DIRECTIVE = '**Decision:** Determine whether this e
|
|||
|
||||
const BG_TASK_MANUAL_PAREN = 'user-triggered — either the Run button in the Background Task detail view or the `run-background-task-agent` tool';
|
||||
|
||||
function buildCodeBlock(slug: string, project: { id: string; path: string; name: string }): string {
|
||||
return `
|
||||
|
||||
# Coding task
|
||||
|
||||
This is a **coding task**. It is pinned to a code repository:
|
||||
- **Project:** ${project.name}
|
||||
- **Path:** \`${project.path}\`
|
||||
|
||||
Your job this run:
|
||||
1. Read the relevant source (e.g. the meeting notes named in the trigger below) and identify **actionable coding items** — bugs to fix, features to build, concrete changes requested.
|
||||
2. Be **conservative**: only implement items that are clearly scoped and self-contained. Items that are ambiguous, large/architectural, or about a different repository — do NOT code them. List them briefly in \`index.md\` as "needs review" instead.
|
||||
3. **Group** related items together; keep unrelated items separate.
|
||||
4. For each group, call the \`launch-code-task\` tool with \`taskSlug: "${slug}"\`, the \`meeting\` name/title these items came from (so sessions are grouped by meeting), a short \`title\`, the \`items\` summary, and a **detailed, fully self-contained \`prompt\`** describing exactly what to implement (the coding agent has no other context and no human to ask). Put the relevant meeting excerpt in \`context\`.
|
||||
5. \`launch-code-task\` runs asynchronously in an isolated git worktree (full-auto) and manages a \`## Code Sessions\` section in \`index.md\` itself — **do not edit that section.** You may add a short note ABOVE it summarizing what you detected.
|
||||
|
||||
If there are no actionable coding items, launch nothing and say so in your final summary.`;
|
||||
}
|
||||
|
||||
function buildMessage(
|
||||
slug: string,
|
||||
task: BackgroundTask,
|
||||
trigger: BackgroundTaskTriggerType,
|
||||
context?: string,
|
||||
codeProject?: { id: string; path: string; name: string },
|
||||
): string {
|
||||
const now = new Date();
|
||||
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
|
|
@ -50,7 +70,7 @@ function buildMessage(
|
|||
**Instructions:**
|
||||
${task.instructions}
|
||||
|
||||
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`;
|
||||
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).${codeProject ? buildCodeBlock(slug, codeProject) : ''}`;
|
||||
|
||||
return baseMessage + buildTriggerBlock({
|
||||
trigger,
|
||||
|
|
@ -103,6 +123,20 @@ export async function runBackgroundTask(
|
|||
// `||` not `??`: an empty-string `task.model` (occasionally synthesized
|
||||
// by an LLM call to create-background-task) should fall through to the
|
||||
// default just like undefined does.
|
||||
// Coding tasks carry a pinned code project — resolve it so the run
|
||||
// message can tell the agent which repo to work in.
|
||||
let codeProject: { id: string; path: string; name: string } | undefined;
|
||||
if (task.projectId) {
|
||||
try {
|
||||
const { default: container } = await import('../di/container.js');
|
||||
const projectsRepo = container.resolve<import('../code-mode/projects/repo.js').ICodeProjectsRepo>('codeProjectsRepo');
|
||||
const project = await projectsRepo.get(task.projectId);
|
||||
if (project) codeProject = { id: project.id, path: project.path, name: project.name };
|
||||
} catch (err) {
|
||||
log.log(`${slug} — could not resolve code project ${task.projectId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const model = task.model || await getBackgroundTaskAgentModel();
|
||||
const agentRun = await createRun({
|
||||
agentId: 'background-task-agent',
|
||||
|
|
@ -129,9 +163,16 @@ export async function runBackgroundTask(
|
|||
// we leave `lastRunAt` / `lastRunSummary` / `lastRunError` untouched —
|
||||
// the previous successful run stays visible in the UI even while this
|
||||
// new run is in-flight or fails.
|
||||
// `projectId` is runtime-owned config the agent must never lose. A weak
|
||||
// model can clobber task.yaml mid-run (despite "never touch this"), which
|
||||
// would silently disable coding on later runs — so we re-assert it on
|
||||
// every patch to self-heal.
|
||||
const heal = task.projectId ? { projectId: task.projectId } : {};
|
||||
|
||||
await patchTask(slug, {
|
||||
lastAttemptAt: startedAt,
|
||||
lastRunId: runId,
|
||||
...heal,
|
||||
});
|
||||
|
||||
backgroundTaskBus.publish({
|
||||
|
|
@ -142,7 +183,7 @@ export async function runBackgroundTask(
|
|||
});
|
||||
|
||||
try {
|
||||
await createMessage(runId, buildMessage(slug, task, trigger, context));
|
||||
await createMessage(runId, buildMessage(slug, task, trigger, context, codeProject));
|
||||
await waitForRunCompletion(runId, { throwOnError: true });
|
||||
const summary = await extractAgentResponse(runId);
|
||||
|
||||
|
|
@ -151,6 +192,7 @@ export async function runBackgroundTask(
|
|||
lastRunAt: new Date().toISOString(),
|
||||
lastRunSummary: summary ?? undefined,
|
||||
lastRunError: undefined,
|
||||
...heal,
|
||||
});
|
||||
|
||||
log.log(`${slug} — done summary="${truncate(summary)}"`);
|
||||
|
|
@ -171,7 +213,7 @@ export async function runBackgroundTask(
|
|||
// state; the scheduler's backoff (lastAttemptAt + 5min) prevents
|
||||
// retry-storming.
|
||||
try {
|
||||
await patchTask(slug, { lastRunError: msg });
|
||||
await patchTask(slug, { lastRunError: msg, ...heal });
|
||||
} catch {
|
||||
// don't mask the original error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { getAccessToken } from '../auth/tokens.js';
|
||||
import { API_URL } from '../config/env.js';
|
||||
import type { BillingInfo, BillingPlan } from '@x/shared/dist/billing.js';
|
||||
import type { BillingInfo, BillingPlanId } from '@x/shared/dist/billing.js';
|
||||
import { getRowboatConfig } from '../config/rowboat.js';
|
||||
|
||||
export async function getBillingInfo(): Promise<BillingInfo> {
|
||||
const config = await getRowboatConfig();
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await fetch(`${API_URL}/v1/me`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
|
|
@ -16,7 +18,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
|
|||
email: string;
|
||||
};
|
||||
billing: {
|
||||
plan: BillingPlan | null;
|
||||
planId: BillingPlanId | null;
|
||||
status: string | null;
|
||||
trialExpiresAt: string | null;
|
||||
usage: {
|
||||
|
|
@ -37,9 +39,10 @@ export async function getBillingInfo(): Promise<BillingInfo> {
|
|||
return {
|
||||
userEmail: body.user.email ?? null,
|
||||
userId: body.user.id ?? null,
|
||||
subscriptionPlan: body.billing.plan,
|
||||
subscriptionPlanId: body.billing.planId,
|
||||
subscriptionStatus: body.billing.status,
|
||||
trialExpiresAt: body.billing.trialExpiresAt ?? null,
|
||||
catalog: config.billing,
|
||||
monthly: body.billing.usage.monthly,
|
||||
daily: body.billing.usage.daily,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -37,6 +37,74 @@ const STARTUP_TIMEOUT_MS = Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS) >
|
|||
? Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS)
|
||||
: 60_000;
|
||||
|
||||
export interface CodeAgentOption { value: string; label: string }
|
||||
export interface CodeAgentModelOptions { models: CodeAgentOption[]; efforts: CodeAgentOption[] }
|
||||
|
||||
// The agent advertises its model + effort choices on the session it opens (the
|
||||
// same data that backs its `/model` picker), in one of two shapes:
|
||||
// - `configOptions`: select options with id "model" / "effort" (Claude).
|
||||
// - `models`: a SessionModelState { availableModels: [{ modelId, name }] }
|
||||
// (Codex — which folds effort into the model id, so no separate effort).
|
||||
// We read configOptions first and fall back to `models`, then prepend a
|
||||
// synthetic "Default" so the user can always keep the engine default.
|
||||
type RawSelectOption = { value?: unknown; name?: unknown; options?: Array<{ value?: unknown; name?: unknown }> };
|
||||
type RawConfigOption = { id?: string; options?: RawSelectOption[] };
|
||||
type RawModelState = { availableModels?: Array<{ modelId?: unknown; name?: unknown }> };
|
||||
|
||||
function withDefault(choices: CodeAgentOption[]): CodeAgentOption[] {
|
||||
return choices.some((c) => c.value === 'default')
|
||||
? choices
|
||||
: [{ value: 'default', label: 'Default' }, ...choices];
|
||||
}
|
||||
|
||||
function toChoices(option: RawConfigOption | undefined): CodeAgentOption[] {
|
||||
const flat = (option?.options ?? []).flatMap((o) => (Array.isArray(o.options) ? o.options : [o]));
|
||||
return flat
|
||||
.filter((o): o is { value: string; name?: unknown } => typeof o.value === 'string')
|
||||
.map((o) => ({ value: o.value, label: typeof o.name === 'string' && o.name ? o.name : o.value }));
|
||||
}
|
||||
|
||||
function modelStateChoices(models: RawModelState | undefined): CodeAgentOption[] {
|
||||
return (models?.availableModels ?? [])
|
||||
.filter((m): m is { modelId: string; name?: unknown } => typeof m.modelId === 'string')
|
||||
.map((m) => ({ value: m.modelId, label: typeof m.name === 'string' && m.name ? m.name : m.modelId }));
|
||||
}
|
||||
|
||||
export function extractModelOptions(configOptions: unknown, models?: unknown): CodeAgentModelOptions {
|
||||
const list = (Array.isArray(configOptions) ? configOptions : []) as RawConfigOption[];
|
||||
const modelOpt = list.find((o) => o.id === 'model');
|
||||
const effortOpt = list.find((o) => o.id === 'effort');
|
||||
const modelChoices = toChoices(modelOpt);
|
||||
return {
|
||||
// configOptions is authoritative when present; otherwise fall back to the
|
||||
// SessionModelState list (Codex reports models only there).
|
||||
models: withDefault(modelChoices.length ? modelChoices : modelStateChoices(models as RawModelState)),
|
||||
efforts: effortOpt ? withDefault(toChoices(effortOpt)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
// Claude's `availableModels` exposes its top model only as "Default
|
||||
// (recommended)" and omits an explicit "Opus" row (the interactive `/model`
|
||||
// lists it, the ACP adapter dedupes it). Surface the canonical aliases
|
||||
// explicitly for clarity — the adapter resolves "opus"/"sonnet"/"haiku" to the
|
||||
// concrete model. Deduped against what the engine already returned, so in
|
||||
// practice this only adds the missing "Opus" entry, placed right after Default.
|
||||
const CLAUDE_ALIAS_ROWS: CodeAgentOption[] = [
|
||||
{ value: 'opus', label: 'Opus' },
|
||||
{ value: 'sonnet', label: 'Sonnet' },
|
||||
{ value: 'haiku', label: 'Haiku' },
|
||||
];
|
||||
|
||||
function withClaudeAliases(options: CodeAgentModelOptions): CodeAgentModelOptions {
|
||||
const have = new Set(options.models.map((m) => m.value));
|
||||
const extra = CLAUDE_ALIAS_ROWS.filter((r) => !have.has(r.value));
|
||||
if (extra.length === 0) return options;
|
||||
const at = options.models.findIndex((m) => m.value === 'default');
|
||||
const models = [...options.models];
|
||||
models.splice(at >= 0 ? at + 1 : 0, 0, ...extra);
|
||||
return { ...options, models };
|
||||
}
|
||||
|
||||
// Map a raw ACP session/update notification onto our small CodeRunEvent union.
|
||||
function toEvent(update: SessionUpdate): CodeRunEvent {
|
||||
switch (update.sessionUpdate) {
|
||||
|
|
@ -180,6 +248,20 @@ export class AcpClient {
|
|||
}
|
||||
}
|
||||
|
||||
// Open a throwaway session purely to read the agent's advertised model +
|
||||
// effort choices, then let the caller dispose this client. Used for the
|
||||
// model picker before any real session exists.
|
||||
async describeModelOptions(): Promise<CodeAgentModelOptions> {
|
||||
try {
|
||||
const res = await this.withStartupTimeout(this.conn().newSession({ cwd: this.cwd, mcpServers: [] }));
|
||||
const r = res as { configOptions?: unknown; models?: unknown };
|
||||
const options = extractModelOptions(r.configOptions, r.models);
|
||||
return this.agent === 'claude' ? withClaudeAliases(options) : options;
|
||||
} catch (e) {
|
||||
throw this.enrich(e, 'describeModelOptions');
|
||||
}
|
||||
}
|
||||
|
||||
async loadSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await this.withStartupTimeout(this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }));
|
||||
|
|
@ -188,6 +270,20 @@ export class AcpClient {
|
|||
}
|
||||
}
|
||||
|
||||
// Point the open session at a specific model. The adapter resolves aliases
|
||||
// ("opus"/"sonnet"/…) to concrete ids. Throws if the model is unknown; the
|
||||
// caller applies this best-effort so a bad value never blocks a turn.
|
||||
async setModel(sessionId: string, modelId: string): Promise<void> {
|
||||
await this.conn().unstable_setSessionModel({ sessionId, modelId });
|
||||
}
|
||||
|
||||
// Set the reasoning-effort level via the agent's "effort" config option.
|
||||
// The option only exists for models that support it, so this throws for
|
||||
// others — again applied best-effort by the caller.
|
||||
async setEffort(sessionId: string, value: string): Promise<void> {
|
||||
await this.conn().setSessionConfigOption({ sessionId, configId: 'effort', value });
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string): Promise<PromptResponse> {
|
||||
try {
|
||||
return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as os from 'os';
|
||||
import type { ApprovalPolicy, CodeRunEvent, CodingAgent, PermissionAsk, PermissionDecision, RunPromptResult } from './types.js';
|
||||
import { AcpClient } from './client.js';
|
||||
import { AcpClient, type CodeAgentModelOptions } from './client.js';
|
||||
import { PermissionBroker } from './permission-broker.js';
|
||||
import { readStoredSession, writeStoredSession, clearStoredSession } from './session-store.js';
|
||||
|
||||
|
|
@ -9,6 +10,11 @@ export interface RunPromptArgs {
|
|||
cwd: string;
|
||||
prompt: string;
|
||||
policy: ApprovalPolicy;
|
||||
/** Coding-agent model alias/id (e.g. "opus"); applied to the ACP session
|
||||
* before the prompt. Omitted / "default" leaves the engine default. */
|
||||
model?: string;
|
||||
/** Reasoning-effort level (e.g. "high"); applied alongside the model. */
|
||||
effort?: string;
|
||||
/** Called when the policy needs the user to decide (the "ask" path). */
|
||||
ask: (ask: PermissionAsk) => Promise<PermissionDecision>;
|
||||
/** Stream sink for this prompt's run. */
|
||||
|
|
@ -56,9 +62,32 @@ const CANCEL_GRACE_MS = 2_000;
|
|||
// resumes the persisted session via session/load.
|
||||
export class CodeModeManager {
|
||||
private readonly runs = new Map<string, ActiveRun>();
|
||||
// Per-agent model/effort choices, discovered once from the engine and reused
|
||||
// (the list only changes when the provider ships new models, and the app can
|
||||
// be restarted to pick those up). Avoids cold-starting an adapter per picker.
|
||||
private readonly modelOptionsCache = new Map<CodingAgent, CodeAgentModelOptions>();
|
||||
|
||||
// Discover a coding agent's available models + effort levels straight from
|
||||
// the engine (what its `/model` picker would show). Spawns a short-lived
|
||||
// adapter, opens a throwaway session to read its advertised options, and
|
||||
// tears it down. Cached per agent for the lifetime of the process.
|
||||
async listModelOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
|
||||
const cached = this.modelOptionsCache.get(agent);
|
||||
if (cached) return cached;
|
||||
const broker = new PermissionBroker({ policy: 'yolo', ask: async () => 'reject' });
|
||||
const client = new AcpClient({ agent, cwd: os.homedir(), broker, onEvent: () => {} });
|
||||
try {
|
||||
await client.start();
|
||||
const options = await client.describeModelOptions();
|
||||
this.modelOptionsCache.set(agent, options);
|
||||
return options;
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> {
|
||||
const { runId, agent, cwd, prompt, policy, ask, onEvent, signal, suppressReplay } = args;
|
||||
const { runId, agent, cwd, prompt, policy, model, effort, ask, onEvent, signal, suppressReplay } = args;
|
||||
|
||||
const broker = new PermissionBroker({
|
||||
policy,
|
||||
|
|
@ -67,6 +96,10 @@ export class CodeModeManager {
|
|||
});
|
||||
|
||||
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent, suppressReplay ?? false);
|
||||
// Re-apply the session's model + effort each turn (idempotent): a warm
|
||||
// connection keeps the last selection, but a cold session/load resets it,
|
||||
// and the user may have changed it from the header since the last turn.
|
||||
await this.applyModelAndEffort(run, model, effort);
|
||||
run.inflight++;
|
||||
|
||||
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
|
@ -109,6 +142,26 @@ export class CodeModeManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Best-effort: a model the engine doesn't know, or an effort level a model
|
||||
// doesn't support, must not abort the turn — we log and proceed with the
|
||||
// engine default rather than surfacing a hard error to the user.
|
||||
private async applyModelAndEffort(run: ActiveRun, model?: string, effort?: string): Promise<void> {
|
||||
if (model && model !== 'default') {
|
||||
try {
|
||||
await run.client.setModel(run.sessionId, model);
|
||||
} catch (e) {
|
||||
console.warn(`[code-mode] could not set model "${model}": ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
if (effort && effort !== 'default') {
|
||||
try {
|
||||
await run.client.setEffort(run.sessionId, effort);
|
||||
} catch (e) {
|
||||
console.warn(`[code-mode] could not set effort "${effort}": ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(runId: string): void {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) return;
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ async function repoToplevel(cwd: string): Promise<string> {
|
|||
}
|
||||
}
|
||||
|
||||
async function mergeBase(cwd: string, baseRef: string): Promise<string> {
|
||||
try {
|
||||
return (await git(cwd, ['merge-base', baseRef, 'HEAD'])).trim() || baseRef;
|
||||
} catch {
|
||||
return baseRef;
|
||||
}
|
||||
}
|
||||
|
||||
function stateFromPorcelain(xy: string): GitFileState {
|
||||
if (xy === '??') return 'untracked';
|
||||
if (xy.includes('R')) return 'renamed';
|
||||
|
|
@ -161,6 +169,62 @@ export async function status(cwd: string): Promise<GitStatusFile[]> {
|
|||
return result;
|
||||
}
|
||||
|
||||
// Everything this worktree's branch changed since it forked from `baseRef` —
|
||||
// committed AND uncommitted. `status()` only sees the working tree (uncommitted),
|
||||
// so it misses work an agent committed; this is what you want for a session
|
||||
// summary. Counts come from numstat, states from name-status, merged by path.
|
||||
export async function changedSinceBase(cwd: string, baseRef: string): Promise<GitStatusFile[]> {
|
||||
const forkPoint = await mergeBase(cwd, baseRef);
|
||||
|
||||
const stateByPath = new Map<string, GitFileState>();
|
||||
try {
|
||||
const ns = await git(cwd, ['diff', '--name-status', '-z', forkPoint]);
|
||||
const parts = ns.split('\0');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const code = parts[i];
|
||||
if (!code) continue;
|
||||
const letter = code[0];
|
||||
if (letter === 'R' || letter === 'C') {
|
||||
// rename/copy: "<code>\0<old>\0<new>"
|
||||
const newPath = parts[i + 2];
|
||||
i += 2;
|
||||
if (newPath) stateByPath.set(newPath, 'renamed');
|
||||
} else {
|
||||
const p = parts[i + 1];
|
||||
i += 1;
|
||||
if (p) stateByPath.set(p, letter === 'A' ? 'added' : letter === 'D' ? 'deleted' : 'modified');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// bad ref / no commits — leave states empty
|
||||
}
|
||||
|
||||
const result: GitStatusFile[] = [];
|
||||
try {
|
||||
const numstat = await git(cwd, ['diff', '--numstat', '-z', forkPoint]);
|
||||
const rows = numstat.split('\0');
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (!row) continue;
|
||||
const m = row.match(/^(\d+|-)\t(\d+|-)\t?(.*)$/);
|
||||
if (!m) continue;
|
||||
const insertions = m[1] === '-' ? null : Number(m[1]);
|
||||
const deletions = m[2] === '-' ? null : Number(m[2]);
|
||||
let filePath = m[3];
|
||||
if (!filePath) {
|
||||
// rename form: old and new paths follow as separate tokens
|
||||
i += 2;
|
||||
filePath = rows[i] ?? '';
|
||||
}
|
||||
if (!filePath) continue;
|
||||
result.push({ path: filePath, state: stateByPath.get(filePath) ?? 'modified', insertions, deletions });
|
||||
}
|
||||
} catch {
|
||||
// bad ref / no commits — nothing to report
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
|
|
@ -168,7 +232,7 @@ export interface FileDiff {
|
|||
tooLarge: boolean;
|
||||
}
|
||||
|
||||
export async function fileDiff(cwd: string, relPath: string): Promise<FileDiff> {
|
||||
export async function fileDiff(cwd: string, relPath: string, opts: { baseRef?: string | null } = {}): Promise<FileDiff> {
|
||||
// Paths from `status` are repo-root-relative; paths clicked in the chat
|
||||
// timeline are cwd-relative. Resolve whichever interpretation points at a
|
||||
// real file (deleted files fall back to the root interpretation, which is
|
||||
|
|
@ -189,7 +253,8 @@ export async function fileDiff(cwd: string, relPath: string): Promise<FileDiff>
|
|||
}
|
||||
let oldText = '';
|
||||
try {
|
||||
oldText = await git(cwd, ['show', `HEAD:${gitPath}`]);
|
||||
const oldRef = opts.baseRef ? await mergeBase(cwd, opts.baseRef) : 'HEAD';
|
||||
oldText = await git(cwd, ['show', `${oldRef}:${gitPath}`]);
|
||||
} catch {
|
||||
// untracked / newly added / no commits — diff against empty
|
||||
oldText = '';
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ export interface CreateSessionArgs {
|
|||
// LLM for Rowboat-mode turns; unset falls through to the configured default.
|
||||
model?: string;
|
||||
provider?: string;
|
||||
// The coding agent's own model + reasoning effort (ACP engine); unset leaves
|
||||
// the engine default. Re-applied to the ACP session on every turn.
|
||||
agentModel?: string;
|
||||
agentEffort?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageResult {
|
||||
|
|
@ -142,6 +146,8 @@ export class CodeSessionService {
|
|||
policy: args.policy,
|
||||
cwd,
|
||||
...(worktree ? { worktree } : {}),
|
||||
...(args.agentModel ? { agentModel: args.agentModel } : {}),
|
||||
...(args.agentEffort ? { agentEffort: args.agentEffort } : {}),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await this.codeSessionsRepo.save(session);
|
||||
|
|
@ -149,7 +155,7 @@ export class CodeSessionService {
|
|||
return session;
|
||||
}
|
||||
|
||||
async update(sessionId: string, patch: Partial<Pick<CodeSession, 'title' | 'mode' | 'policy' | 'agent'>>): Promise<CodeSession> {
|
||||
async update(sessionId: string, patch: Partial<Pick<CodeSession, 'title' | 'mode' | 'policy' | 'agent' | 'agentModel' | 'agentEffort'>>): Promise<CodeSession> {
|
||||
const session = await this.codeSessionsRepo.get(sessionId);
|
||||
if (!session) throw new Error(`Unknown session: ${sessionId}`);
|
||||
const updated: CodeSession = { ...session, ...patch };
|
||||
|
|
@ -217,6 +223,8 @@ export class CodeSessionService {
|
|||
cwd: session.cwd,
|
||||
prompt: text,
|
||||
policy: session.policy,
|
||||
...(session.agentModel ? { model: session.agentModel } : {}),
|
||||
...(session.agentEffort ? { effort: session.agentEffort } : {}),
|
||||
signal,
|
||||
suppressReplay: true,
|
||||
onEvent: (event) => {
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export interface Classification {
|
|||
const ClassificationSchema = z.object({
|
||||
importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'),
|
||||
summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'),
|
||||
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
|
||||
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". If a sign-off name is included, use only the user\'s first name. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
|
||||
});
|
||||
|
||||
const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user.
|
||||
|
|
@ -139,6 +139,8 @@ Could you resend it with a bit more context so I can get back to you properly?
|
|||
Best,
|
||||
John
|
||||
|
||||
If you include the user's name in the sign-off, use only their first name, never their full name.
|
||||
|
||||
When an email-style guide is provided below, it takes precedence: follow it for greeting, tone, sign-off, length, and phrasing patterns (while keeping the line-break structure shown above). If no style guide is provided, default to a brief, warm, professional voice.
|
||||
|
||||
For scheduling-related threads (where the sender proposes meeting times, asks for the user's availability, or follows up on a meeting request), look at the user's upcoming calendar (provided below) and either:
|
||||
|
|
|
|||
114
apps/x/packages/core/src/knowledge/contact_filters.ts
Normal file
114
apps/x/packages/core/src/knowledge/contact_filters.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// Local-part aliases that are almost always automated/role addresses you don't
|
||||
// compose a fresh message to. Matched as a whole segment of the local part
|
||||
// (segments split on . _ - +).
|
||||
const AUTOMATED_LOCAL_PARTS = new Set([
|
||||
'noreply', 'no-reply', 'donotreply', 'do-not-reply', 'reply',
|
||||
'notifications', 'notification', 'notify',
|
||||
'alerts', 'alert', 'updates', 'update',
|
||||
'news', 'newsletter', 'newsletters',
|
||||
'info', 'information', 'hello', 'hi', 'hey',
|
||||
'welcome', 'onboarding', 'getstarted',
|
||||
'team', 'marketing', 'promo', 'promos', 'promotions',
|
||||
'offer', 'offers', 'deals', 'deal',
|
||||
'accounts', 'account', 'billing', 'invoices', 'statements', 'statement',
|
||||
'learn', 'learning', 'courses',
|
||||
'mailer-daemon', 'mailerdaemon', 'postmaster', 'bounce', 'bounces',
|
||||
'automated', 'auto', 'autoconfirm',
|
||||
'support-bot', 'noticeboard', 'system',
|
||||
'contact', 'connect',
|
||||
'sender', 'broadcast', 'digest', 'campaign', 'campaigns',
|
||||
'support', 'service', 'help', 'helpdesk', 'feedback',
|
||||
'mailer', 'mailers', 'members', 'membership',
|
||||
'careers', 'jobs', 'recruit', 'recruiting',
|
||||
'tickets', 'orders', 'order', 'receipts', 'receipt',
|
||||
'applications', 'apply', 'admissions',
|
||||
'health', 'security', 'auth',
|
||||
]);
|
||||
|
||||
// Subdomain labels that flag a bulk/marketing infrastructure domain.
|
||||
const AUTOMATED_SUBDOMAIN_LABELS = new Set([
|
||||
'mail', 'mailer', 'mailers', 'mailing', 'mailgun', 'sendgrid', 'mta',
|
||||
'email', 'em', 'e', 'm',
|
||||
'news', 'newsletter', 'newsletters',
|
||||
'marketing', 'mkt', 'promo', 'promos', 'offers',
|
||||
'event', 'events', 'ecomm', 'commerce',
|
||||
'notifications', 'notification', 'notify', 'alerts', 'alert', 'updates',
|
||||
'messaging', 'message', 'msg',
|
||||
'noreply', 'donotreply',
|
||||
'creators', 'partners', 'team',
|
||||
'info', 'welcome', 'hi', 'hello',
|
||||
'bounces', 'bounce',
|
||||
'reply', 'user', 'usr', 'auto',
|
||||
]);
|
||||
|
||||
// Specific bulk-mail provider domains (substring match on full domain).
|
||||
const AUTOMATED_DOMAIN_KEYWORDS = [
|
||||
'facebookmail', 'kajabimail', 'substack', 'mailgun', 'sendgrid',
|
||||
'mcsv.net', 'mailchimp', 'mailerlite', 'createsend', 'cmail',
|
||||
'amazonses', 'sparkpost', 'sendinblue', 'brevo',
|
||||
'luma-mail', 'lumamail',
|
||||
'umusic-online', 'icloud-mail',
|
||||
];
|
||||
|
||||
function localSegments(local: string): string[] {
|
||||
return local.toLowerCase().split(/[._\-+]/).filter(Boolean);
|
||||
}
|
||||
|
||||
export function isAutomatedAddress(email: string): boolean {
|
||||
if (!email) return true;
|
||||
const at = email.indexOf('@');
|
||||
if (at < 0) return true;
|
||||
const local = email.slice(0, at).toLowerCase();
|
||||
const domain = email.slice(at + 1).toLowerCase();
|
||||
|
||||
// Plus-aliased reply bots: `reply+abc123@...`
|
||||
if (/^reply\+/i.test(local)) return true;
|
||||
|
||||
// Encoded VERP/list aliases, e.g. long-token-arjun=rowboat...@domain.
|
||||
if (local.includes('=') && /^[a-z0-9]{16,}[-+].*=/.test(local)) return true;
|
||||
|
||||
const segs = localSegments(local);
|
||||
for (const s of segs) {
|
||||
if (AUTOMATED_LOCAL_PARTS.has(s)) return true;
|
||||
}
|
||||
|
||||
if (/(no.?reply|do.?not.?reply|notifications?|news.?letter|mailer.?daemon|postmaster|automated|broadcast|statement)/i.test(local)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (local.length >= 20 && /^[a-z0-9=._\-+]+$/.test(local) && /[0-9]/.test(local)) {
|
||||
const digits = (local.match(/[0-9]/g) || []).length;
|
||||
const letters = (local.match(/[a-z]/g) || []).length;
|
||||
if (digits / local.length >= 0.2 || (digits >= 3 && letters >= 12 && !local.includes('.'))) return true;
|
||||
}
|
||||
|
||||
const labels = domain.split('.');
|
||||
if (labels.length >= 3) {
|
||||
const subs = labels.slice(0, -2);
|
||||
for (const label of subs) {
|
||||
if (AUTOMATED_SUBDOMAIN_LABELS.has(label)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const kw of AUTOMATED_DOMAIN_KEYWORDS) {
|
||||
if (domain.includes(kw)) return true;
|
||||
}
|
||||
|
||||
if (/(^|\.)(mailers?|mailer|mailgun|sendgrid|mailchimp|mailerlite|bounces?|marketing|promo|notifications?|newsletter)(\.|$)/i.test(domain)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sld = labels[labels.length - 1];
|
||||
if (['email', 'mail', 'marketing', 'promo', 'news', 'newsletter', 'click', 'link'].includes(sld)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Brand-identity addresses like `uber@uber.com`, `lenovo@lenovo.com` -
|
||||
// local part equals the first label of the domain. Almost always a
|
||||
// transactional/marketing sender.
|
||||
if (labels.length >= 2 && local === labels[0]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import path from 'path';
|
|||
import { WorkDir } from '../config/config.js';
|
||||
import type { GmailThreadSnapshot } from './sync_gmail.js';
|
||||
import { getAccountEmail } from './sync_gmail.js';
|
||||
import { isAutomatedAddress } from './contact_filters.js';
|
||||
|
||||
const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
|
||||
const INDEX_TTL_MS = 5 * 60 * 1000;
|
||||
|
|
@ -62,125 +63,6 @@ function parseAddressList(header: string): Array<{ name: string; email: string }
|
|||
return result;
|
||||
}
|
||||
|
||||
// Local-part aliases that are almost always automated/role addresses you don't
|
||||
// compose a fresh message to. Matched as a whole segment of the local part
|
||||
// (segments split on . _ - +).
|
||||
const AUTOMATED_LOCAL_PARTS = new Set([
|
||||
'noreply', 'no-reply', 'donotreply', 'do-not-reply', 'reply',
|
||||
'notifications', 'notification', 'notify',
|
||||
'alerts', 'alert', 'updates', 'update',
|
||||
'news', 'newsletter', 'newsletters',
|
||||
'info', 'information', 'hello', 'hi', 'hey',
|
||||
'welcome', 'onboarding', 'getstarted',
|
||||
'team', 'marketing', 'promo', 'promos', 'promotions',
|
||||
'offer', 'offers', 'deals', 'deal',
|
||||
'accounts', 'account', 'billing', 'invoices', 'statements', 'statement',
|
||||
'learn', 'learning', 'courses',
|
||||
'mailer-daemon', 'mailerdaemon', 'postmaster', 'bounce', 'bounces',
|
||||
'automated', 'auto', 'autoconfirm',
|
||||
'support-bot', 'noticeboard', 'system',
|
||||
'contact', 'connect',
|
||||
'sender', 'broadcast', 'digest', 'campaign', 'campaigns',
|
||||
'support', 'service', 'help', 'helpdesk', 'feedback',
|
||||
'mailer', 'mailers', 'members', 'membership',
|
||||
'careers', 'jobs', 'recruit', 'recruiting',
|
||||
'tickets', 'orders', 'order', 'receipts', 'receipt',
|
||||
'applications', 'apply', 'admissions',
|
||||
'health', 'security', 'auth',
|
||||
]);
|
||||
|
||||
// Subdomain labels that flag a bulk/marketing infrastructure domain.
|
||||
const AUTOMATED_SUBDOMAIN_LABELS = new Set([
|
||||
'mail', 'mailer', 'mailers', 'mailing', 'mailgun', 'sendgrid', 'mta',
|
||||
'email', 'em', 'e', 'm',
|
||||
'news', 'newsletter', 'newsletters',
|
||||
'marketing', 'mkt', 'promo', 'promos', 'offers',
|
||||
'event', 'events', 'ecomm', 'commerce',
|
||||
'notifications', 'notification', 'notify', 'alerts', 'alert', 'updates',
|
||||
'messaging', 'message', 'msg',
|
||||
'noreply', 'donotreply',
|
||||
'creators', 'partners', 'team',
|
||||
'info', 'welcome', 'hi', 'hello',
|
||||
'bounces', 'bounce',
|
||||
'reply', 'user', 'usr', 'auto',
|
||||
]);
|
||||
|
||||
// Specific bulk-mail provider domains (substring match on full domain).
|
||||
const AUTOMATED_DOMAIN_KEYWORDS = [
|
||||
'facebookmail', 'kajabimail', 'substack', 'mailgun', 'sendgrid',
|
||||
'mcsv.net', 'mailchimp', 'mailerlite', 'createsend', 'cmail',
|
||||
'amazonses', 'sparkpost', 'sendinblue', 'brevo',
|
||||
'luma-mail', 'lumamail',
|
||||
'umusic-online', 'icloud-mail',
|
||||
];
|
||||
|
||||
function localSegments(local: string): string[] {
|
||||
return local.toLowerCase().split(/[._\-+]/).filter(Boolean);
|
||||
}
|
||||
|
||||
function isAutomatedAddress(email: string): boolean {
|
||||
if (!email) return true;
|
||||
const at = email.indexOf('@');
|
||||
if (at < 0) return true;
|
||||
const local = email.slice(0, at).toLowerCase();
|
||||
const domain = email.slice(at + 1).toLowerCase();
|
||||
|
||||
// Plus-aliased reply bots: `reply+abc123@…`
|
||||
if (/^reply\+/i.test(local)) return true;
|
||||
|
||||
// Whole-segment local-part matches.
|
||||
const segs = localSegments(local);
|
||||
for (const s of segs) {
|
||||
if (AUTOMATED_LOCAL_PARTS.has(s)) return true;
|
||||
}
|
||||
// Some senders pack noise into the local part with no separators
|
||||
// (e.g. `hdfcbanksmartstatement`). Catch the common ones.
|
||||
if (/(no.?reply|do.?not.?reply|notifications?|news.?letter|mailer.?daemon|postmaster|automated|broadcast|statement)/i.test(local)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Random-looking machine local parts: long, mostly hex/base32-ish.
|
||||
if (local.length >= 20 && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(local) && /[0-9]/.test(local)) {
|
||||
const digits = (local.match(/[0-9]/g) || []).length;
|
||||
if (digits / local.length >= 0.25) return true;
|
||||
}
|
||||
|
||||
// Subdomain-label check (everything except the registrable last two labels).
|
||||
const labels = domain.split('.');
|
||||
if (labels.length >= 3) {
|
||||
const subs = labels.slice(0, -2);
|
||||
for (const label of subs) {
|
||||
if (AUTOMATED_SUBDOMAIN_LABELS.has(label)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Provider keyword anywhere in the domain.
|
||||
for (const kw of AUTOMATED_DOMAIN_KEYWORDS) {
|
||||
if (domain.includes(kw)) return true;
|
||||
}
|
||||
|
||||
// Domain itself contains tell-tale tokens.
|
||||
if (/(^|\.)(mailers?|mailer|mailgun|sendgrid|mailchimp|mailerlite|bounces?|marketing|promo|notifications?|newsletter)(\.|$)/i.test(domain)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Marketing-style TLD / second-level domain (e.g. bookmyshow.email,
|
||||
// foo.marketing, bar.news). These domains exist almost exclusively for bulk.
|
||||
const sld = labels[labels.length - 1];
|
||||
if (['email', 'mail', 'marketing', 'promo', 'news', 'newsletter', 'click', 'link'].includes(sld)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Brand-identity addresses like `uber@uber.com`, `lenovo@lenovo.com` —
|
||||
// local part equals the first label of the domain. Almost always a
|
||||
// transactional/marketing sender.
|
||||
if (labels.length >= 2 && local === labels[0]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function ingestSnapshot(snapshot: GmailThreadSnapshot, selfEmail: string, map: Map<string, IndexEntry>): void {
|
||||
if (!snapshot?.messages) return;
|
||||
for (const msg of snapshot.messages) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { OAuth2Client } from 'google-auth-library';
|
|||
import { WorkDir } from '../config/config.js';
|
||||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { getUserEmail } from './classify_thread.js';
|
||||
import { isAutomatedAddress } from './contact_filters.js';
|
||||
|
||||
const STATE_FILE = path.join(WorkDir, 'contacts_sent.json');
|
||||
const RECENCY_HALFLIFE_DAYS = 60;
|
||||
|
|
@ -104,6 +105,7 @@ async function saveState(state: StoredState): Promise<void> {
|
|||
function indexFromStored(state: StoredState): Map<string, IndexEntry> {
|
||||
const map = new Map<string, IndexEntry>();
|
||||
for (const e of state.entries) {
|
||||
if (isAutomatedAddress(e.email)) continue;
|
||||
map.set(e.email, {
|
||||
name: e.name,
|
||||
email: e.email,
|
||||
|
|
@ -167,6 +169,7 @@ async function ingestMessage(
|
|||
];
|
||||
for (const { name, email } of recipients) {
|
||||
if (!email || email === selfEmail) continue;
|
||||
if (isAutomatedAddress(email)) continue;
|
||||
let entry = map.get(email);
|
||||
if (!entry) {
|
||||
entry = { name, email, count: 0, lastSeenMs: 0, nameCounts: new Map() };
|
||||
|
|
@ -374,6 +377,7 @@ export async function searchSentContacts(query: string, opts: SearchOpts = {}):
|
|||
const matches: Array<{ entry: IndexEntry; tier: number; s: number }> = [];
|
||||
for (const entry of cachedIndex.values()) {
|
||||
if (excluded.has(entry.email)) continue;
|
||||
if (isAutomatedAddress(entry.email)) continue;
|
||||
const tier = matchTier(q, entry);
|
||||
if (tier < 0) continue;
|
||||
matches.push({ entry, tier, s: score(entry, nowMs) });
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import container from '../../di/container.js';
|
|||
import { IGranolaConfigRepo } from './repo.js';
|
||||
import { serviceLogger } from '../../services/service_logger.js';
|
||||
import { limitEventItems } from '../limit_event_items.js';
|
||||
import { publishMeetingNotesReadyEvent } from '../meeting-events.js';
|
||||
import {
|
||||
GetDocumentsResponse,
|
||||
SyncState,
|
||||
|
|
@ -439,6 +440,14 @@ async function syncNotes(): Promise<void> {
|
|||
} else {
|
||||
console.log(`[Granola] Saved: ${filename}`);
|
||||
newCount++;
|
||||
// First-time write only — don't re-fire on later edits to the
|
||||
// same note (Granola notes update live).
|
||||
await publishMeetingNotesReadyEvent({
|
||||
source: 'granola',
|
||||
title: docTitle,
|
||||
filePath,
|
||||
when: docDate.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Update state
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ If there are events, include them:
|
|||
1. Use \`file-list\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`)
|
||||
2. Use \`file-readText\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`)
|
||||
3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response
|
||||
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example:
|
||||
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. If a draft includes a sign-off name, use only the user's first name, never their full name. Example:
|
||||
|
||||
\`\`\`
|
||||
\\\`\\\`\\\`emails
|
||||
|
|
|
|||
37
apps/x/packages/core/src/knowledge/meeting-events.ts
Normal file
37
apps/x/packages/core/src/knowledge/meeting-events.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { createEvent } from '../events/producer.js';
|
||||
|
||||
// Emitted when a meeting note/transcript is first written to disk (Fireflies,
|
||||
// Granola, …). This is the natural "the meeting is over and we have content"
|
||||
// signal — unlike a calendar end-time, the notes actually exist now. Coding
|
||||
// background tasks subscribe to it (via eventMatchCriteria) to scan freshly
|
||||
// landed notes for actionable coding items.
|
||||
//
|
||||
// Fire ONCE per meeting (on first write), not on every re-sync/edit, so a note
|
||||
// that keeps updating doesn't re-trigger downstream agents.
|
||||
export async function publishMeetingNotesReadyEvent(args: {
|
||||
source: string;
|
||||
title: string;
|
||||
filePath: string;
|
||||
when?: string;
|
||||
}): Promise<void> {
|
||||
const { source, title, filePath, when } = args;
|
||||
try {
|
||||
await createEvent({
|
||||
source,
|
||||
type: 'meeting.notes_ready',
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: [
|
||||
`# Meeting notes ready`,
|
||||
``,
|
||||
`**Title:** ${title}`,
|
||||
when ? `**When:** ${when}` : ``,
|
||||
`**Source:** ${source}`,
|
||||
`**Notes file:** \`${filePath}\``,
|
||||
``,
|
||||
`The full meeting notes/transcript are at the path above. They may contain coding action items (bugs to fix, features to build, changes requested). Read the file to decide whether to act.`,
|
||||
].filter(Boolean).join('\n'),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[${source}] Failed to publish meeting.notes_ready event:`, err);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { WorkDir } from '../config/config.js';
|
|||
import { FirefliesClientFactory } from './fireflies-client-factory.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { publishMeetingNotesReadyEvent } from './meeting-events.js';
|
||||
|
||||
// Configuration
|
||||
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies');
|
||||
|
|
@ -583,6 +584,15 @@ async function syncMeetings() {
|
|||
fs.writeFileSync(filePath, markdown);
|
||||
console.log(`[Fireflies] Saved: ${filename}`);
|
||||
|
||||
// First-time write for this meeting (guarded by syncedIds above) —
|
||||
// signal that fresh notes are available for downstream agents.
|
||||
await publishMeetingNotesReadyEvent({
|
||||
source: 'fireflies',
|
||||
title: meetingData.title || 'untitled',
|
||||
filePath,
|
||||
...(meetingData.dateString ? { when: meetingData.dateString } : {}),
|
||||
});
|
||||
|
||||
syncedIds.add(meetingId);
|
||||
newCount++;
|
||||
processedInBatch++;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { IModelConfigRepo } from "./repo.js";
|
|||
import { isSignedIn } from "../account/account.js";
|
||||
import container from "../di/container.js";
|
||||
|
||||
const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
|
||||
const SIGNED_IN_DEFAULT_MODEL = "anthropic/claude-opus-4.7";
|
||||
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
||||
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
|
||||
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ Subject: Re: {original_subject}
|
|||
|
||||
**Drafting Guidelines:**
|
||||
- Be concise and professional
|
||||
- If you include a sign-off name, use only the user's first name, never their full name
|
||||
- For scheduling: propose specific times based on calendar availability
|
||||
- For inquiries: answer directly or indicate what info is needed
|
||||
- Reference any relevant context from memory naturally
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ export type BackgroundTask = {
|
|||
instructions: string;
|
||||
active: boolean;
|
||||
triggers?: Triggers;
|
||||
// When set, this is a *coding* task: it implements code in the pinned code
|
||||
// project (a registered repo) via the `launch-code-task` tool, each launch
|
||||
// running in its own isolated worktree. Omit for ordinary OUTPUT/ACTION tasks.
|
||||
projectId?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
createdAt: string;
|
||||
|
|
@ -48,6 +52,7 @@ export type BackgroundTaskSummary = {
|
|||
instructions: string;
|
||||
active: boolean;
|
||||
triggers?: Triggers;
|
||||
projectId?: string;
|
||||
createdAt: string;
|
||||
lastAttemptAt?: string;
|
||||
lastRunId?: string;
|
||||
|
|
@ -56,11 +61,14 @@ export type BackgroundTaskSummary = {
|
|||
lastRunError?: string;
|
||||
};
|
||||
|
||||
// NOTE: keep `BackgroundTaskSummary` (above) and `BackgroundTask` (top) in sync.
|
||||
|
||||
export const BackgroundTaskSchema = z.object({
|
||||
name: z.string().min(1).describe('User-facing display name.'),
|
||||
instructions: z.string().min(1).describe('A persistent instruction in the user\'s words — what should this task keep doing? E.g. "Summarize my unread emails every morning into a brief digest." The agent re-reads instructions on every run and decides whether to rewrite index.md (OUTPUT mode) or perform a side-effect and journal it (ACTION mode) based on the verbs.'),
|
||||
active: z.boolean().default(true).describe('Set false to pause without deleting.'),
|
||||
triggers: TriggersSchema.optional().describe('When the agent fires. Omit for manual-only.'),
|
||||
projectId: z.string().optional().describe('When set, marks this as a coding task pinned to a registered code project (repo). The agent implements detected work via the launch-code-task tool, each launch in its own isolated worktree.'),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-task model override.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-task provider name override.'),
|
||||
createdAt: z.string().describe('ISO timestamp set once at create-time.'),
|
||||
|
|
@ -77,6 +85,7 @@ export const BackgroundTaskSummarySchema = z.object({
|
|||
instructions: z.string(),
|
||||
active: z.boolean(),
|
||||
triggers: TriggersSchema.optional(),
|
||||
projectId: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
lastAttemptAt: z.string().optional(),
|
||||
lastRunId: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,26 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const BillingPlanSchema = z.enum(['free', 'starter', 'pro']);
|
||||
export type BillingPlan = z.infer<typeof BillingPlanSchema>;
|
||||
export const BillingPlanCategorySchema = z.enum(['free', 'starter', 'pro']);
|
||||
export type BillingPlanCategory = z.infer<typeof BillingPlanCategorySchema>;
|
||||
|
||||
export const BillingPlanIdSchema = z.string().min(1);
|
||||
export type BillingPlanId = z.infer<typeof BillingPlanIdSchema>;
|
||||
|
||||
export const BillingCatalogPlanSchema = z.object({
|
||||
id: BillingPlanIdSchema,
|
||||
category: BillingPlanCategorySchema,
|
||||
displayName: z.string(),
|
||||
monthlyCredits: z.number(),
|
||||
dailyCredits: z.number(),
|
||||
monthlyPriceCents: z.number().nullable(),
|
||||
archived: z.boolean().optional(),
|
||||
});
|
||||
export type BillingCatalogPlan = z.infer<typeof BillingCatalogPlanSchema>;
|
||||
|
||||
export const BillingCatalogSchema = z.object({
|
||||
plans: z.array(BillingCatalogPlanSchema),
|
||||
});
|
||||
export type BillingCatalog = z.infer<typeof BillingCatalogSchema>;
|
||||
|
||||
export const BillingUsageBucketSchema = z.object({
|
||||
sanctionedCredits: z.number(),
|
||||
|
|
@ -13,12 +32,21 @@ export type BillingUsageBucket = z.infer<typeof BillingUsageBucketSchema>;
|
|||
export const BillingInfoSchema = z.object({
|
||||
userEmail: z.string().nullable(),
|
||||
userId: z.string().nullable(),
|
||||
subscriptionPlan: BillingPlanSchema.nullable(),
|
||||
subscriptionPlanId: BillingPlanIdSchema.nullable(),
|
||||
subscriptionStatus: z.string().nullable(),
|
||||
trialExpiresAt: z.string().nullable(),
|
||||
catalog: BillingCatalogSchema,
|
||||
monthly: BillingUsageBucketSchema,
|
||||
daily: BillingUsageBucketSchema.extend({
|
||||
usageDay: z.string(),
|
||||
}),
|
||||
});
|
||||
export type BillingInfo = z.infer<typeof BillingInfoSchema>;
|
||||
|
||||
export function getBillingPlanData(
|
||||
catalog: BillingCatalog,
|
||||
planId: string | null | undefined,
|
||||
): BillingCatalogPlan | null {
|
||||
if (!planId) return null;
|
||||
return catalog.plans.find((plan) => plan.id === planId) ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,11 +53,34 @@ export const CodeSession = z.object({
|
|||
// Where the agent works: the project path, or the worktree path.
|
||||
cwd: z.string(),
|
||||
worktree: CodeWorktree.optional(),
|
||||
// The coding agent's own model + reasoning effort (applied to the ACP engine,
|
||||
// not the Rowboat-mode LLM). Values come from CODE_AGENT_MODELS /
|
||||
// CODE_AGENT_EFFORTS; unset (or 'default') leaves the engine's own default.
|
||||
agentModel: z.string().optional(),
|
||||
agentEffort: z.string().optional(),
|
||||
createdAt: z.iso.datetime(),
|
||||
lastActivityAt: z.iso.datetime().optional(),
|
||||
});
|
||||
export type CodeSession = z.infer<typeof CodeSession>;
|
||||
|
||||
// Model + effort choices for the ACP coding agents are discovered live from the
|
||||
// engine (the same list `/model` shows), not hardcoded — so they always reflect
|
||||
// whatever the provider currently offers. See the `codeMode:listModelOptions`
|
||||
// IPC and CodeModeManager.listModelOptions. 'default' is a synthetic sentinel
|
||||
// meaning "don't override the engine default".
|
||||
//
|
||||
// Claude exposes model and effort as two independent options; Codex folds the
|
||||
// reasoning effort into the model id ("gpt-5-codex[high]") and so reports no
|
||||
// separate effort list. The UI renders whatever each agent advertises.
|
||||
export const CodeAgentOption = z.object({ value: z.string(), label: z.string() });
|
||||
export type CodeAgentOption = z.infer<typeof CodeAgentOption>;
|
||||
|
||||
export const CodeAgentModelOptions = z.object({
|
||||
models: z.array(CodeAgentOption),
|
||||
efforts: z.array(CodeAgentOption),
|
||||
});
|
||||
export type CodeAgentModelOptions = z.infer<typeof CodeAgentModelOptions>;
|
||||
|
||||
export const GitFileState = z.enum(["modified", "added", "deleted", "untracked", "renamed"]);
|
||||
export type GitFileState = z.infer<typeof GitFileState>;
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { BillingInfoSchema } from './billing.js';
|
|||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||
import { PermissionDecision, ApprovalPolicy, CodingAgent } from './code-mode.js';
|
||||
import { NotificationSettingsSchema } from './notification-settings.js';
|
||||
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile } from './code-sessions.js';
|
||||
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile, CodeAgentModelOptions } from './code-sessions.js';
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Validation Schemas (Single Source of Truth)
|
||||
|
|
@ -611,6 +611,10 @@ const ipcSchemas = {
|
|||
// chat, the model is fixed once the session's run exists.
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
// The coding agent's own model + reasoning effort (ACP engine). Unlike the
|
||||
// Rowboat model these are re-applied each turn, so they stay editable.
|
||||
agentModel: z.string().optional(),
|
||||
agentEffort: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
session: CodeSession,
|
||||
|
|
@ -626,12 +630,18 @@ const ipcSchemas = {
|
|||
'codeSession:update': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true }).partial(),
|
||||
patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true, agentModel: true, agentEffort: true }).partial(),
|
||||
}),
|
||||
res: z.object({
|
||||
session: CodeSession,
|
||||
}),
|
||||
},
|
||||
// Live model + effort choices for a coding agent, discovered from the engine
|
||||
// (cached per agent in the main process). Mirrors what `/model` would show.
|
||||
'codeMode:listModelOptions': {
|
||||
req: z.object({ agent: CodingAgent }),
|
||||
res: CodeAgentModelOptions,
|
||||
},
|
||||
'codeSession:delete': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
|
|
@ -1309,6 +1319,7 @@ const ipcSchemas = {
|
|||
name: z.string(),
|
||||
instructions: z.string(),
|
||||
triggers: TriggersSchema.optional(),
|
||||
projectId: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
import { BillingCatalogSchema } from './billing.js';
|
||||
|
||||
export const RowboatApiConfig = z.object({
|
||||
appUrl: z.string(),
|
||||
websocketApiUrl: z.string(),
|
||||
supabaseUrl: z.string(),
|
||||
billing: BillingCatalogSchema,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue