mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-09 20:52:20 +02:00
Merge remote-tracking branch 'origin/dev' into drive
This commit is contained in:
commit
8e6978b6c1
40 changed files with 2181 additions and 375 deletions
|
|
@ -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 ?? ''),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
196
apps/x/apps/renderer/src/components/docx-file-viewer.tsx
Normal file
196
apps/x/apps/renderer/src/components/docx-file-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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 & 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...
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue