Merge remote-tracking branch 'origin/dev' into drive

This commit is contained in:
Gagancreates 2026-06-01 02:07:48 +05:30
commit 8e6978b6c1
40 changed files with 2181 additions and 375 deletions

View file

@ -10,11 +10,13 @@
*/
import * as esbuild from 'esbuild';
import { readFile } from 'node:fs/promises';
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
// The banner defines __import_meta_url at the top of the bundle,
// and we use define to replace all import.meta.url references with it.
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
await esbuild.build({
entryPoints: ['./dist/main.js'],
@ -36,6 +38,7 @@ await esbuild.build({
// Empty strings disable analytics gracefully.
'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''),
'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'),
'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''),
},
});

View file

@ -1,4 +1,4 @@
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron';
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } from 'electron';
import { ipc } from '@x/shared';
import path from 'node:path';
import os from 'node:os';
@ -31,6 +31,9 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
@ -453,6 +456,7 @@ export function setupIpcHandlers() {
return {
installationId: getInstallationId(),
apiUrl: API_URL,
appVersion: app.getVersion(),
};
},
'workspace:getRoot': async () => {
@ -527,7 +531,7 @@ export function setupIpcHandlers() {
return runsCore.createRun(args);
},
'runs:createMessage': async (_event, args) => {
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) };
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) };
},
'runs:authorizePermission': async (_event, args) => {
await runsCore.authorizePermission(args.runId, args.authorization);
@ -631,6 +635,20 @@ export function setupIpcHandlers() {
const config = await repo.getConfig();
return { enabled: config.enabled };
},
'codeMode:getConfig': async () => {
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
const config = await repo.getConfig();
return { enabled: config.enabled };
},
'codeMode:setConfig': async (_event, args) => {
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
await repo.setConfig({ enabled: args.enabled });
invalidateCopilotInstructionsCache();
return { success: true };
},
'codeMode:checkAgentStatus': async () => {
return await checkCodeModeAgentStatus();
},
'granola:setConfig': async (_event, args) => {
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
await repo.setConfig({ enabled: args.enabled });

View file

@ -51,6 +51,7 @@ import {
extractDeepLinkFromArgv,
setMainWindowForDeepLinks,
} from "./deeplink.js";
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
const execAsync = promisify(exec);
@ -351,6 +352,11 @@ app.whenReady().then(async () => {
registerConsumer(backgroundTaskEventConsumer);
initEventProcessor();
// If the stored Google grant predates a scope change (only old scopes),
// disconnect it now so the user re-connects with the current scopes before
// any Google sync runs against the stale grant.
await disconnectGoogleIfScopesStale();
// start gmail sync
initGmailSync();

View file

@ -508,7 +508,7 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
if (connection.mode === 'rowboat' && connection.tokens?.access_token) {
try {
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
const res = await fetch(revokeUrl, { method: 'POST' });
const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
if (!res.ok) {
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`);
}
@ -532,6 +532,81 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
}
}
/**
* Startup migration for Google scope changes. When a connected Google grant was
* issued before a scope was added (e.g. old installs on gmail.readonly that
* never received gmail.modify), invalidate it so the user is prompted to
* reconnect and re-grant with the current scopes. The currently-requested
* scopes in the provider config are the source of truth: a grant missing any
* of them is treated as stale.
*
* We revoke + clear the stale token but DELIBERATELY keep the provider entry
* with an `error` set rather than calling disconnectProvider (which deletes the
* whole entry). The renderer's reconnect prompts the sidebar "Reconnect your
* accounts" alert and the connectors "Reconnect" row key off this `error`
* field, not off the connected flag. A fully deleted entry has no error and is
* indistinguishable from "never connected", so no prompt would ever appear.
*
* Tokens with no recorded scopes (very old installs that never persisted them)
* are also treated as stale. Safe to call on every startup it's a no-op once
* the grant covers all current scopes, and once invalidated the early return on
* the missing token keeps it from re-running until the user reconnects.
*/
export async function disconnectGoogleIfScopesStale(): Promise<void> {
try {
const oauthRepo = getOAuthRepo();
const connection = await oauthRepo.read('google');
// Not connected (or already invalidated) — nothing to migrate.
if (!connection.tokens) {
return;
}
const providerConfig = await getProviderConfig('google');
const requiredScopes = providerConfig.scopes ?? [];
if (requiredScopes.length === 0) {
return;
}
const granted = new Set(connection.tokens.scopes ?? []);
const missingScopes = requiredScopes.filter((scope) => !granted.has(scope));
if (missingScopes.length === 0) {
return;
}
console.log(
`[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` +
'invalidating it so the user is prompted to reconnect with the new scopes.'
);
// Best-effort revoke at Google for rowboat-mode grants (mirrors disconnectProvider).
if (connection.mode === 'rowboat' && connection.tokens.access_token) {
try {
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
if (!res.ok) {
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local invalidation`);
}
} catch (error) {
console.warn('[OAuth] Google revoke failed; continuing with local invalidation:', error);
}
}
// Drop the stale token but keep the entry with an error so the reconnect
// prompt fires (see the note above).
await oauthRepo.upsert('google', {
tokens: null,
error: 'Google permissions changed. Please reconnect to continue.',
});
// Nudge any already-open window to re-read state. The renderer's initial
// mount also re-reads, so the prompt shows even if no window is up yet.
emitOAuthEvent({ provider: 'google', success: false });
} catch (error) {
console.error('[OAuth] Google scope migration check failed:', error);
}
}
/**
* Get access token for a provider (internal use only)
* Refreshes token if expired

View file

@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@eigenpal/docx-editor-react": "^1.0.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
@ -46,6 +47,15 @@
"motion": "^12.23.26",
"nanoid": "^5.1.6",
"posthog-js": "^1.332.0",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-history": "^1.5.0",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.7",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.8",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",

View file

@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, Plus, HistoryIcon } from 'lucide-react';
import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar';
@ -18,6 +18,7 @@ import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/ba
import { ImageFileViewer } from '@/components/image-file-viewer';
import { VideoFileViewer } from '@/components/video-file-viewer';
import { AudioFileViewer } from '@/components/audio-file-viewer';
import { DocxFileViewer } from '@/components/docx-file-viewer';
import { PersistentViewerCache } from '@/components/persistent-viewer-cache';
import { UnsupportedFileViewer } from '@/components/unsupported-file-viewer';
import { getViewerType, isCacheableViewerPath } from '@/lib/file-types';
@ -66,12 +67,6 @@ import {
} from "@/components/ui/sidebar"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner"
import { BillingErrorDialog } from "@/components/billing-error-dialog"
@ -1007,7 +1002,7 @@ function App() {
voice.start()
}, [voice])
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise<void>) | null>(null)
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise<void>) | null>(null)
const pendingVoiceInputRef = useRef(false)
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
@ -2299,6 +2294,19 @@ function App() {
status: 'running',
timestamp: Date.now(),
}])
// Detect acpx-driven coding-agent runs so the composer can retroactively
// flip code mode on with the right agent (when the user reached the skill
// via plain prompt rather than the explicit toggle).
if (llmEvent.toolName === 'executeCommand') {
const input = llmEvent.input as { command?: unknown } | undefined
const cmd = typeof input?.command === 'string' ? input.command : ''
const match = cmd.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b/)
if (match) {
window.dispatchEvent(new CustomEvent('code-mode-detected', {
detail: { runId: event.runId, agent: match[1] as 'claude' | 'codex' },
}))
}
}
} else if (llmEvent.type === 'finish-step') {
const nextUsage = normalizeUsage(llmEvent.usage)
if (nextUsage) {
@ -2413,7 +2421,7 @@ function App() {
return next
})
if (event.toolCallId && event.toolName !== 'executeCommand') {
if (event.toolCallId) {
setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false)
}
@ -2591,6 +2599,7 @@ function App() {
mentions?: FileMention[],
stagedAttachments: StagedAttachment[] = [],
searchEnabled?: boolean,
codeMode?: 'claude' | 'codex',
) => {
if (isProcessing) return
@ -2702,6 +2711,7 @@ function App() {
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
codeMode: codeMode || undefined,
middlePaneContext,
})
analytics.chatMessageSent({
@ -2717,6 +2727,7 @@ function App() {
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
codeMode: codeMode || undefined,
middlePaneContext,
})
analytics.chatMessageSent({
@ -5312,25 +5323,6 @@ function App() {
if (tabId === activeChatTabId) return activeChatTabState
return chatViewStateByTab[tabId] ?? emptyChatTabState
}, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState])
const activeRunIdForDownload = activeChatTabState.runId
const handleDownloadActiveChatLog = useCallback(async () => {
if (!activeRunIdForDownload) {
toast.error('No chat log available yet')
return
}
try {
const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunIdForDownload })
if (result.success) {
toast.success('Chat log saved')
} else if (result.error) {
toast.error(result.error)
}
} catch (err) {
console.error('Download chat log failed:', err)
toast.error('Failed to download chat log')
}
}, [activeRunIdForDownload])
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
@ -5517,33 +5509,6 @@ function App() {
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0"
aria-label="Chat options"
>
<MoreHorizontal className="size-5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Chat options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem
disabled={!activeRunIdForDownload}
onSelect={() => {
void handleDownloadActiveChatLog()
}}
>
<Bug className="size-4" />
Download chat log
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Trailing layout control. Always mounted (just toggled invisible
when inactive) so its -webkit-app-region:no-drag rect is stable
a freshly-mounted no-drag button inside the drag-region header
@ -5654,7 +5619,11 @@ function App() {
remove: knowledgeActions.remove,
copyPath: knowledgeActions.copyPath,
revealInFileManager: knowledgeActions.revealInFileManager,
createNote: knowledgeActions.createNote,
createFolder: knowledgeActions.createFolder,
onOpenInNewTab: knowledgeActions.onOpenInNewTab,
}}
onNavigate={(path) => { void navigateToView({ type: 'workspace', path: path === WORKSPACE_ROOT ? undefined : path }) }}
onOpenNote={(path) => navigateToFile(path)}
onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }}
/>
@ -5880,6 +5849,10 @@ function App() {
<div className="flex-1 min-h-0 overflow-hidden">
<AudioFileViewer path={selectedPath} />
</div>
) : selectedPath && getViewerType(selectedPath) === 'docx' ? (
<div className="flex-1 min-h-0 overflow-hidden">
<DocxFileViewer path={selectedPath} />
</div>
) : (
<div className="flex-1 min-h-0 overflow-hidden">
<UnsupportedFileViewer path={selectedPath} />
@ -5962,7 +5935,6 @@ function App() {
const response = tabState.permissionResponses.get(item.id) || null
return (
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
permission={permRequest.permission}
@ -5970,9 +5942,28 @@ function App() {
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
onSwitchAgent={async (newAgent) => {
const runIdForSwitch = tab.runId
await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')
window.dispatchEvent(new CustomEvent('code-mode-detected', {
detail: { runId: runIdForSwitch, agent: newAgent },
}))
if (runIdForSwitch) {
try {
await window.ipc.invoke('runs:createMessage', {
runId: runIdForSwitch,
message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`,
codeMode: newAgent,
})
} catch (err) {
console.error('Failed to send swap-agent follow-up', err)
}
}
}}
isProcessing={isActive && isProcessing}
response={response}
/>
{rendered}
</React.Fragment>
)
}
@ -5984,6 +5975,7 @@ function App() {
<AskHumanRequest
key={request.toolCallId}
query={request.query}
options={request.options}
onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}
isProcessing={isActive && isProcessing}
/>

View file

@ -9,6 +9,7 @@ import { useState, useRef, useEffect } from "react";
export type AskHumanRequestProps = ComponentProps<"div"> & {
query: string;
options?: string[];
onResponse: (response: string) => void;
isProcessing?: boolean;
};
@ -16,17 +17,21 @@ export type AskHumanRequestProps = ComponentProps<"div"> & {
export const AskHumanRequest = ({
className,
query,
options,
onResponse,
isProcessing = false,
...props
}: AskHumanRequestProps) => {
const [response, setResponse] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hasOptions = Array.isArray(options) && options.length > 0;
useEffect(() => {
// Auto-focus the textarea when component mounts
textareaRef.current?.focus();
}, []);
// Auto-focus the textarea when in free-text mode; nothing to focus for buttons.
if (!hasOptions) {
textareaRef.current?.focus();
}
}, [hasOptions]);
const handleSubmit = () => {
const trimmed = response.trim();
@ -36,6 +41,11 @@ export const AskHumanRequest = ({
}
};
const handleOptionClick = (option: string) => {
if (isProcessing) return;
onResponse(option);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@ -65,30 +75,47 @@ export const AskHumanRequest = ({
{query}
</p>
</div>
<div className="space-y-2">
<Textarea
ref={textareaRef}
value={response}
onChange={(e) => setResponse(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your response..."
disabled={isProcessing}
rows={3}
className="resize-none"
/>
<div className="flex justify-end">
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className="gap-2"
>
<ArrowUpIcon className="size-4" />
Send Response
</Button>
{hasOptions ? (
<div className="flex flex-wrap gap-2">
{options!.map((option) => (
<Button
key={option}
variant="outline"
size="sm"
onClick={() => handleOptionClick(option)}
disabled={isProcessing}
className="bg-background"
>
{option}
</Button>
))}
</div>
</div>
) : (
<div className="space-y-2">
<Textarea
ref={textareaRef}
value={response}
onChange={(e) => setResponse(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your response..."
disabled={isProcessing}
rows={3}
className="resize-none"
/>
<div className="flex justify-end">
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className="gap-2"
>
<ArrowUpIcon className="size-4" />
Send Response
</Button>
</div>
</div>
)}
</div>
</div>
</div>

View file

@ -9,8 +9,8 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, RefreshCwIcon, Terminal, XIcon } from "lucide-react";
import { useState, type ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import z from "zod";
@ -21,6 +21,7 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
onApproveSession?: () => void;
onApproveAlways?: () => void;
onDeny?: () => void;
onSwitchAgent?: (newAgent: 'claude' | 'codex') => void;
isProcessing?: boolean;
response?: 'approve' | 'deny' | null;
permission?: z.infer<typeof ToolPermissionMetadata>;
@ -41,6 +42,7 @@ export const PermissionRequest = ({
onApproveSession,
onApproveAlways,
onDeny,
onSwitchAgent,
isProcessing = false,
response = null,
permission,
@ -54,17 +56,33 @@ export const PermissionRequest = ({
: null;
const filePermission = permission?.kind === "file" ? permission : null;
// Detect acpx coding-agent invocations so we can show the agent identity and
// offer a one-click swap-and-retry.
const acpxAgent: 'claude' | 'codex' | null = (() => {
if (!command) return null;
const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/);
return match ? (match[1] as 'claude' | 'codex') : null;
})();
const otherAgent: 'claude' | 'codex' | null = acpxAgent === 'claude' ? 'codex' : acpxAgent === 'codex' ? 'claude' : null;
const agentDisplay = acpxAgent === 'claude' ? 'Claude Code' : acpxAgent === 'codex' ? 'Codex' : null;
const otherDisplay = otherAgent === 'claude' ? 'Claude Code' : otherAgent === 'codex' ? 'Codex' : null;
const isResponded = response !== null;
const isApproved = response === 'approve';
// Once a response is chosen, collapse the details to just the header.
// Users can click the header to expand them again.
const [expanded, setExpanded] = useState(false);
const showDetails = !isResponded || expanded;
return (
<div
className={cn(
"not-prose mb-4 w-full rounded-md border",
isResponded
? isApproved
? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20"
: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20"
? "border-green-500/60 bg-green-200/80 dark:border-green-500/40 dark:bg-green-900/40"
: "border-[#fa2525]/70 bg-[#fa2525]/30 dark:border-[#fa2525]/60 dark:bg-[#fa2525]/30"
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
className
)}
@ -72,50 +90,41 @@ export const PermissionRequest = ({
>
<div className="p-4 space-y-4">
<div className="flex items-start gap-3">
{isResponded ? (
isApproved ? (
<CheckCircleIcon className="size-5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
) : (
<XCircleIcon className="size-5 text-red-600 dark:text-red-500 shrink-0 mt-0.5" />
)
) : (
{!isResponded && (
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
)}
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div
className={cn("flex items-center gap-2", isResponded && "cursor-pointer select-none")}
onClick={isResponded ? () => setExpanded((v) => !v) : undefined}
>
<div className="flex-1">
<h3 className="font-semibold text-sm text-foreground">
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{isResponded ? "Requested:" : "The agent wants to execute:"} <span className="font-mono font-medium">{toolCall.toolName}</span>
{agentDisplay && (
<Badge
variant="secondary"
className="ml-2 align-middle bg-secondary text-foreground"
>
<Terminal className="size-3 mr-1" />
{agentDisplay}
</Badge>
)}
</p>
</div>
{isResponded && (
<Badge
variant="secondary"
<ChevronDownIcon
className={cn(
"shrink-0",
isApproved
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400"
"size-4 shrink-0 text-muted-foreground transition-transform",
expanded ? "rotate-180" : "rotate-0"
)}
>
{isApproved ? (
<>
<CheckIcon className="size-3 mr-1" />
Approved
</>
) : (
<>
<XIcon className="size-3 mr-1" />
Denied
</>
)}
</Badge>
/>
)}
</div>
{command && (
{showDetails && command && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Command
@ -125,7 +134,7 @@ export const PermissionRequest = ({
</pre>
</div>
)}
{filePermission && (
{showDetails && filePermission && (
<div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
@ -153,7 +162,7 @@ export const PermissionRequest = ({
</div>
</div>
)}
{!command && !filePermission && toolCall.arguments && (
{showDetails && !command && !filePermission && toolCall.arguments && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Arguments
@ -211,6 +220,18 @@ export const PermissionRequest = ({
<XIcon className="size-4" />
Deny
</Button>
{otherAgent && otherDisplay && onSwitchAgent && (
<Button
variant="secondary"
size="sm"
onClick={() => onSwitchAgent(otherAgent)}
disabled={isProcessing}
className="flex-1"
>
<RefreshCwIcon className="size-4" />
Use {otherDisplay} instead
</Button>
)}
</div>
)}
</div>

View file

@ -18,6 +18,7 @@ import {
Mic,
Plus,
Square,
Terminal,
X,
} from 'lucide-react'
@ -108,7 +109,7 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
}
interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
@ -178,6 +179,9 @@ function ChatInputInner({
const [searchEnabled, setSearchEnabled] = useState(false)
const [searchAvailable, setSearchAvailable] = useState(false)
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
// When a run exists, freeze the dropdown to the run's resolved model+provider.
useEffect(() => {
@ -260,8 +264,89 @@ function ChatInputInner({
return () => window.removeEventListener('models-config-changed', handler)
}, [loadModelConfig])
// Load the global code-mode feature flag (from settings) and stay in sync.
useEffect(() => {
const load = () => {
window.ipc.invoke('codeMode:getConfig', null)
.then((r) => setCodeModeFeatureEnabled(r.enabled))
.catch(() => setCodeModeFeatureEnabled(false))
}
load()
window.addEventListener('code-mode-config-changed', load)
return () => window.removeEventListener('code-mode-config-changed', load)
}, [])
// If the feature is turned off in settings, also turn off any per-conversation chip.
useEffect(() => {
if (!codeModeFeatureEnabled && codeModeEnabled) {
setCodeModeEnabled(false)
}
}, [codeModeFeatureEnabled, codeModeEnabled])
// Listen for coding-agent runs that were triggered without the explicit code-mode
// toggle. App.tsx dispatches this when it sees an acpx executeCommand fire. We
// flip the pill on with the detected agent so the UI reflects what's happening.
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent<{ runId?: string; agent?: 'claude' | 'codex' }>).detail
if (!detail || !detail.agent) return
if (runId && detail.runId && detail.runId !== runId) return
setCodeModeEnabled(true)
setCodingAgent(detail.agent)
}
window.addEventListener('code-mode-detected', handler)
return () => window.removeEventListener('code-mode-detected', handler)
}, [runId])
// Cross-platform basename — handles both / and \ separators.
const basename = useCallback((p: string): string => {
const trimmed = p.replace(/[\\/]+$/, '')
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
}, [])
// Load coding-agent preference for a given workdir.
// Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' }
const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => {
if (!dir) return 'claude'
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
const parsed = JSON.parse(result.data) as Record<string, unknown>
const value = parsed?.[dir]
if (value === 'codex' || value === 'claude') return value
} catch {
/* file missing or invalid — fall through to default */
}
return 'claude'
}, [])
const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => {
let existing: Record<string, 'claude' | 'codex'> = {}
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
const parsed = JSON.parse(result.data) as Record<string, unknown>
for (const [k, v] of Object.entries(parsed ?? {})) {
if (v === 'claude' || v === 'codex') existing[k] = v
}
} catch { /* start fresh */ }
existing[dir] = agent
await window.ipc.invoke('workspace:writeFile', {
path: 'config/coding-agents.json',
data: JSON.stringify(existing, null, 2),
})
}, [])
// Work directory is owned per-chat by the parent (App). This component only
// drives the picker dialog and reports changes up via onWorkDirChange.
// drives the picker dialog and reports changes up via onWorkDirChange. Whenever
// the work directory changes, load its persisted coding-agent preference.
useEffect(() => {
let cancelled = false
loadCodingAgentFor(workDir).then((agent) => {
if (!cancelled) setCodingAgent(agent)
})
return () => { cancelled = true }
}, [workDir, loadCodingAgentFor])
const handleSetWorkDir = useCallback(async () => {
try {
let defaultPath: string | undefined = workDir ?? undefined
@ -282,18 +367,35 @@ function ChatInputInner({
})
if (!chosen) return
onWorkDirChange?.(chosen)
setCodingAgent(await loadCodingAgentFor(chosen))
toast.success(`Work directory set: ${chosen}`)
} catch (err) {
console.error('Failed to set work directory', err)
toast.error('Failed to set work directory')
}
}, [workDir, onWorkDirChange])
}, [workDir, onWorkDirChange, loadCodingAgentFor])
const handleClearWorkDir = useCallback(() => {
onWorkDirChange?.(null)
setCodingAgent('claude')
toast.success('Work directory cleared')
}, [onWorkDirChange])
const handleToggleCodingAgent = useCallback(async () => {
const next: 'claude' | 'codex' = codingAgent === 'claude' ? 'codex' : 'claude'
setCodingAgent(next)
// Persist only when scoped to a workdir; without one there's nothing to key on.
if (!workDir) return
try {
await persistCodingAgent(workDir, next)
} catch (err) {
console.error('Failed to save coding agent', err)
toast.error('Failed to save coding agent')
// revert on failure
setCodingAgent(codingAgent)
}
}, [workDir, codingAgent, persistCodingAgent])
// Check search tool availability (exa or signed-in via gateway)
useEffect(() => {
const checkSearch = async () => {
@ -378,13 +480,15 @@ function ChatInputInner({
const handleSubmit = useCallback(() => {
if (!canSubmit) return
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined)
// codeMode is sticky per conversation — don't reset after send.
const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode)
controller.textInput.clear()
controller.mentions.clearMentions()
setAttachments([])
// Web search toggle stays on for the rest of the chat session; the user
// turns it off explicitly. (Not persisted across app restarts.)
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled])
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, workDir])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@ -529,15 +633,20 @@ function ChatInputInner({
</div>
<div className="flex items-center gap-2 px-4 pb-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Add"
>
<Plus className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Add"
>
<Plus className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Add files or set work directory</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
<ImagePlus className="size-4" />
@ -559,7 +668,7 @@ function ChatInputInner({
className="flex min-w-0 items-center gap-1.5"
>
<FolderCog className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{workDir.split('/').pop() || workDir}</span>
<span className="truncate">{basename(workDir) || workDir}</span>
</button>
<button
type="button"
@ -600,6 +709,52 @@ function ChatInputInner({
</span>
</button>
)}
{codeModeFeatureEnabled && (codeModeEnabled ? (
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCodeModeEnabled(false)}
className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70"
>
<Terminal className="h-3.5 w-3.5" />
<span>Code</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">Code mode on click to disable</TooltipContent>
</Tooltip>
<span className="text-foreground/30">·</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleToggleCodingAgent}
className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70"
>
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} click to swap
</TooltipContent>
</Tooltip>
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCodeModeEnabled(true)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Code mode"
>
<Terminal className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
</Tooltip>
))}
<div className="flex-1" />
{lockedModel ? (
<span
@ -760,7 +915,7 @@ export interface ChatInputWithMentionsProps {
knowledgeFiles: string[]
recentFiles: string[]
visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean

View file

@ -645,7 +645,6 @@ export function ChatSidebar({
const response = tabState.permissionResponses.get(item.id) || null
return (
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
@ -655,6 +654,7 @@ export function ChatSidebar({
isProcessing={isActive && isProcessing}
response={response}
/>
{rendered}
</React.Fragment>
)
}

View file

@ -0,0 +1,196 @@
import { Suspense, lazy, useEffect, useRef, useState } from 'react'
import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
import type { DocxEditorRef } from '@eigenpal/docx-editor-react'
// The editor (and its CSS) is heavy and only needed when a .docx is open, so it
// loads in its own chunk the first time a Word document is viewed.
const LazyDocxEditor = lazy(async () => {
const [mod] = await Promise.all([
import('@eigenpal/docx-editor-react'),
import('@eigenpal/docx-editor-react/styles.css'),
])
return { default: mod.DocxEditor }
})
interface DocxFileViewerProps {
path: string
}
type LoadState = 'loading' | 'ready' | 'error'
type SaveState = 'idle' | 'saving' | 'saved' | 'error'
const SAVE_DEBOUNCE_MS = 800
// onChange fires for the editor's own load-time normalization. Ignore changes
// until shortly after the document settles so opening a file never rewrites it.
const ARM_DELAY_MS = 500
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64)
const len = binary.length
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i)
return bytes.buffer
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
const chunk = 0x8000
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
}
return btoa(binary)
}
function baseName(path: string): string {
const segs = path.split('/')
return segs[segs.length - 1] || path
}
export function DocxFileViewer({ path }: DocxFileViewerProps) {
const [loadState, setLoadState] = useState<LoadState>('loading')
const [buffer, setBuffer] = useState<ArrayBuffer | null>(null)
const [saveState, setSaveState] = useState<SaveState>('idle')
const editorRef = useRef<DocxEditorRef>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const armTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const armedRef = useRef(false)
const dirtyRef = useRef(false)
const savingRef = useRef(false)
// Load the .docx bytes whenever the path changes.
useEffect(() => {
let cancelled = false
setLoadState('loading')
setBuffer(null)
setSaveState('idle')
armedRef.current = false
dirtyRef.current = false
savingRef.current = false
;(async () => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path, encoding: 'base64' })
if (cancelled) return
setBuffer(base64ToArrayBuffer(result.data))
setLoadState('ready')
if (armTimerRef.current) clearTimeout(armTimerRef.current)
armTimerRef.current = setTimeout(() => { armedRef.current = true }, ARM_DELAY_MS)
} catch (err) {
console.error('Failed to load docx:', err)
if (!cancelled) setLoadState('error')
}
})()
return () => {
cancelled = true
if (armTimerRef.current) clearTimeout(armTimerRef.current)
}
}, [path])
// Serialize the current document and write it back to disk.
const persist = async () => {
const editor = editorRef.current
if (!editor || savingRef.current) return
savingRef.current = true
dirtyRef.current = false
setSaveState('saving')
try {
const out = await editor.save()
if (out) {
await window.ipc.invoke('workspace:writeFile', {
path,
data: arrayBufferToBase64(out),
opts: { encoding: 'base64' },
})
}
setSaveState('saved')
} catch (err) {
console.error('Failed to save docx:', err)
dirtyRef.current = true
setSaveState('error')
} finally {
savingRef.current = false
// A change landed while we were saving — flush it.
if (dirtyRef.current) scheduleSave()
}
}
const scheduleSave = () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => { void persist() }, SAVE_DEBOUNCE_MS)
}
const handleChange = () => {
if (!armedRef.current) return
dirtyRef.current = true
scheduleSave()
}
// Flush a pending save when navigating away or unmounting.
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
if (dirtyRef.current) void persist()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path])
if (loadState === 'error') {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
<FileTextIcon className="size-6" />
<p className="text-sm font-medium text-foreground">Cannot open this document</p>
<p className="max-w-md text-xs">The file may be corrupted or not a valid Word document.</p>
<button
type="button"
onClick={() => { void window.ipc.invoke('shell:openPath', { path }) }}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
>
<ExternalLinkIcon className="size-3.5" />
Open in system
</button>
</div>
)
}
if (loadState === 'loading' || !buffer) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading document</p>
</div>
)
}
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<Suspense
fallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading editor</p>
</div>
}
>
<LazyDocxEditor
key={path}
ref={editorRef}
documentBuffer={buffer}
mode="editing"
documentName={baseName(path)}
documentNameEditable={false}
onChange={handleChange}
onError={(err) => { console.error('docx editor error:', err) }}
className="flex-1 min-h-0"
/>
</Suspense>
{saveState !== 'idle' && (
<div className="pointer-events-none absolute bottom-3 right-4 z-10 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur">
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : 'Save failed'}
</div>
)}
</div>
)
}

View file

@ -69,6 +69,31 @@ function snippet(text?: string): string {
return (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
}
function isReplyQuoteBoundary(lines: string[], index: number): boolean {
const line = lines[index]?.trim() || ''
if (/^On\b.+\bwrote:\s*$/i.test(line)) return true
if (/^-{2,}\s*(Original Message|Forwarded message)\s*-{2,}$/i.test(line)) return true
if (/^From:\s+\S/i.test(line)) {
const next = lines.slice(index + 1, index + 6).map((value) => value.trim())
return next.some((value) => /^(Sent|Date):\s+\S/i.test(value))
&& next.some((value) => /^To:\s+\S/i.test(value))
&& next.some((value) => /^Subject:\s+\S/i.test(value))
}
return false
}
function stripQuotedReplyText(text: string): string {
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
const boundary = lines.findIndex((line, index) => {
if (isReplyQuoteBoundary(lines, index)) return true
return index > 0
&& line.trim().startsWith('>')
&& (lines[index - 1]?.trim() === '' || lines[index - 1]?.trim().startsWith('>'))
})
const visible = boundary >= 0 ? lines.slice(0, boundary) : lines
return visible.join('\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
}
function getInitial(from?: string): string {
return (extractName(from)[0] || '?').toUpperCase()
}
@ -692,7 +717,7 @@ function ComposeBox({
const initialContent = useMemo(() => {
if (mode === 'forward') return buildForwardedContent(thread)
// Gmail-side draft (user's own work) wins over the AI-generated draft.
const source = thread.gmail_draft || thread.draft_response
const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '')
if (!source) return ''
return source
.split(/\n{2,}/)
@ -1048,8 +1073,7 @@ function ThreadDetail({
const MAX_KEPT_OPEN = 5
const PAGE_SIZE = 25
const SECTIONS = ['important', 'other'] as const
type InboxSection = (typeof SECTIONS)[number]
type InboxSection = 'important' | 'other'
interface SectionState {
threads: GmailThread[]

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw } from "lucide-react"
import {
Dialog,
@ -26,7 +26,7 @@ import { toast } from "sonner"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "appearance" | "note-tagging" | "help"
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
interface TabConfig {
id: ConfigTab
@ -70,6 +70,12 @@ const tabs: TabConfig[] = [
path: "config/security.json",
description: "Configure allowed shell commands",
},
{
id: "code-mode",
label: "Code Mode",
icon: Terminal,
description: "Delegate coding tasks to Claude Code or Codex",
},
{
id: "appearance",
label: "Appearance",
@ -1648,6 +1654,198 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
)
}
// --- Code Mode Settings ---
type AgentStatus = { installed: boolean; signedIn: boolean }
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
function AgentStatusRow({
name,
installLink,
signInCommand,
status,
}: {
name: string
installLink: string
signInCommand: string
status: AgentStatus | null
}) {
const ready = status?.installed && status?.signedIn
const needsSignInOnly = status?.installed && !status?.signedIn
return (
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
<Terminal className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{name}</div>
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Installed
</span>
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Signed in
</span>
</div>
</div>
{ready ? (
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
Ready
</span>
) : needsSignInOnly ? (
<span className="text-xs text-muted-foreground shrink-0">
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
</span>
) : (
<a
href={installLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline shrink-0"
>
Install &amp; sign in
</a>
)}
</div>
)
}
function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [enabled, setEnabled] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
const [statusLoading, setStatusLoading] = useState(false)
const loadStatus = useCallback(async () => {
setStatusLoading(true)
try {
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
setStatus(result)
} catch {
setStatus(null)
} finally {
setStatusLoading(false)
}
}, [])
useEffect(() => {
if (!dialogOpen) return
let cancelled = false
async function load() {
setLoading(true)
try {
const result = await window.ipc.invoke("codeMode:getConfig", null)
if (!cancelled) setEnabled(result.enabled)
} catch {
if (!cancelled) setEnabled(false)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
loadStatus()
return () => { cancelled = true }
}, [dialogOpen, loadStatus])
const handleToggle = useCallback(async (next: boolean) => {
setSaving(true)
setEnabled(next)
try {
await window.ipc.invoke("codeMode:setConfig", { enabled: next })
window.dispatchEvent(new Event("code-mode-config-changed"))
toast.success(next ? "Code mode enabled" : "Code mode disabled")
} catch {
setEnabled(!next)
toast.error("Failed to update code mode")
} finally {
setSaving(false)
}
}, [])
const anyReady = status?.claude.installed && status?.claude.signedIn
|| status?.codex.installed && status?.codex.signedIn
if (loading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="space-y-5">
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
<p>
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
on your machine. Pick the agent inline from the composer; the assistant calls it via
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">acpx</code>
and streams results back into chat.
</p>
<p>
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent status</span>
<button
onClick={() => { void loadStatus() }}
disabled={statusLoading}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{statusLoading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
Re-check
</button>
</div>
<div className="space-y-2">
<AgentStatusRow
name="Claude Code"
installLink="https://claude.ai/code"
signInCommand="claude login"
status={status?.claude ?? null}
/>
<AgentStatusRow
name="Codex"
installLink="https://developers.openai.com/codex/cli"
signInCommand="codex login"
status={status?.codex ?? null}
/>
</div>
</div>
<div className="rounded-md border px-3 py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">Enable code mode</div>
<div className="text-xs text-muted-foreground mt-0.5">
Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={saving}
/>
</div>
{enabled && status && !anyReady && (
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
<div className="text-amber-900 dark:text-amber-200">
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
account, then click Re-check.
</div>
</div>
)}
</div>
)
}
// --- Main Settings Dialog ---
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
@ -1695,7 +1893,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help") return
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
@ -1803,7 +2001,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connections" ? (
@ -1828,6 +2026,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<AppearanceSettings />
) : activeTab === "help" ? (
<HelpSettings />
) : activeTab === "code-mode" ? (
<CodeModeSettings dialogOpen={open} />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...

View file

@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import {
ChevronRight,
Copy,
ExternalLink,
File as FileIcon,
FilePlus,
Folder as FolderIcon,
@ -53,12 +54,18 @@ type WorkspaceActions = {
remove: (path: string) => Promise<void>
copyPath: (path: string) => void
revealInFileManager: (path: string, isDir: boolean) => void
createNote: (parentPath?: string) => void
createFolder: (parentPath?: string) => Promise<string>
onOpenInNewTab?: (path: string) => void
}
type WorkspaceViewProps = {
tree: TreeNode[]
initialPath?: string | null
actions: WorkspaceActions
// Folder currently being browsed. Controlled by the app so drill-down
// participates in the global back/forward history.
onNavigate: (path: string) => void
onOpenNote: (path: string) => void
onCreateWorkspace: (name: string) => Promise<void>
}
@ -71,6 +78,12 @@ function getFileManagerName(): string {
return 'File Manager'
}
function fileExtensionLabel(name: string): string {
const dot = name.lastIndexOf('.')
if (dot <= 0 || dot === name.length - 1) return 'File'
return `${name.slice(dot + 1).toUpperCase()} file`
}
function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null {
if (!nodes) return null
for (const node of nodes) {
@ -113,8 +126,8 @@ function readFileAsBase64(file: File): Promise<string> {
})
}
export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
const [currentPath, setCurrentPath] = useState<string>(initialPath || WORKSPACE_ROOT)
export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
const currentPath = initialPath || WORKSPACE_ROOT
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
@ -127,10 +140,6 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
const filesInputRef = useRef<HTMLInputElement | null>(null)
const folderInputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (initialPath) setCurrentPath(initialPath)
}, [initialPath])
const isRoot = currentPath === WORKSPACE_ROOT
const fileManagerName = getFileManagerName()
@ -160,12 +169,12 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
(item: TreeNode) => {
if (renameTarget) return
if (item.kind === 'dir') {
setCurrentPath(item.path)
onNavigate(item.path)
} else {
onOpenNote(item.path)
}
},
[onOpenNote, renameTarget],
[onNavigate, onOpenNote, renameTarget],
)
const beginRename = useCallback((item: TreeNode) => {
@ -295,7 +304,7 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
<div className="flex min-w-0 items-center gap-1 text-sm">
<button
type="button"
onClick={() => setCurrentPath(WORKSPACE_ROOT)}
onClick={() => onNavigate(WORKSPACE_ROOT)}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors',
isRoot ? 'text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
@ -316,7 +325,7 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
) : (
<button
type="button"
onClick={() => setCurrentPath(crumb.path)}
onClick={() => onNavigate(crumb.path)}
className="rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
>
{crumb.name}
@ -326,31 +335,42 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
)
})}
</div>
{isRoot ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus className="size-4" />
Add workspace
<div className="grid shrink-0 grid-cols-2 items-center gap-2">
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => actions.revealInFileManager(currentPath, true)}
>
<FolderOpen className="size-4" />
Open in {fileManagerName}
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm">
<Plus className="size-4" />
Add
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => filesInputRef.current?.click()}>
<FilePlus className="mr-2 size-4" />
Add files
</DropdownMenuItem>
<DropdownMenuItem onClick={() => folderInputRef.current?.click()}>
<FolderPlus className="mr-2 size-4" />
Add folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{isRoot ? (
<Button size="sm" className="w-full" onClick={() => setAddOpen(true)}>
<Plus className="size-4" />
Add workspace
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="w-full">
<Plus className="size-4" />
Add
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => filesInputRef.current?.click()}>
<FilePlus className="mr-2 size-4" />
Add files
</DropdownMenuItem>
<DropdownMenuItem onClick={() => folderInputRef.current?.click()}>
<FolderPlus className="mr-2 size-4" />
Add folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<input
ref={filesInputRef}
@ -429,31 +449,56 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
) : (
<div className="truncate text-sm font-medium">{item.name}</div>
)}
{item.kind === 'dir' && !isRenaming && (
<div className="text-xs text-muted-foreground">
{childCount} {childCount === 1 ? 'item' : 'items'}
{!isRenaming && (
<div className="truncate text-xs text-muted-foreground">
{item.kind === 'dir'
? `${childCount} ${childCount === 1 ? 'item' : 'items'}`
: fileExtensionLabel(item.name)}
</div>
)}
</div>
</button>
)
const isDir = item.kind === 'dir'
return (
<ContextMenu key={item.path}>
<ContextMenuTrigger asChild>{card}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onClick={() => beginRename(item)}>
<Pencil className="mr-2 size-4" />
Rename
</ContextMenuItem>
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
{isDir && (
<>
<ContextMenuItem onClick={() => actions.createNote(item.path)}>
<FilePlus className="mr-2 size-4" />
New Note
</ContextMenuItem>
<ContextMenuItem onClick={() => void actions.createFolder(item.path)}>
<FolderPlus className="mr-2 size-4" />
New Folder
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{!isDir && actions.onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(item.path)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem onClick={() => { actions.copyPath(item.path); toast('Path copied', 'success') }}>
<Copy className="mr-2 size-4" />
Copy Path
</ContextMenuItem>
<ContextMenuItem onClick={() => actions.revealInFileManager(item.path, item.kind === 'dir')}>
<ContextMenuItem onClick={() => actions.revealInFileManager(item.path, isDir)}>
<FolderOpen className="mr-2 size-4" />
Show in {fileManagerName}
Open in {fileManagerName}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => beginRename(item)}>
<Pencil className="mr-2 size-4" />
Rename
</ContextMenuItem>
<ContextMenuItem variant="destructive" onClick={() => void handleDelete(item)}>
<Trash2 className="mr-2 size-4" />
Delete

View file

@ -1,5 +1,6 @@
import { useEffect } from 'react'
import posthog from 'posthog-js'
import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics'
/**
* Identifies the user in PostHog when signed into Rowboat,
@ -17,7 +18,7 @@ export function useAnalyticsIdentity() {
// Identify if Rowboat account is connected
const rowboat = config.rowboat
if (rowboat?.connected && rowboat?.userId) {
posthog.identify(rowboat.userId)
identifyUser(rowboat.userId)
}
// Set provider connection flags
@ -69,7 +70,7 @@ export function useAnalyticsIdentity() {
// Rowboat sign-in
if (event.success) {
if (event.userId) {
posthog.identify(event.userId)
identifyUser(event.userId)
}
posthog.people.set({ signed_in: true, rowboat_connected: true })
posthog.capture('user_signed_in')
@ -80,7 +81,7 @@ export function useAnalyticsIdentity() {
// future events on this device don't get attributed to the prior user.
posthog.people.set({ signed_in: false, rowboat_connected: false })
posthog.capture('user_signed_out')
posthog.reset()
resetAnalyticsIdentity()
})
return cleanup

View file

@ -1,5 +1,42 @@
import posthog from 'posthog-js'
let appVersion: string | undefined
let apiUrl: string | undefined
function appVersionProperties(): Record<string, string> {
return appVersion ? { app_version: appVersion } : {}
}
export function configureAnalyticsContext(props: { appVersion?: string; apiUrl?: string }) {
appVersion = props.appVersion?.trim() || undefined
apiUrl = props.apiUrl?.trim() || undefined
const eventProperties = appVersionProperties()
if (Object.keys(eventProperties).length > 0) {
posthog.register(eventProperties)
}
const personProperties = {
...(apiUrl ? { api_url: apiUrl } : {}),
...eventProperties,
}
if (Object.keys(personProperties).length > 0) {
posthog.people.set(personProperties)
}
}
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
posthog.identify(userId, {
...properties,
...appVersionProperties(),
})
}
export function resetAnalyticsIdentity() {
posthog.reset()
configureAnalyticsContext({ appVersion, apiUrl })
}
export function chatSessionCreated(runId: string) {
posthog.capture('chat_session_created', { run_id: runId })
}

View file

@ -517,9 +517,41 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
* For builtin tools, returns a static friendly name (e.g., "Reading file").
* Falls back to the raw tool name if no mapping exists.
*/
// Phrases shown while a code-mode task is running. They advance over time (5s
// each) to read as progress, then hold on the last one until the task finishes.
const CODE_MODE_RUNNING_LABELS = [
'Working on the task…',
'Inspecting the project…',
'Digging into the code…',
'Figuring it out…',
'Making the changes…',
'Wiring things up…',
'Putting it together…',
]
const CODE_MODE_LABEL_INTERVAL_MS = 5000
// Detect acpx coding-agent invocations (code mode) and produce a status-aware
// label, e.g. "Working on the task…" → "Completed the task".
export const getCodeModeCommandLabel = (tool: ToolCall): string | null => {
if (tool.name !== 'executeCommand') return null
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const command = typeof input?.command === 'string' ? input.command : ''
const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/)
if (!match) return null
if (tool.status === 'error') return `Couldn't complete the task`
if (tool.status === 'completed') return `Completed the task`
// Advance through the phrases from the tool's start, holding on the last.
const elapsed = Math.max(0, Date.now() - tool.timestamp)
const step = Math.floor(elapsed / CODE_MODE_LABEL_INTERVAL_MS)
const idx = Math.min(step, CODE_MODE_RUNNING_LABELS.length - 1)
return CODE_MODE_RUNNING_LABELS[idx]
}
export const getToolDisplayName = (tool: ToolCall): string => {
const browserLabel = getBrowserControlLabel(tool)
if (browserLabel) return browserLabel
const codeModeLabel = getCodeModeCommandLabel(tool)
if (codeModeLabel) return codeModeLabel
const composioData = getComposioActionCardData(tool)
if (composioData) return composioData.label
return TOOL_DISPLAY_NAMES[tool.name] || tool.name

View file

@ -6,7 +6,7 @@
* also uses it to decide what to keep mounted.
*/
export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf'
export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf' | 'docx'
const VIEWER_BY_EXT: Record<string, ViewerType> = {
html: 'html',
@ -31,6 +31,7 @@ const VIEWER_BY_EXT: Record<string, ViewerType> = {
flac: 'audio',
aac: 'audio',
pdf: 'pdf',
docx: 'docx',
}
function extensionOf(path: string): string {

View file

@ -2,9 +2,10 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import posthog from 'posthog-js'
import { PostHogProvider } from 'posthog-js/react'
import type { CaptureResult } from 'posthog-js'
import { ThemeProvider } from '@/contexts/theme-context'
import { configureAnalyticsContext } from './lib/analytics'
// Fetch the stable installation ID from main so renderer + main share one
// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID
@ -12,19 +13,36 @@ import { ThemeProvider } from '@/contexts/theme-context'
async function bootstrap() {
let installationId: string | undefined
let apiUrl: string | undefined
let appVersion: string | undefined
try {
const result = await window.ipc.invoke('analytics:bootstrap', null)
installationId = result.installationId
apiUrl = result.apiUrl
appVersion = result.appVersion
} catch (err) {
console.error('[Analytics] Failed to bootstrap from main:', err)
}
configureAnalyticsContext({ apiUrl, appVersion })
const options = {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2025-11-30',
defaults: '2025-11-30' as const,
...(installationId ? { bootstrap: { distinctID: installationId } } : {}),
} as const
before_send: (event: CaptureResult | null) => {
if (!event) return event
if (appVersion) {
event.properties = {
...event.properties,
app_version: appVersion,
}
}
return event
},
loaded: () => {
configureAnalyticsContext({ apiUrl, appVersion })
},
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
@ -36,11 +54,7 @@ async function bootstrap() {
</StrictMode>,
)
// Tag the active person record with api_url so anonymous users are also
// segmentable by environment.
if (apiUrl) {
posthog.people.set({ api_url: apiUrl })
}
// The loaded callback applies api_url/app_version once PostHog has initialized.
}
bootstrap()