From 390ade14a055576664b65e710d0ebdfbab0be625 Mon Sep 17 00:00:00 2001 From: justelson Date: Wed, 25 Mar 2026 09:05:09 +0300 Subject: [PATCH 1/5] docs(ui-standards): update UI border and divider standards - Remove white-border chrome as the default treatment for current UI work - Replace `border-sparkle-border` with transparent or near-invisible borders for controls that do not need structural separation - Introduce subtle fills and opacity changes for idle states - Use motion, tint, and background changes for hover/active emphasis - Update canonical values for visible borders - Introduce preferred values for non-structural controls - Update guidelines for subtle separators, dividers, and timeline guides --- .codex/skills/devscope-ui-standards/SKILL.md | 5 +++- docs/current/BRANDING_ASSETS.md | 2 ++ docs/current/CURRENT_CAPABILITIES_MATRIX.md | 9 ++++-- docs/current/CURRENT_CODEBASE_ARCHITECTURE.md | 2 +- .../UI_BORDER_AND_DIVIDER_STANDARDS.md | 30 ++++++++++++++----- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.codex/skills/devscope-ui-standards/SKILL.md b/.codex/skills/devscope-ui-standards/SKILL.md index 99686ea..e2abd63 100644 --- a/.codex/skills/devscope-ui-standards/SKILL.md +++ b/.codex/skills/devscope-ui-standards/SKILL.md @@ -12,7 +12,10 @@ Read these files before making UI styling changes in the covered surfaces: ## Workflow -- Use the current white-border pattern, not `border-sparkle-border`, for the supported UI surfaces. +- Do not introduce white-border chrome by default on supported UI surfaces. +- Do not use `border-sparkle-border` as the fallback border treatment either. +- Prefer the existing surface language first: transparent borders, subtle fills, and state changes through background, opacity, and motion. +- Only use visible white borders when the surrounding surface already depends on that treatment for structure or separation. - Check `src/renderer/src/pages/project-details/ProjectDetailsHeaderSection.tsx` before choosing border values. - Keep default, hover, active, collapsed, and expanded states visually consistent. - Match the existing subtle divider behavior before introducing a new one. diff --git a/docs/current/BRANDING_ASSETS.md b/docs/current/BRANDING_ASSETS.md index e3d4438..e0603ba 100644 --- a/docs/current/BRANDING_ASSETS.md +++ b/docs/current/BRANDING_ASSETS.md @@ -39,6 +39,7 @@ The generator refreshes: ## Runtime Rule - Dev runs should prefer the blueprint artwork for the literal app icon path and other dev-only shell-facing icon surfaces. +- Dev runs now use the separate runtime identity `DevScope Air-dev`, a dev-only AppUserModelID, and an isolated local profile path so they can run beside the installed app without sharing the same state bucket. - Packaged Windows builds should use the cleaner release icon set for taskbar, shortcuts, installer, and shell surfaces. - In-app DevScope ASCII logo components remain the primary UI branding unless a screen explicitly needs image artwork. @@ -47,6 +48,7 @@ The generator refreshes: Before tagging a release, verify: - dev run shows the blueprint artwork on dev-only branding surfaces +- dev run resolves as `DevScope Air-dev` instead of reusing the installed app identity - packaged build still uses the clean icon in the window/taskbar - installer icon and shortcut icon resolve from `resources/icon.ico` - landing logo still matches the packaged release mark diff --git a/docs/current/CURRENT_CAPABILITIES_MATRIX.md b/docs/current/CURRENT_CAPABILITIES_MATRIX.md index f161798..e7705b8 100644 --- a/docs/current/CURRENT_CAPABILITIES_MATRIX.md +++ b/docs/current/CURRENT_CAPABILITIES_MATRIX.md @@ -45,15 +45,20 @@ Last validated against code on March 20, 2026. - Connect/disconnect and model listing: `Implemented` - Prompt send and interrupt: `Implemented` (empty composer text falls back to a default send prompt) - Approval response and user-input response handling: `Implemented` -- Active-plan progress panel and proposed-plan sidebar toggle in the assistant header: `Implemented` +- Active-plan progress panel, proposed-plan sidebar toggle, and inline proposed-plan history blocks with collapsed preview, show-more/show-less controls, sidebar-open action, and explicit implement action: `Implemented` - Assistant header project Git change summary with total uncommitted +/- stats: `Implemented` +- Assistant composer branch switcher with upward dropdown, branch search, current/default markers, and in-place checkout: `Implemented` - Pending AI follow-up question panel with inline option response flow: `Implemented` +- Resolved guided-input responses persist as a tool-style `Consulted user` history row with expandable question/answer detail: `Implemented` - Session project-path association and new thread flow: `Implemented` - Event subscription and snapshot/status reads: `Implemented` - Session switching with cached selected-thread hydration: `Implemented` - Assistant persistence auto-recovers corrupt SQLite state by backing it up, rebuilding, and maintaining a JSON fallback snapshot for recovery: `Implemented` - Assistant markdown file links and edited-file entries opening in-app preview: `Implemented` -- App-level assistant defaults for model, chat/plan mode, supervised/full-access mode, reasoning level, and fast mode: `Implemented` +- Assistant text inputs expose native right-click spelling suggestions and edit actions: `Implemented` +- Assistant composer exposes optional voice input with mic start/stop control: browser speech streams live on supported runtimes, local Vosk MVP records locally with rolling draft updates plus a final pass on stop, and browser-speech network failures can route directly into highlighted transcription settings: `Implemented` +- Assistant defaults/settings page exposes transcription enablement, browser-vs-local engine selection, local Vosk model download/install prep, and highlight-targeted deep linking from assistant error recovery flows: `Implemented` +- App-level assistant defaults for starter prompt template, model, chat/plan mode, supervised/full-access mode, reasoning level, and fast mode: `Implemented` - Assistant account overview surface with auth mode, plan, and rate-limit reads: `Implemented` ## Settings and Navigation diff --git a/docs/current/CURRENT_CODEBASE_ARCHITECTURE.md b/docs/current/CURRENT_CODEBASE_ARCHITECTURE.md index 14a9c17..8564b0a 100644 --- a/docs/current/CURRENT_CODEBASE_ARCHITECTURE.md +++ b/docs/current/CURRENT_CODEBASE_ARCHITECTURE.md @@ -72,7 +72,7 @@ The intended architecture direction remains contract-first: define shared contra - Renderer route state persists key navigation state in local storage and gates optional tabs through settings. - File preview and project details flows use narrower read operations to avoid unnecessary full reloads. - Update state is tracked in a dedicated main-process update subsystem instead of being ad hoc renderer state. -- Assistant streaming batches text deltas before projection/broadcast, coalesces renderer event application to animation frames, batches main-to-renderer assistant event IPC, keeps hot persistence writes off the immediate UI interaction path, avoids deep-cloning hydrated thread history on every renderer store update, and relies on incremental off-screen row rendering plus exact history paging to keep long conversations responsive. +- Assistant streaming batches text deltas before projection/broadcast, coalesces renderer event application behind a short delta-flush window plus animation-frame delivery for non-delta events, batches main-to-renderer assistant event IPC, keeps hot persistence writes off the immediate UI interaction path, avoids deep-cloning hydrated thread history on every renderer store update, and relies on exact history paging plus row virtualization with an always-live tail to keep long conversations responsive. ## Current Boundary Rules diff --git a/docs/current/UI_BORDER_AND_DIVIDER_STANDARDS.md b/docs/current/UI_BORDER_AND_DIVIDER_STANDARDS.md index 4acde95..9e91900 100644 --- a/docs/current/UI_BORDER_AND_DIVIDER_STANDARDS.md +++ b/docs/current/UI_BORDER_AND_DIVIDER_STANDARDS.md @@ -1,6 +1,6 @@ # UI Border And Divider Standards -Last updated: March 19, 2026 +Last updated: March 24, 2026 This document defines the default border and subtle-separator treatment for the current DevScope UI. @@ -11,9 +11,17 @@ Use it together with: ## Primary Rule -Use white-border patterns for application UI borders. +Do not use white-border chrome as the default treatment for current UI work. -Do not use `border-sparkle-border` as the default border treatment for current UI work. +Do not use `border-sparkle-border` as the default border treatment either. + +Prefer: + +- transparent or near-invisible borders for controls that do not need structural separation +- subtle fills and opacity changes for idle states +- motion, tint, and background changes for hover/active emphasis + +Use visible white borders only when the surrounding surface already relies on them for containment, grouping, or separation. ## Reference Pattern @@ -21,7 +29,7 @@ Reference component: - `src/renderer/src/pages/project-details/ProjectDetailsHeaderSection.tsx` -Canonical values: +Canonical values for cases where a visible border is actually needed: - default border: `border-white/10` - hover border: `hover:border-white/20` @@ -30,6 +38,13 @@ Canonical values: - bordered surface background: `bg-sparkle-card` or `bg-white/[0.03]` - hover surface background: `hover:bg-white/[0.03]` or `hover:bg-white/10` +Preferred values for non-structural controls: + +- idle border: `border-transparent` +- idle surface: `bg-white/[0.02]` to `bg-white/[0.03]` +- idle text: muted or reduced-opacity foreground +- hover emphasis: background/tint change first, border second + ## Where This Applies - assistant sidebar @@ -55,10 +70,11 @@ For subtle separators, dividers, and timeline guides: When touching these surfaces: 1. check the reference component first -2. keep border tokens consistent across default, hover, active, collapsed, and expanded states +2. do not add a visible white border unless the control actually needs structural definition 3. search for stray `border-sparkle-border` usage in the edited surface -4. verify compact and non-compact modes -5. verify hover and active states +4. keep state styling consistent across default, hover, active, collapsed, and expanded states +5. verify compact and non-compact modes +6. verify hover and active states ## When To Update This Doc From 74c036eb0e4d622478b039fa4e4c296858b64e8e Mon Sep 17 00:00:00 2001 From: justelson Date: Thu, 26 Mar 2026 22:31:39 +0300 Subject: [PATCH 2/5] feat(shell): add explorer launch and frameless quick preview --- build/installer.nsh | 24 ++ package.json | 1 + src/main/file-protocol.ts | 74 +++++ src/main/index.ts | 303 +++++++++++------- src/main/ipc/handlers.ts | 47 ++- src/renderer/src/App.tsx | 37 ++- .../src/components/ui/FilePreviewModal.tsx | 8 +- .../PreviewHeaderStatusActions.tsx | 18 +- .../ui/file-preview/PreviewModalHeader.tsx | 3 + .../ui/file-preview/PreviewModalLayout.tsx | 35 +- .../src/components/ui/file-preview/types.ts | 2 + .../ui/file-preview/useFilePreview.ts | 3 +- .../ui/file-preview/useFilePreviewChrome.ts | 10 +- .../components/ui/markdown/linkNavigation.ts | 75 ++++- src/renderer/src/pages/QuickOpen.tsx | 39 ++- .../src/pages/QuickPreviewTitleBar.tsx | 106 ++++++ 16 files changed, 611 insertions(+), 174 deletions(-) create mode 100644 build/installer.nsh create mode 100644 src/main/file-protocol.ts create mode 100644 src/renderer/src/pages/QuickPreviewTitleBar.tsx diff --git a/build/installer.nsh b/build/installer.nsh new file mode 100644 index 0000000..1ba7008 --- /dev/null +++ b/build/installer.nsh @@ -0,0 +1,24 @@ +!macro customInstall + WriteRegStr SHELL_CONTEXT "Software\Classes\*\shell\DevScopeAir" "" "Open with DevScope Air" + WriteRegStr SHELL_CONTEXT "Software\Classes\*\shell\DevScopeAir" "Icon" "$appExe,0" + WriteRegStr SHELL_CONTEXT "Software\Classes\*\shell\DevScopeAir" "Position" "Top" + WriteRegStr SHELL_CONTEXT "Software\Classes\*\shell\DevScopeAir\command" "" '"$appExe" "%1"' + + WriteRegStr SHELL_CONTEXT "Software\Classes\Directory\shell\DevScopeAir" "" "Open with DevScope Air" + WriteRegStr SHELL_CONTEXT "Software\Classes\Directory\shell\DevScopeAir" "Icon" "$appExe,0" + WriteRegStr SHELL_CONTEXT "Software\Classes\Directory\shell\DevScopeAir" "Position" "Top" + WriteRegStr SHELL_CONTEXT "Software\Classes\Directory\shell\DevScopeAir\command" "" '"$appExe" "%1"' + + WriteRegStr SHELL_CONTEXT "Software\Classes\Directory\Background\shell\DevScopeAir" "" "Open DevScope Air Here" + WriteRegStr SHELL_CONTEXT "Software\Classes\Directory\Background\shell\DevScopeAir" "Icon" "$appExe,0" + WriteRegStr SHELL_CONTEXT "Software\Classes\Directory\Background\shell\DevScopeAir" "Position" "Top" + WriteRegStr SHELL_CONTEXT "Software\Classes\Directory\Background\shell\DevScopeAir\command" "" '"$appExe" "%V"' +!macroend + +!macro customUnInstall + ${ifNot} ${isUpdated} + DeleteRegKey SHELL_CONTEXT "Software\Classes\*\shell\DevScopeAir" + DeleteRegKey SHELL_CONTEXT "Software\Classes\Directory\shell\DevScopeAir" + DeleteRegKey SHELL_CONTEXT "Software\Classes\Directory\Background\shell\DevScopeAir" + ${endIf} +!macroend diff --git a/package.json b/package.json index 5cbee56..fbb82fe 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true, + "include": "build/installer.nsh", "installerIcon": "resources/icon.ico", "uninstallerIcon": "resources/icon.ico" } diff --git a/src/main/file-protocol.ts b/src/main/file-protocol.ts new file mode 100644 index 0000000..bfafbc0 --- /dev/null +++ b/src/main/file-protocol.ts @@ -0,0 +1,74 @@ +import { protocol } from 'electron' +import log from 'electron-log' + +const MIME_TYPES: Record = { + 'html': 'text/html', + 'htm': 'text/html', + 'css': 'text/css', + 'js': 'application/javascript', + 'json': 'application/json', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'svg': 'image/svg+xml', + 'mp4': 'video/mp4', + 'webm': 'video/webm' +} + +function resolveProtocolFilePath(requestUrl: string) { + const url = new URL(requestUrl) + let filePath = decodeURIComponent(url.pathname) + + if (url.hostname && url.hostname.length === 1 && /^[a-zA-Z]$/.test(url.hostname)) { + return `${url.hostname}:${filePath}` + } + if (url.hostname) { + return `//${url.hostname}${filePath}` + } + if (process.platform === 'win32' && filePath.startsWith('/')) { + return filePath.slice(1) + } + return filePath +} + +function resolveMimeType(filePath: string) { + const extension = filePath.split('.').pop()?.toLowerCase() || '' + return MIME_TYPES[extension] || 'application/octet-stream' +} + +export function registerFileProtocol(fileProtocol: string) { + protocol.registerBufferProtocol(fileProtocol, (request, callback) => { + let filePath = '' + + try { + filePath = resolveProtocolFilePath(request.url) + } catch (error) { + log.error('Failed to resolve protocol URL:', request.url, error) + callback({ statusCode: 500, data: Buffer.from('') }) + return + } + + import('fs').then(({ readFile }) => { + readFile(filePath, (error, data) => { + if (error) { + log.error('Failed to read file:', filePath, error) + callback({ statusCode: 404, data: Buffer.from('') }) + return + } + + callback({ + statusCode: 200, + data, + mimeType: resolveMimeType(filePath), + headers: { + 'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;" + } + }) + }) + }).catch((error) => { + log.error('Failed to import fs:', error) + callback({ statusCode: 500, data: Buffer.from('') }) + }) + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index 5e4732e..4573785 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,8 +3,8 @@ * Main Process Entry Point */ -import { app, BrowserWindow, shell, ipcMain, protocol } from 'electron' -import { basename, extname, join } from 'path' +import { app, BrowserWindow, Menu, shell, ipcMain, protocol, type IpcMainEvent, type IpcMainInvokeEvent } from 'electron' +import { join } from 'path' import { existsSync, statSync } from 'fs' import { electronApp, is } from './utils' import log from 'electron-log' @@ -12,6 +12,51 @@ import { registerIpcHandlers } from './ipc' import { disposeAssistantService } from './assistant' import { disposeSystemMetricsBridge } from './system-metrics/manager' import { disposeUpdater, initializeUpdater, registerUpdateWindow } from './update/manager' +import { registerFileProtocol } from './file-protocol' + +const APP_NAME = 'DevScope Air' +const DEV_APP_NAME = `${APP_NAME}-dev` +const APP_USER_MODEL_ID = 'com.devscope.air.win' +const DEV_APP_USER_MODEL_ID = `${APP_USER_MODEL_ID}.dev` + +type RuntimeIdentity = { + appName: string + appUserModelId: string + userDataDirectoryName: string + isDevRuntime: boolean +} + +function resolveRuntimeIdentity(): RuntimeIdentity { + if (is.dev) { + return { + appName: DEV_APP_NAME, + appUserModelId: DEV_APP_USER_MODEL_ID, + userDataDirectoryName: DEV_APP_NAME, + isDevRuntime: true + } + } + + return { + appName: APP_NAME, + appUserModelId: APP_USER_MODEL_ID, + userDataDirectoryName: APP_NAME, + isDevRuntime: false + } +} + +const runtimeIdentity = resolveRuntimeIdentity() + +function applyRuntimeIdentity(identity: RuntimeIdentity): void { + app.setName(identity.appName) + + if (!identity.isDevRuntime) return + + const userDataPath = join(app.getPath('appData'), identity.userDataDirectoryName) + app.setPath('userData', userDataPath) + app.setPath('sessionData', join(userDataPath, 'session')) +} + +applyRuntimeIdentity(runtimeIdentity) // Configure logging const verboseMainLogs = process.env.DEVSCOPE_VERBOSE_LOGS === '1' @@ -26,28 +71,12 @@ let quickPreviewWindow: BrowserWindow | null = null let hasRegisteredIpcHandlers = false const FILE_PROTOCOL = 'devscope' const QUICK_PREVIEW_ROUTE = '/quick-open' -const ASSOCIATED_CODE_EXTENSIONS = new Set([ - '.md', '.markdown', '.mdx', '.txt', '.log', - '.js', '.jsx', '.mjs', '.cjs', - '.ts', '.tsx', - '.json', '.jsonc', '.json5', - '.css', '.scss', '.less', - '.html', '.htm', '.xml', - '.yml', '.yaml', '.toml', '.ini', '.conf', - '.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd', - '.py', '.rb', '.php', '.java', '.kt', '.kts', - '.c', '.h', '.cpp', '.cxx', '.hpp', - '.cs', '.go', '.rs', '.swift', '.dart', '.scala', '.sql', - '.vue', '.svelte', '.gradle' -]) -const ASSOCIATED_DOTFILES = new Set([ - '.gitignore', - '.gitattributes', - '.editorconfig', - '.npmrc', - '.eslintrc', - '.prettierrc' -]) +const EXTERNAL_EXPLORER_LAUNCH_QUERY = 'shellLaunch=1' + +type ShellLaunchTarget = { + kind: 'file' | 'directory' + path: string +} protocol.registerSchemesAsPrivileged([ { @@ -98,29 +127,75 @@ function lockWindowZoom(window: BrowserWindow): void { void webContents.setVisualZoomLevelLimits(1, 1).catch(() => {}) } -function shouldTreatAsAssociatedFile(arg: string): boolean { +function registerEditableContextMenu(window: BrowserWindow): void { + window.webContents.on('context-menu', (_event, params) => { + if (!params.isEditable) return + + const template: Electron.MenuItemConstructorOptions[] = [] + + if (params.misspelledWord) { + if (params.dictionarySuggestions.length > 0) { + template.push( + ...params.dictionarySuggestions.slice(0, 6).map((suggestion) => ({ + label: suggestion, + click: () => window.webContents.replaceMisspelling(suggestion) + })) + ) + } else { + template.push({ + label: 'No spelling suggestions', + enabled: false + }) + } + + template.push({ + label: 'Add to Dictionary', + click: () => window.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord) + }) + template.push({ type: 'separator' }) + } + + template.push( + { role: 'undo', enabled: params.editFlags.canUndo }, + { role: 'redo', enabled: params.editFlags.canRedo }, + { type: 'separator' }, + { role: 'cut', enabled: params.editFlags.canCut }, + { role: 'copy', enabled: params.editFlags.canCopy }, + { role: 'paste', enabled: params.editFlags.canPaste }, + { role: 'selectAll', enabled: params.editFlags.canSelectAll } + ) + + Menu.buildFromTemplate(template).popup({ window }) + }) +} + +function resolveShellLaunchTarget(arg: string): ShellLaunchTarget | null { const trimmed = String(arg || '').trim() - if (!trimmed || trimmed.startsWith('-')) return false - if (!existsSync(trimmed)) return false + if (!trimmed || trimmed.startsWith('-')) return null + if (!existsSync(trimmed)) return null try { const stat = statSync(trimmed) - if (!stat.isFile()) return false + if (stat.isDirectory()) { + return { kind: 'directory', path: trimmed } + } + if (stat.isFile()) { + return { kind: 'file', path: trimmed } + } } catch { - return false + return null } - const fileName = basename(trimmed).toLowerCase() - const extension = extname(trimmed).toLowerCase() - return ASSOCIATED_CODE_EXTENSIONS.has(extension) || ASSOCIATED_DOTFILES.has(fileName) + return null } -function extractAssociatedFileFromArgv(argv: string[]): string | null { +function extractShellLaunchTargetFromArgv(argv: string[]): ShellLaunchTarget | null { const startIndex = app.isPackaged ? 1 : 2 for (let i = startIndex; i < argv.length; i += 1) { const candidate = String(argv[i] || '').trim() - if (shouldTreatAsAssociatedFile(candidate)) { - return candidate + const shellLaunchTarget = resolveShellLaunchTarget(candidate) + if (shellLaunchTarget) { + return shellLaunchTarget } } return null @@ -142,7 +217,11 @@ function loadRendererRoute(window: BrowserWindow, routeWithSearch: string): void void window.loadFile(join(__dirname, '../renderer/index.html'), { hash: routeWithSearch }) } -function createWindow(showOnReady = true): BrowserWindow { +function buildExternalExplorerRoute(folderPath: string): string { + return `/explorer/${encodeURIComponent(folderPath)}?${EXTERNAL_EXPLORER_LAUNCH_QUERY}` +} + +function createWindow(showOnReady = true, initialRoute = '/'): BrowserWindow { const iconPath = getAppIconPath() const window = new BrowserWindow({ width: 1200, @@ -189,8 +268,9 @@ function createWindow(showOnReady = true): BrowserWindow { } }) + registerEditableContextMenu(window) lockWindowZoom(window) - loadRendererRoute(window, '/') + loadRendererRoute(window, initialRoute) registerUpdateWindow(window) return window @@ -214,6 +294,7 @@ function createQuickPreviewWindow(filePath: string): BrowserWindow { minWidth: 760, minHeight: 520, show: false, + frame: false, backgroundColor: '#0c121f', autoHideMenuBar: true, ...(iconPath ? { icon: iconPath } : {}), @@ -242,13 +323,47 @@ function createQuickPreviewWindow(filePath: string): BrowserWindow { return { action: 'deny' } }) + registerEditableContextMenu(window) lockWindowZoom(window) loadRendererRoute(window, route) quickPreviewWindow = window return window } -const initialAssociatedFilePath = extractAssociatedFileFromArgv(process.argv) +function openFolderInMainWindow(folderPath: string): BrowserWindow { + const route = buildExternalExplorerRoute(folderPath) + + if (!mainWindow || mainWindow.isDestroyed()) { + mainWindow = createWindow(true, route) + ensureIpcHandlersRegistered(mainWindow) + return mainWindow + } + + loadRendererRoute(mainWindow, route) + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.show() + mainWindow.focus() + return mainWindow +} + +function handleShellLaunchTarget(shellLaunchTarget: ShellLaunchTarget): void { + if (shellLaunchTarget.kind === 'directory') { + openFolderInMainWindow(shellLaunchTarget.path) + return + } + + if (!mainWindow || mainWindow.isDestroyed()) { + mainWindow = createWindow(false) + ensureIpcHandlersRegistered(mainWindow) + } + createQuickPreviewWindow(shellLaunchTarget.path) +} + +function resolveSenderWindow(event: IpcMainEvent | IpcMainInvokeEvent): BrowserWindow | null { + return BrowserWindow.fromWebContents(event.sender) +} + +const initialShellLaunchTarget = extractShellLaunchTargetFromArgv(process.argv) const hasSingleInstanceLock = app.requestSingleInstanceLock() if (!hasSingleInstanceLock) { @@ -256,9 +371,9 @@ if (!hasSingleInstanceLock) { } app.on('second-instance', (_event, argv) => { - const associatedFilePath = extractAssociatedFileFromArgv(argv) - if (associatedFilePath) { - createQuickPreviewWindow(associatedFilePath) + const shellLaunchTarget = extractShellLaunchTargetFromArgv(argv) + if (shellLaunchTarget) { + handleShellLaunchTarget(shellLaunchTarget) return } @@ -274,80 +389,19 @@ app.on('second-instance', (_event, argv) => { }) app.whenReady().then(() => { - app.setName('DevScope Air') - electronApp.setAppUserModelId('com.devscope.air.win') + electronApp.setAppUserModelId(runtimeIdentity.appUserModelId) void initializeUpdater() - - protocol.registerBufferProtocol(FILE_PROTOCOL, (request, callback) => { - try { - const url = new URL(request.url) - let filePath = decodeURIComponent(url.pathname) - - // Handle case where drive letter is interpreted as hostname (e.g. devscope://c/Users/...) - if (url.hostname && url.hostname.length === 1 && /^[a-zA-Z]$/.test(url.hostname)) { - filePath = `${url.hostname}:${filePath}` - } - // Handle UNC paths - else if (url.hostname) { - filePath = `//${url.hostname}${filePath}` - } - // Handle standard Windows paths with leading slash (e.g. /C:/Users/...) - else if (process.platform === 'win32' && filePath.startsWith('/')) { - filePath = filePath.slice(1) - } - - // Read file and return as buffer with permissive CSP - import('fs').then(({ readFile }) => { - readFile(filePath, (err, data) => { - if (err) { - log.error('Failed to read file:', filePath, err) - callback({ statusCode: 404, data: Buffer.from('') }) - return - } - - // Determine MIME type - const ext = filePath.split('.').pop()?.toLowerCase() || '' - const mimeTypes: Record = { - 'html': 'text/html', - 'htm': 'text/html', - 'css': 'text/css', - 'js': 'application/javascript', - 'json': 'application/json', - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - 'svg': 'image/svg+xml', - 'mp4': 'video/mp4', - 'webm': 'video/webm' - } - const mimeType = mimeTypes[ext] || 'application/octet-stream' - - callback({ - statusCode: 200, - data, - mimeType, - headers: { - 'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;" - } - }) - }) - }).catch(err => { - log.error('Failed to import fs:', err) - callback({ statusCode: 500, data: Buffer.from('') }) - }) - } catch (err) { - log.error('Failed to resolve protocol URL:', request.url, err) - callback({ statusCode: 500, data: Buffer.from('') }) - } - }) - - // Keep the full app alive in background for quick-file preview launches. - const launchHidden = Boolean(initialAssociatedFilePath) - mainWindow = createWindow(!launchHidden) + registerFileProtocol(FILE_PROTOCOL) + + // Keep the full app alive in background for shell file-preview launches. + const launchHidden = initialShellLaunchTarget?.kind === 'file' + const initialRoute = initialShellLaunchTarget?.kind === 'directory' + ? buildExternalExplorerRoute(initialShellLaunchTarget.path) + : '/' + mainWindow = createWindow(!launchHidden, initialRoute) ensureIpcHandlersRegistered(mainWindow) - if (initialAssociatedFilePath) { - createQuickPreviewWindow(initialAssociatedFilePath) + if (initialShellLaunchTarget?.kind === 'file') { + createQuickPreviewWindow(initialShellLaunchTarget.path) } app.on('activate', function () { @@ -388,26 +442,29 @@ app.on('before-quit', () => { }) // Handle window control IPC -ipcMain.on('window:minimize', () => { +ipcMain.on('window:minimize', (event) => { log.info('Window minimize requested') - mainWindow?.minimize() + resolveSenderWindow(event)?.minimize() }) -ipcMain.on('window:maximize', () => { +ipcMain.on('window:maximize', (event) => { log.info('Window maximize requested') - if (mainWindow?.isMaximized()) { - mainWindow.unmaximize() + const targetWindow = resolveSenderWindow(event) + if (!targetWindow) return + + if (targetWindow.isMaximized()) { + targetWindow.unmaximize() } else { - mainWindow?.maximize() + targetWindow.maximize() } }) -ipcMain.on('window:close', () => { +ipcMain.on('window:close', (event) => { log.info('Window close requested') - mainWindow?.close() + resolveSenderWindow(event)?.close() }) -ipcMain.handle('window:isMaximized', () => { - return mainWindow?.isMaximized() ?? false +ipcMain.handle('window:isMaximized', (event) => { + return resolveSenderWindow(event)?.isMaximized() ?? false }) diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 4582fff..79a98aa 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -28,15 +28,22 @@ import { handleTestGroqConnection } from './handlers/settings-ai-handlers' import { + handleAssistantApprovePendingPlaygroundLabRequest, handleAssistantArchiveSession, + handleAssistantAttachSessionToPlaygroundLab, handleAssistantBootstrap, handleAssistantClearLogs, handleAssistantConnect, + handleAssistantCreatePlaygroundLab, handleAssistantCreateSession, handleAssistantDeleteMessage, handleAssistantDeleteSession, + handleAssistantDeclinePendingPlaygroundLabRequest, + handleAssistantDownloadTranscriptionModel, handleAssistantDisconnect, handleAssistantGetAccountOverview, + handleAssistantGetSessionTurnUsage, + handleAssistantGetTranscriptionModelState, handleAssistantGetSnapshot, handleAssistantGetStatus, handleAssistantInterruptTurn, @@ -45,9 +52,11 @@ import { handleAssistantPersistClipboardImage, handleAssistantRenameSession, handleAssistantRespondApproval, + handleAssistantTranscribeAudioWithLocalModel, handleAssistantRespondUserInput, handleAssistantSelectSession, handleAssistantSendPrompt, + handleAssistantSetPlaygroundRoot, handleAssistantSetSessionProjectPath, handleAssistantSubscribe, handleAssistantUnsubscribe @@ -200,6 +209,7 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { ipcMain.handle(ASSISTANT_IPC.getSnapshot, handleAssistantGetSnapshot) ipcMain.handle(ASSISTANT_IPC.getStatus, handleAssistantGetStatus) ipcMain.handle(ASSISTANT_IPC.getAccountOverview, handleAssistantGetAccountOverview) + ipcMain.handle(ASSISTANT_IPC.getSessionTurnUsage, handleAssistantGetSessionTurnUsage) ipcMain.handle(ASSISTANT_IPC.listModels, handleAssistantListModels) ipcMain.handle(ASSISTANT_IPC.connect, handleAssistantConnect) ipcMain.handle(ASSISTANT_IPC.disconnect, handleAssistantDisconnect) @@ -211,12 +221,20 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { ipcMain.handle(ASSISTANT_IPC.deleteMessage, handleAssistantDeleteMessage) ipcMain.handle(ASSISTANT_IPC.clearLogs, handleAssistantClearLogs) ipcMain.handle(ASSISTANT_IPC.setSessionProjectPath, handleAssistantSetSessionProjectPath) + ipcMain.handle(ASSISTANT_IPC.setPlaygroundRoot, handleAssistantSetPlaygroundRoot) + ipcMain.handle(ASSISTANT_IPC.createPlaygroundLab, handleAssistantCreatePlaygroundLab) + ipcMain.handle(ASSISTANT_IPC.attachSessionToPlaygroundLab, handleAssistantAttachSessionToPlaygroundLab) + ipcMain.handle(ASSISTANT_IPC.approvePendingPlaygroundLabRequest, handleAssistantApprovePendingPlaygroundLabRequest) + ipcMain.handle(ASSISTANT_IPC.declinePendingPlaygroundLabRequest, handleAssistantDeclinePendingPlaygroundLabRequest) ipcMain.handle(ASSISTANT_IPC.persistClipboardImage, handleAssistantPersistClipboardImage) ipcMain.handle(ASSISTANT_IPC.newThread, handleAssistantNewThread) ipcMain.handle(ASSISTANT_IPC.sendPrompt, handleAssistantSendPrompt) ipcMain.handle(ASSISTANT_IPC.interruptTurn, handleAssistantInterruptTurn) ipcMain.handle(ASSISTANT_IPC.respondApproval, handleAssistantRespondApproval) ipcMain.handle(ASSISTANT_IPC.respondUserInput, handleAssistantRespondUserInput) + ipcMain.handle(ASSISTANT_IPC.getTranscriptionModelState, handleAssistantGetTranscriptionModelState) + ipcMain.handle(ASSISTANT_IPC.downloadTranscriptionModel, handleAssistantDownloadTranscriptionModel) + ipcMain.handle(ASSISTANT_IPC.transcribeAudioWithLocalModel, handleAssistantTranscribeAudioWithLocalModel) ipcMain.handle('devscope:selectFolder', handleSelectFolder) ipcMain.handle('devscope:selectMarkdownFile', handleSelectMarkdownFile) @@ -315,21 +333,26 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { ipcMain.removeAllListeners('window:close') ipcMain.removeHandler('window:isMaximized') - ipcMain.on('window:minimize', () => { - if (!mainWindow.isDestroyed()) mainWindow.minimize() + ipcMain.on('window:minimize', (event) => { + const targetWindow = BrowserWindow.fromWebContents(event.sender) + if (!targetWindow || targetWindow.isDestroyed()) return + targetWindow.minimize() }) - ipcMain.on('window:maximize', () => { - if (!mainWindow.isDestroyed()) { - if (mainWindow.isMaximized()) mainWindow.unmaximize() - else mainWindow.maximize() - } + ipcMain.on('window:maximize', (event) => { + const targetWindow = BrowserWindow.fromWebContents(event.sender) + if (!targetWindow || targetWindow.isDestroyed()) return + if (targetWindow.isMaximized()) targetWindow.unmaximize() + else targetWindow.maximize() }) - ipcMain.on('window:close', () => { - if (!mainWindow.isDestroyed()) mainWindow.close() + ipcMain.on('window:close', (event) => { + const targetWindow = BrowserWindow.fromWebContents(event.sender) + if (!targetWindow || targetWindow.isDestroyed()) return + targetWindow.close() }) - ipcMain.handle('window:isMaximized', () => { - if (mainWindow.isDestroyed()) return false - return mainWindow.isMaximized() + ipcMain.handle('window:isMaximized', (event) => { + const targetWindow = BrowserWindow.fromWebContents(event.sender) + if (!targetWindow || targetWindow.isDestroyed()) return false + return targetWindow.isMaximized() }) mainWindow.webContents.once('destroyed', () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 438e36a..e0148f0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,5 +1,5 @@ // ... imports -import { useRef, lazy, Suspense, useEffect, useMemo, createContext, useContext, type ReactNode } from 'react' +import { useRef, lazy, Suspense, useEffect, useMemo, useState, createContext, useContext, type ReactNode } from 'react' import { HashRouter, Routes, Route, useLocation, Navigate, useNavigate } from 'react-router-dom' import TitleBar from './components/layout/TitleBar' import Sidebar, { SidebarProvider, useSidebar } from './components/layout/Sidebar' @@ -34,6 +34,7 @@ const TerminalSettings = lazy(() => import('./pages/settings/TerminalSettings')) const LogsSettings = lazy(() => import('./pages/settings/LogsSettings')) const LAST_MAIN_TAB_KEY = 'devscope:last-main-tab:v1' const LAST_APP_ROUTE_KEY = 'devscope:last-app-route:v1' +const EXTERNAL_EXPLORER_ACCESS_KEY = 'devscope:external-explorer-access:v1' // Terminal Context interface TerminalContextType { @@ -135,6 +136,19 @@ function readLastLaunchRoute(allowTasks: boolean, allowExplorer: boolean): strin return readLastMainTabPath(allowTasks, allowExplorer) } +function hasExternalExplorerLaunchAccess(pathname: string, search: string): boolean { + if (!isExplorerAreaPath(pathname)) return false + return new URLSearchParams(search).get('shellLaunch') === '1' +} + +function readExternalExplorerLaunchAccess(): boolean { + try { + return sessionStorage.getItem(EXTERNAL_EXPLORER_ACCESS_KEY) === '1' + } catch { + return false + } +} + function LaunchRedirect() { const { settings } = useSettings() return @@ -148,9 +162,12 @@ function MainContent() { const mainRef = useRef(null!) const location = useLocation() const navigate = useNavigate() + const [externalExplorerAccess, setExternalExplorerAccess] = useState(() => readExternalExplorerLaunchAccess()) const isSettingsRoute = location.pathname.startsWith('/settings') const { isCollapsed } = useSidebar() const { settings } = useSettings() + const hasRouteLaunchAccess = hasExternalExplorerLaunchAccess(location.pathname, location.search) + const allowExplorerRoute = settings.explorerTabEnabled || externalExplorerAccess || hasRouteLaunchAccess const { targetY, currentY, animationFrame, isAnimating } = useSmoothScroll(mainRef, { ease: 0.12, @@ -199,6 +216,16 @@ function MainContent() { } }, [location.pathname, settings.explorerTabEnabled, settings.tasksPageEnabled]) + useEffect(() => { + if (!hasExternalExplorerLaunchAccess(location.pathname, location.search)) return + setExternalExplorerAccess(true) + try { + sessionStorage.setItem(EXTERNAL_EXPLORER_ACCESS_KEY, '1') + } catch { + // Ignore storage write errors. + } + }, [location.pathname, location.search]) + useEffect(() => { if (settings.tasksPageEnabled) return if (!location.pathname.startsWith('/tasks')) return @@ -206,10 +233,10 @@ function MainContent() { }, [settings.tasksPageEnabled, location.pathname, navigate]) useEffect(() => { - if (settings.explorerTabEnabled) return + if (allowExplorerRoute) return if (!isExplorerAreaPath(location.pathname)) return navigate('/home', { replace: true }) - }, [settings.explorerTabEnabled, location.pathname, navigate]) + }, [allowExplorerRoute, location.pathname, navigate]) return (
} /> : } + element={allowExplorerRoute ? : } /> : } + element={allowExplorerRoute ? : } /> Promise mediaItems?: PreviewMediaItem[] onSaved?: (filePath: string) => Promise | void @@ -36,6 +37,7 @@ export function FilePreviewModal({ previewBytes, modifiedAt, projectPath, + shellMode = 'modal', onOpenLinkedPreview, mediaItems = [], onSaved, @@ -132,7 +134,8 @@ export function FilePreviewModal({ resetKey: file.path, defaultStartExpanded, defaultLeftPanelOpen, - defaultRightPanelOpen + defaultRightPanelOpen, + initialFocusLine: file.focusLine ?? null }) const { @@ -375,6 +378,7 @@ export function FilePreviewModal({ const modalContent = ( ) - if (typeof document === 'undefined') { + if (shellMode === 'window' || typeof document === 'undefined') { return modalContent } diff --git a/src/renderer/src/components/ui/file-preview/PreviewHeaderStatusActions.tsx b/src/renderer/src/components/ui/file-preview/PreviewHeaderStatusActions.tsx index 50e966b..c9eab58 100644 --- a/src/renderer/src/components/ui/file-preview/PreviewHeaderStatusActions.tsx +++ b/src/renderer/src/components/ui/file-preview/PreviewHeaderStatusActions.tsx @@ -5,6 +5,7 @@ import type { PreviewFile } from './types' type PreviewHeaderStatusActionsProps = { file: PreviewFile + showCloseButton?: boolean gitDiffSummary?: GitDiffSummary | null totalFileLines: number isMediaFile: boolean @@ -37,6 +38,7 @@ type PreviewHeaderStatusActionsProps = { export function PreviewHeaderStatusActions({ file, + showCloseButton = true, gitDiffSummary, totalFileLines, isMediaFile, @@ -169,13 +171,15 @@ export function PreviewHeaderStatusActions({ )} - + {showCloseButton && ( + + )} ) } diff --git a/src/renderer/src/components/ui/file-preview/PreviewModalHeader.tsx b/src/renderer/src/components/ui/file-preview/PreviewModalHeader.tsx index a23b87a..844e246 100644 --- a/src/renderer/src/components/ui/file-preview/PreviewModalHeader.tsx +++ b/src/renderer/src/components/ui/file-preview/PreviewModalHeader.tsx @@ -11,6 +11,7 @@ import { PreviewHeaderHtmlControls } from './PreviewHeaderHtmlControls' interface PreviewModalHeaderProps { file: PreviewFile + showCloseButton?: boolean gitDiffSummary?: GitDiffSummary | null totalFileLines?: number mode: 'preview' | 'edit' @@ -73,6 +74,7 @@ function formatPreviewFileName(name: string, maxLength: number): string { export default function PreviewModalHeader({ file, + showCloseButton = true, gitDiffSummary, totalFileLines = 0, mode, @@ -468,6 +470,7 @@ export default function PreviewModalHeader({ onRevert={onRevert} onSave={onSave} onClose={onClose} + showCloseButton={showCloseButton} controlGroupClass={controlGroupClass} iconButtonBaseClass={iconButtonBaseClass} /> diff --git a/src/renderer/src/components/ui/file-preview/PreviewModalLayout.tsx b/src/renderer/src/components/ui/file-preview/PreviewModalLayout.tsx index dda4584..5ce79d2 100644 --- a/src/renderer/src/components/ui/file-preview/PreviewModalLayout.tsx +++ b/src/renderer/src/components/ui/file-preview/PreviewModalLayout.tsx @@ -12,6 +12,7 @@ import { PreviewModalDialogs } from './PreviewModalDialogs' type PreviewModalLayoutProps = { file: PreviewFile + shellMode?: 'modal' | 'window' loading?: boolean truncated?: boolean size?: number @@ -111,6 +112,7 @@ type PreviewModalLayoutProps = { export function PreviewModalLayout(props: PreviewModalLayoutProps) { const { file, + shellMode = 'modal', loading, truncated, size, @@ -239,11 +241,40 @@ export function PreviewModalLayout(props: PreviewModalLayoutProps) { ) + const isWindowShell = shellMode === 'window' + const modalContent = ( -
event.stopPropagation()}> -
event.stopPropagation())} style={modalStyle}> +
event.stopPropagation()} + > +
event.stopPropagation()) : undefined} + style={isWindowShell ? undefined : modalStyle} + > ('responsive') const [isExpanded, setIsExpanded] = useState(defaultStartExpanded) @@ -28,7 +30,7 @@ export function useFilePreviewChrome({ const [editorFontSize, setEditorFontSize] = useState(13) const [findRequestToken, setFindRequestToken] = useState(0) const [replaceRequestToken, setReplaceRequestToken] = useState(0) - const [focusLine, setFocusLine] = useState(null) + const [focusLine, setFocusLine] = useState(initialFocusLine) const previewSurfaceRef = useRef(null) const panelResizeRef = useRef<{ side: 'left' | 'right'; startX: number; startWidth: number } | null>(null) @@ -47,8 +49,8 @@ export function useFilePreviewChrome({ setEditorFontSize(13) setFindRequestToken(0) setReplaceRequestToken(0) - setFocusLine(null) - }, [defaultLeftPanelOpen, defaultRightPanelOpen, defaultStartExpanded, resetKey]) + setFocusLine(initialFocusLine) + }, [defaultLeftPanelOpen, defaultRightPanelOpen, defaultStartExpanded, initialFocusLine, resetKey]) useEffect(() => { if (!isExpanded) return diff --git a/src/renderer/src/components/ui/markdown/linkNavigation.ts b/src/renderer/src/components/ui/markdown/linkNavigation.ts index 1e35e4b..389424f 100644 --- a/src/renderer/src/components/ui/markdown/linkNavigation.ts +++ b/src/renderer/src/components/ui/markdown/linkNavigation.ts @@ -3,6 +3,7 @@ import type { PreviewOpenOptions } from '../file-preview/types' type MarkdownPathTarget = { path: string anchor?: string + focusLine?: number } type MarkdownLinkNavigationOptions = { @@ -31,6 +32,10 @@ function isExternalHref(href: string): boolean { return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(href) } +function isWindowsAbsolutePath(pathValue: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') +} + function splitHrefAnchor(href: string): { pathname: string; anchor?: string } { const hashIndex = href.indexOf('#') if (hashIndex < 0) return { pathname: href } @@ -40,6 +45,46 @@ function splitHrefAnchor(href: string): { pathname: string; anchor?: string } { } } +function toPositiveInteger(value: string | undefined): number | undefined { + if (!value) return undefined + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined +} + +function extractPathLineReference(pathname: string): { pathname: string; focusLine?: number } { + const match = pathname.match(/^(.*?)(?::(\d+))(?:\:(\d+))?$/) + if (!match) return { pathname } + const basePath = match[1] + if (!basePath || /^[a-zA-Z]$/.test(basePath)) return { pathname } + return { + pathname: basePath, + focusLine: toPositiveInteger(match[2]) + } +} + +function extractAnchorLineReference(anchor?: string): { anchor?: string; focusLine?: number } { + const normalizedAnchor = String(anchor || '').trim() + if (!normalizedAnchor) return { anchor: undefined } + + const gitHubStyleMatch = normalizedAnchor.match(/^L(\d+)(?:C(\d+))?$/i) + if (gitHubStyleMatch) { + return { + anchor: normalizedAnchor, + focusLine: toPositiveInteger(gitHubStyleMatch[1]) + } + } + + const plainMatch = normalizedAnchor.match(/^(\d+)(?:\:(\d+))?$/) + if (plainMatch) { + return { + anchor: normalizedAnchor, + focusLine: toPositiveInteger(plainMatch[1]) + } + } + + return { anchor: normalizedAnchor } +} + function toFileUrlPath(pathname: string): string | null { try { const url = new URL(pathname) @@ -56,33 +101,44 @@ function toFileUrlPath(pathname: string): string | null { export function resolveMarkdownLinkTarget(href: string, filePath?: string): MarkdownPathTarget | null { const rawHref = String(href || '').trim() - if (!rawHref || rawHref.startsWith('#') || isExternalHref(rawHref) && !rawHref.startsWith('file://')) { + if ( + !rawHref + || rawHref.startsWith('#') + || (isExternalHref(rawHref) && !rawHref.startsWith('file://') && !isWindowsAbsolutePath(rawHref)) + ) { return null } const { pathname, anchor } = splitHrefAnchor(rawHref) + const anchorReference = extractAnchorLineReference(anchor) let decodedPathname = pathname try { decodedPathname = pathname ? decodeURIComponent(pathname) : '' } catch { decodedPathname = pathname } + const pathReference = extractPathLineReference(decodedPathname) + decodedPathname = pathReference.pathname if (rawHref.startsWith('file://')) { - const resolvedFilePath = toFileUrlPath(rawHref) + const resolvedFilePath = toFileUrlPath(decodedPathname) if (!resolvedFilePath) return null - return { path: denormalizePath(normalizePath(resolvedFilePath), filePath), anchor } + return { + path: denormalizePath(normalizePath(resolvedFilePath), filePath), + anchor: anchorReference.anchor, + focusLine: pathReference.focusLine ?? anchorReference.focusLine + } } - if (!filePath) return null - - const normalizedSourcePath = normalizePath(filePath) + const normalizedSourcePath = filePath ? normalizePath(filePath) : '' const lastSlashIndex = normalizedSourcePath.lastIndexOf('/') const sourceDirectory = lastSlashIndex >= 0 ? normalizedSourcePath.slice(0, lastSlashIndex) : normalizedSourcePath let normalizedTargetPath = '' if (/^[a-zA-Z]:[\\/]/.test(decodedPathname) || decodedPathname.startsWith('\\\\')) { normalizedTargetPath = normalizePath(decodedPathname) + } else if (!filePath) { + return null } else if (decodedPathname.startsWith('/')) { const driveMatch = /^[a-zA-Z]:\//.exec(normalizedSourcePath) normalizedTargetPath = driveMatch ? `${driveMatch[0]}${decodedPathname.slice(1)}` : decodedPathname @@ -104,7 +160,8 @@ export function resolveMarkdownLinkTarget(href: string, filePath?: string): Mark return { path: denormalizePath(normalizedTargetPath, filePath), - anchor + anchor: anchorReference.anchor, + focusLine: pathReference.focusLine ?? anchorReference.focusLine } } @@ -146,7 +203,9 @@ export async function navigateMarkdownLink({ const { extension, name } = splitFileNameAndExtension(pathInfo.path) if (openPreview) { - await openPreview({ name, path: pathInfo.path }, extension) + await openPreview({ name, path: pathInfo.path }, extension, { + focusLine: target.focusLine + }) return true } diff --git a/src/renderer/src/pages/QuickOpen.tsx b/src/renderer/src/pages/QuickOpen.tsx index 787f0ca..b5ece27 100644 --- a/src/renderer/src/pages/QuickOpen.tsx +++ b/src/renderer/src/pages/QuickOpen.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' import { FileCode2, Loader2, X } from 'lucide-react' import { FilePreviewModal, useFilePreview } from '@/components/ui/FilePreviewModal' +import QuickPreviewTitleBar from './QuickPreviewTitleBar' function parseFilePathFromSearch(search: string): string | null { const params = new URLSearchParams(search) @@ -56,27 +57,44 @@ export default function QuickOpen() { const closeWindow = () => { closePreview() - window.close() + window.devscope.window.close() } + const quickPreviewName = useMemo(() => { + if (previewFile?.name) return previewFile.name + if (!filePath) return 'Quick Preview' + return splitFileNameAndExtension(filePath).fileName + }, [filePath, previewFile?.name]) + const quickPreviewExtension = useMemo(() => { + if (previewFile?.name) { + return splitFileNameAndExtension(previewFile.name).extension + } + if (!filePath) return '' + return splitFileNameAndExtension(filePath).extension + }, [filePath, previewFile?.name]) + if (!filePath) { return ( -
-
-
- - Quick Preview +
+ +
+
+
+ + Quick Preview +
+

No file path was provided to preview.

-

No file path was provided to preview.

) } return ( -
+
+ {loadingPreview && !previewFile && ( -
+
Loading preview... @@ -85,7 +103,7 @@ export default function QuickOpen() { )} {!loadingPreview && !previewFile && ( -
+
Unable to preview this file
@@ -113,6 +131,7 @@ export default function QuickOpen() { previewBytes={previewBytes} modifiedAt={previewModifiedAt} projectPath={undefined} + shellMode="window" onOpenLinkedPreview={openPreview} onClose={closeWindow} /> diff --git a/src/renderer/src/pages/QuickPreviewTitleBar.tsx b/src/renderer/src/pages/QuickPreviewTitleBar.tsx new file mode 100644 index 0000000..6b9823b --- /dev/null +++ b/src/renderer/src/pages/QuickPreviewTitleBar.tsx @@ -0,0 +1,106 @@ +import type { CSSProperties } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { Copy, Minus, Square, X } from 'lucide-react' +import { DevScopeLogoASCIIMini } from '@/components/ui/DevScopeLogo' +import { useSettings } from '@/lib/settings' +import { cn } from '@/lib/utils' + +function shortenPath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/') + return normalized.replace(/^([A-Z]:)\/Users\/[^/]+/i, '$1/Users/~') +} + +export function QuickPreviewTitleBar(props: { + fileName: string + filePath: string + extension?: string + title?: string +}) { + const { fileName, filePath, extension, title = 'Quick Preview' } = props + const { settings } = useSettings() + const [isMaximized, setIsMaximized] = useState(false) + const iconTheme = settings.theme === 'light' ? 'light' : 'dark' + const displayPath = useMemo(() => shortenPath(filePath), [filePath]) + const extensionLabel = useMemo(() => { + const normalized = String(extension || '').trim().replace(/^\./, '').toUpperCase() + return normalized || 'FILE' + }, [extension]) + + useEffect(() => { + let cancelled = false + + void window.devscope.window.isMaximized().then((maximized) => { + if (!cancelled) setIsMaximized(maximized) + }).catch(() => {}) + + return () => { + cancelled = true + } + }, []) + + const handleMinimize = () => window.devscope.window.minimize() + const handleToggleMaximize = () => { + window.devscope.window.maximize() + setIsMaximized((current) => !current) + } + const handleClose = () => window.devscope.window.close() + + return ( +
+
+ +
+ + {extensionLabel} + +
+
{fileName || title}
+
{displayPath || title}
+
+
+ +
+
+ + + +
+
+ ) +} + +export default QuickPreviewTitleBar From 02c349ad977046d8cf99270bc270ee8d5369e2b1 Mon Sep 17 00:00:00 2001 From: justelson Date: Thu, 26 Mar 2026 22:32:06 +0300 Subject: [PATCH 3/5] refactor(git): split PR services and project actions --- .../ipc/handlers/git-write-basic-handlers.ts | 264 ++++++++ src/main/ipc/handlers/git-write-handlers.ts | 585 +----------------- .../ipc/handlers/git-write-tasked-handlers.ts | 282 +++++++++ .../services/github-pull-request-branch.ts | 183 ++++++ .../services/github-pull-request-draft.ts | 140 +++++ src/main/services/github-pull-request-gh.ts | 176 ++++++ .../services/github-pull-request-types.ts | 53 ++ src/main/services/github-pull-request.ts | 545 +--------------- .../ProjectDetailsPageView.tsx | 262 +------- .../project-details/WorkingChangesView.tsx | 182 +----- .../src/pages/project-details/gitActions.ts | 512 +-------------- .../gitCommitAndPullRequestActions.ts | 211 +++++++ .../project-details/gitProjectSetupActions.ts | 105 ++++ .../pages/project-details/gitSyncActions.ts | 132 ++++ .../projectDetailsPageViewProps.ts | 267 ++++++++ .../project-details/useStackedTaskStatus.ts | 62 ++ .../workingChangesBranchGuard.tsx | 133 ++++ 17 files changed, 2093 insertions(+), 2001 deletions(-) create mode 100644 src/main/ipc/handlers/git-write-basic-handlers.ts create mode 100644 src/main/ipc/handlers/git-write-tasked-handlers.ts create mode 100644 src/main/services/github-pull-request-branch.ts create mode 100644 src/main/services/github-pull-request-draft.ts create mode 100644 src/main/services/github-pull-request-gh.ts create mode 100644 src/main/services/github-pull-request-types.ts create mode 100644 src/renderer/src/pages/project-details/gitCommitAndPullRequestActions.ts create mode 100644 src/renderer/src/pages/project-details/gitProjectSetupActions.ts create mode 100644 src/renderer/src/pages/project-details/gitSyncActions.ts create mode 100644 src/renderer/src/pages/project-details/projectDetailsPageViewProps.ts create mode 100644 src/renderer/src/pages/project-details/useStackedTaskStatus.ts create mode 100644 src/renderer/src/pages/project-details/workingChangesBranchGuard.tsx diff --git a/src/main/ipc/handlers/git-write-basic-handlers.ts b/src/main/ipc/handlers/git-write-basic-handlers.ts new file mode 100644 index 0000000..09c4d7e --- /dev/null +++ b/src/main/ipc/handlers/git-write-basic-handlers.ts @@ -0,0 +1,264 @@ +import log from 'electron-log' +import { + applyStash, + checkoutBranch, + createBranch, + createInitialCommit, + createStash, + createTag, + deleteBranch, + deleteTag, + discardChanges, + dropStash, + listBranches, + listRemotes, + listStashes, + listTags, + removeRemote, + setGlobalGitUser, + setRemoteUrl, + stageFiles, + unstageFiles +} from '../../inspectors/git' + +export async function handleStageFiles( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + files: string[], + options?: { scope?: 'project' | 'repo' } +) { + try { + await stageFiles(projectPath, files, options) + return { success: true } + } catch (err: any) { + log.error('Failed to stage files:', err) + return { success: false, error: err.message } + } +} + +export async function handleUnstageFiles( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + files: string[], + options?: { scope?: 'project' | 'repo' } +) { + try { + await unstageFiles(projectPath, files, options) + return { success: true } + } catch (err: any) { + log.error('Failed to unstage files:', err) + return { success: false, error: err.message } + } +} + +export async function handleDiscardChanges( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + files: string[], + options?: { scope?: 'project' | 'repo'; mode?: 'unstaged' | 'staged' | 'both' } +) { + try { + await discardChanges(projectPath, files, options) + return { success: true } + } catch (err: any) { + log.error('Failed to discard changes:', err) + return { success: false, error: err.message } + } +} + +export async function handleSetGlobalGitUser( + _event: Electron.IpcMainInvokeEvent, + user: { name: string; email: string } +) { + try { + await setGlobalGitUser(user?.name || '', user?.email || '') + return { success: true } + } catch (err: any) { + log.error('Failed to set global git user:', err) + return { success: false, error: err.message } + } +} + +export async function handleListBranches(_event: Electron.IpcMainInvokeEvent, projectPath: string) { + try { + const branches = await listBranches(projectPath) + return { success: true, branches } + } catch (err: any) { + log.error('Failed to list branches:', err) + return { success: false, error: err.message } + } +} + +export async function handleCreateBranch( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + branchName: string, + checkout: boolean = true +) { + try { + await createBranch(projectPath, branchName, checkout) + return { success: true } + } catch (err: any) { + log.error('Failed to create branch:', err) + return { success: false, error: err.message } + } +} + +export async function handleCheckoutBranch( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + branchName: string, + options?: { autoStash?: boolean; autoCleanupLock?: boolean } +) { + try { + const result = await checkoutBranch(projectPath, branchName, options) + return { success: true, ...result } + } catch (err: any) { + log.error('Failed to checkout branch:', err) + return { success: false, error: err.message } + } +} + +export async function handleDeleteBranch( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + branchName: string, + force: boolean = false +) { + try { + await deleteBranch(projectPath, branchName, force) + return { success: true } + } catch (err: any) { + log.error('Failed to delete branch:', err) + return { success: false, error: err.message } + } +} + +export async function handleListRemotes(_event: Electron.IpcMainInvokeEvent, projectPath: string) { + try { + const remotes = await listRemotes(projectPath) + return { success: true, remotes } + } catch (err: any) { + log.error('Failed to list remotes:', err) + return { success: false, error: err.message } + } +} + +export async function handleSetRemoteUrl( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + remoteName: string, + remoteUrl: string +) { + try { + await setRemoteUrl(projectPath, remoteName, remoteUrl) + return { success: true } + } catch (err: any) { + log.error('Failed to set remote URL:', err) + return { success: false, error: err.message } + } +} + +export async function handleRemoveRemote(_event: Electron.IpcMainInvokeEvent, projectPath: string, remoteName: string) { + try { + await removeRemote(projectPath, remoteName) + return { success: true } + } catch (err: any) { + log.error('Failed to remove remote:', err) + return { success: false, error: err.message } + } +} + +export async function handleListTags(_event: Electron.IpcMainInvokeEvent, projectPath: string) { + try { + const tags = await listTags(projectPath) + return { success: true, tags } + } catch (err: any) { + log.error('Failed to list tags:', err) + return { success: false, error: err.message } + } +} + +export async function handleCreateTag( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + tagName: string, + target?: string +) { + try { + await createTag(projectPath, tagName, target) + return { success: true } + } catch (err: any) { + log.error('Failed to create tag:', err) + return { success: false, error: err.message } + } +} + +export async function handleDeleteTag(_event: Electron.IpcMainInvokeEvent, projectPath: string, tagName: string) { + try { + await deleteTag(projectPath, tagName) + return { success: true } + } catch (err: any) { + log.error('Failed to delete tag:', err) + return { success: false, error: err.message } + } +} + +export async function handleListStashes(_event: Electron.IpcMainInvokeEvent, projectPath: string) { + try { + const stashes = await listStashes(projectPath) + return { success: true, stashes } + } catch (err: any) { + log.error('Failed to list stashes:', err) + return { success: false, error: err.message } + } +} + +export async function handleCreateStash(_event: Electron.IpcMainInvokeEvent, projectPath: string, message?: string) { + try { + await createStash(projectPath, message) + return { success: true } + } catch (err: any) { + log.error('Failed to create stash:', err) + return { success: false, error: err.message } + } +} + +export async function handleApplyStash( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + stashRef?: string, + pop?: boolean +) { + try { + await applyStash(projectPath, stashRef, pop) + return { success: true } + } catch (err: any) { + log.error('Failed to apply stash:', err) + return { success: false, error: err.message } + } +} + +export async function handleDropStash( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + stashRef?: string +) { + try { + await dropStash(projectPath, stashRef) + return { success: true } + } catch (err: any) { + log.error('Failed to drop stash:', err) + return { success: false, error: err.message } + } +} + +export async function handleCreateInitialCommit(_event: Electron.IpcMainInvokeEvent, projectPath: string, message: string) { + try { + const result = await createInitialCommit(projectPath, message) + return result + } catch (err: any) { + log.error('Failed to create initial commit:', err) + return { success: false, error: err.message } + } +} diff --git a/src/main/ipc/handlers/git-write-handlers.ts b/src/main/ipc/handlers/git-write-handlers.ts index 6f928ee..7f4fb92 100644 --- a/src/main/ipc/handlers/git-write-handlers.ts +++ b/src/main/ipc/handlers/git-write-handlers.ts @@ -1,551 +1,34 @@ -import log from 'electron-log' -import { - addRemote, - addRemoteOrigin, - applyStash, - checkoutBranch, - createBranch, - createCommit, - createInitialCommit, - createStash, - createTag, - deleteBranch, - deleteTag, - discardChanges, - dropStash, - fetchUpdates, - initGitRepo, - listBranches, - listRemotes, - listStashes, - listTags, - pullUpdates, - pushCommits, - pushSingleCommit, - removeRemote, - setGlobalGitUser, - setRemoteUrl, - stageFiles, - unstageFiles -} from '../../inspectors/git' -import { appendTaskLog, completeTask, createTask } from '../task-manager' -import { createOrOpenPullRequest, logPullRequestError, summarizePullRequestOutcome } from '../../services/github-pull-request' -import { commitPushAndCreatePullRequest } from '../../services/git-stacked-pull-request' - -export async function handleStageFiles( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - files: string[], - options?: { scope?: 'project' | 'repo' } -) { - try { - await stageFiles(projectPath, files, options) - return { success: true } - } catch (err: any) { - log.error('Failed to stage files:', err) - return { success: false, error: err.message } - } -} - -export async function handleUnstageFiles( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - files: string[], - options?: { scope?: 'project' | 'repo' } -) { - try { - await unstageFiles(projectPath, files, options) - return { success: true } - } catch (err: any) { - log.error('Failed to unstage files:', err) - return { success: false, error: err.message } - } -} - -export async function handleDiscardChanges( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - files: string[], - options?: { scope?: 'project' | 'repo'; mode?: 'unstaged' | 'staged' | 'both' } -) { - try { - await discardChanges(projectPath, files, options) - return { success: true } - } catch (err: any) { - log.error('Failed to discard changes:', err) - return { success: false, error: err.message } - } -} - -export async function handleCreateCommit(_event: Electron.IpcMainInvokeEvent, projectPath: string, message: string) { - const task = createTask({ - type: 'git.commit', - title: 'Create commit', - projectPath, - initialLog: `Preparing commit in ${projectPath}` - }) - try { - appendTaskLog(task.id, `Commit message: ${message}`) - await createCommit(projectPath, message) - completeTask(task.id, 'success', 'Commit created successfully.') - return { success: true } - } catch (err: any) { - log.error('Failed to create commit:', err) - completeTask(task.id, 'failed', err?.message || 'Failed to create commit.') - return { success: false, error: err.message } - } -} - -export async function handleSetGlobalGitUser( - _event: Electron.IpcMainInvokeEvent, - user: { name: string; email: string } -) { - try { - await setGlobalGitUser(user?.name || '', user?.email || '') - return { success: true } - } catch (err: any) { - log.error('Failed to set global git user:', err) - return { success: false, error: err.message } - } -} - -export async function handlePushCommits( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - options?: { remoteName?: string; branchName?: string } -) { - const task = createTask({ - type: 'git.push', - title: 'Push commits', - projectPath, - initialLog: `Pushing commits for ${projectPath}` - }) - try { - await pushCommits(projectPath, options) - completeTask(task.id, 'success', 'Push completed successfully.') - return { success: true } - } catch (err: any) { - log.error('Failed to push commits:', err) - completeTask(task.id, 'failed', err?.message || 'Failed to push commits.') - return { success: false, error: err.message } - } -} - -export async function handlePushSingleCommit( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - commitHash: string, - options?: { remoteName?: string; branchName?: string } -) { - const task = createTask({ - type: 'git.push', - title: 'Push single commit', - projectPath, - initialLog: `Pushing commit ${commitHash} for ${projectPath}` - }) - try { - await pushSingleCommit(projectPath, commitHash, options) - completeTask(task.id, 'success', 'Single-commit push completed successfully.') - return { success: true } - } catch (err: any) { - log.error('Failed to push single commit:', err) - completeTask(task.id, 'failed', err?.message || 'Failed to push single commit.') - return { success: false, error: err.message } - } -} - -export async function handleCreateOrOpenPullRequest( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - input: { - projectName?: string - targetBranch?: string - draft?: boolean - title?: string - body?: string - guideText?: string - provider?: 'groq' | 'gemini' | 'codex' - apiKey?: string - model?: string - } -) { - const task = createTask({ - type: 'git.pr', - title: 'Create pull request', - projectPath, - initialLog: `Preparing pull request for ${projectPath}` - }) - try { - appendTaskLog(task.id, `Target branch: ${String(input?.targetBranch || 'auto').trim() || 'auto'}`) - if (input?.draft !== false) { - appendTaskLog(task.id, 'Draft mode: enabled') - } - const result = await createOrOpenPullRequest(projectPath, input || {}) - completeTask(task.id, 'success', summarizePullRequestOutcome(result)) - return { success: true, ...result } - } catch (err: any) { - const normalized = logPullRequestError('failed to create or open pull request', err) - completeTask(task.id, 'failed', normalized.message) - return { success: false, error: normalized.message } - } -} - -export async function handleCommitPushAndCreatePullRequest( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - input: { - projectName?: string - commitMessage?: string - targetBranch?: string - draft?: boolean - guideText?: string - provider?: 'groq' | 'gemini' | 'codex' - apiKey?: string - model?: string - autoStageAll?: boolean - stageScope?: 'project' | 'repo' - } -) { - const task = createTask({ - type: 'git.stacked', - title: 'Commit, push and create pull request', - projectPath, - initialLog: `Running stacked PR flow for ${projectPath}` - }) - try { - appendTaskLog(task.id, `Target branch: ${String(input?.targetBranch || 'auto').trim() || 'auto'}`) - if (input?.autoStageAll) { - appendTaskLog(task.id, `Auto-stage all: enabled (${input.stageScope === 'project' ? 'project scope' : 'repo scope'})`) - } - if (String(input?.commitMessage || '').trim()) { - appendTaskLog(task.id, 'Commit message: provided manually') - } else if (input?.provider) { - appendTaskLog(task.id, `Commit message: AI generated by ${input.provider}${input?.model ? ` (${input.model})` : ''}`) - } - const result = await commitPushAndCreatePullRequest(projectPath, input || {}, (message) => { - appendTaskLog(task.id, message) - }) - completeTask(task.id, 'success', `Committed and ${summarizePullRequestOutcome(result).toLowerCase()}`) - return { success: true, ...result } - } catch (err: any) { - const normalized = logPullRequestError('failed to run commit/push/pr flow', err) - completeTask(task.id, 'failed', normalized.message) - return { success: false, error: normalized.message } - } -} - -export async function handleFetchUpdates(_event: Electron.IpcMainInvokeEvent, projectPath: string, remoteName?: string) { - const task = createTask({ - type: 'git.fetch', - title: 'Fetch updates', - projectPath, - initialLog: remoteName ? `Fetching from ${remoteName}` : 'Fetching from default remote' - }) - try { - await fetchUpdates(projectPath, remoteName) - completeTask(task.id, 'success', 'Fetch completed successfully.') - return { success: true } - } catch (err: any) { - log.error('Failed to fetch updates:', err) - completeTask(task.id, 'failed', err?.message || 'Failed to fetch updates.') - return { success: false, error: err.message } - } -} - -export async function handlePullUpdates( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - options?: { remoteName?: string; branchName?: string; pushRemoteName?: string } -) { - const remoteName = String(options?.remoteName || '').trim() - const pushRemoteName = String(options?.pushRemoteName || '').trim() - const branchName = String(options?.branchName || '').trim() - const task = createTask({ - type: 'git.pull', - title: remoteName ? `Pull ${remoteName}` : 'Pull updates', - projectPath, - initialLog: remoteName - ? pushRemoteName - ? `Pulling ${branchName || 'current branch'} from ${remoteName} and syncing to ${pushRemoteName}` - : `Pulling ${branchName || 'current branch'} from ${remoteName}` - : 'Pulling latest changes from remote' - }) - try { - await pullUpdates(projectPath, options) - completeTask(task.id, 'success', 'Pull completed successfully.') - return { success: true } - } catch (err: any) { - log.error('Failed to pull updates:', err) - completeTask(task.id, 'failed', err?.message || 'Failed to pull updates.') - return { success: false, error: err.message } - } -} - -export async function handleListBranches(_event: Electron.IpcMainInvokeEvent, projectPath: string) { - try { - const branches = await listBranches(projectPath) - return { success: true, branches } - } catch (err: any) { - log.error('Failed to list branches:', err) - return { success: false, error: err.message } - } -} - -export async function handleCreateBranch( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - branchName: string, - checkout: boolean = true -) { - try { - await createBranch(projectPath, branchName, checkout) - return { success: true } - } catch (err: any) { - log.error('Failed to create branch:', err) - return { success: false, error: err.message } - } -} - -export async function handleCheckoutBranch( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - branchName: string, - options?: { autoStash?: boolean; autoCleanupLock?: boolean } -) { - const task = createTask({ - type: 'git.checkout', - title: `Checkout branch: ${branchName}`, - projectPath, - initialLog: `Switching to ${branchName}` - }) - try { - const result = await checkoutBranch(projectPath, branchName, options) - completeTask(task.id, 'success', `Checked out ${branchName}.`) - return { success: true, ...result } - } catch (err: any) { - log.error('Failed to checkout branch:', err) - completeTask(task.id, 'failed', err?.message || `Failed to checkout ${branchName}.`) - return { success: false, error: err.message } - } -} - -export async function handleDeleteBranch( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - branchName: string, - force: boolean = false -) { - try { - await deleteBranch(projectPath, branchName, force) - return { success: true } - } catch (err: any) { - log.error('Failed to delete branch:', err) - return { success: false, error: err.message } - } -} - -export async function handleListRemotes(_event: Electron.IpcMainInvokeEvent, projectPath: string) { - try { - const remotes = await listRemotes(projectPath) - return { success: true, remotes } - } catch (err: any) { - log.error('Failed to list remotes:', err) - return { success: false, error: err.message } - } -} - -export async function handleAddRemote( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - remoteName: string, - remoteUrl: string -) { - const task = createTask({ - type: 'git.remote', - title: `Add remote ${remoteName}`, - projectPath, - initialLog: `Adding remote ${remoteName}: ${remoteUrl}` - }) - try { - const result = await addRemote(projectPath, remoteName, remoteUrl) - if (result?.success) { - completeTask(task.id, 'success', `Remote ${remoteName} added.`) - } else { - completeTask(task.id, 'failed', result?.error || `Failed to add remote ${remoteName}.`) - } - return result - } catch (err: any) { - log.error('Failed to add remote:', err) - completeTask(task.id, 'failed', err?.message || `Failed to add remote ${remoteName}.`) - return { success: false, error: err.message } - } -} - -export async function handleSetRemoteUrl( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - remoteName: string, - remoteUrl: string -) { - try { - await setRemoteUrl(projectPath, remoteName, remoteUrl) - return { success: true } - } catch (err: any) { - log.error('Failed to set remote URL:', err) - return { success: false, error: err.message } - } -} - -export async function handleRemoveRemote(_event: Electron.IpcMainInvokeEvent, projectPath: string, remoteName: string) { - try { - await removeRemote(projectPath, remoteName) - return { success: true } - } catch (err: any) { - log.error('Failed to remove remote:', err) - return { success: false, error: err.message } - } -} - -export async function handleListTags(_event: Electron.IpcMainInvokeEvent, projectPath: string) { - try { - const tags = await listTags(projectPath) - return { success: true, tags } - } catch (err: any) { - log.error('Failed to list tags:', err) - return { success: false, error: err.message } - } -} - -export async function handleCreateTag( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - tagName: string, - target?: string -) { - try { - await createTag(projectPath, tagName, target) - return { success: true } - } catch (err: any) { - log.error('Failed to create tag:', err) - return { success: false, error: err.message } - } -} - -export async function handleDeleteTag(_event: Electron.IpcMainInvokeEvent, projectPath: string, tagName: string) { - try { - await deleteTag(projectPath, tagName) - return { success: true } - } catch (err: any) { - log.error('Failed to delete tag:', err) - return { success: false, error: err.message } - } -} - -export async function handleListStashes(_event: Electron.IpcMainInvokeEvent, projectPath: string) { - try { - const stashes = await listStashes(projectPath) - return { success: true, stashes } - } catch (err: any) { - log.error('Failed to list stashes:', err) - return { success: false, error: err.message } - } -} - -export async function handleCreateStash(_event: Electron.IpcMainInvokeEvent, projectPath: string, message?: string) { - try { - await createStash(projectPath, message) - return { success: true } - } catch (err: any) { - log.error('Failed to create stash:', err) - return { success: false, error: err.message } - } -} - -export async function handleApplyStash( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - stashRef?: string, - pop?: boolean -) { - try { - await applyStash(projectPath, stashRef, pop) - return { success: true } - } catch (err: any) { - log.error('Failed to apply stash:', err) - return { success: false, error: err.message } - } -} - -export async function handleDropStash( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - stashRef?: string -) { - try { - await dropStash(projectPath, stashRef) - return { success: true } - } catch (err: any) { - log.error('Failed to drop stash:', err) - return { success: false, error: err.message } - } -} - -export async function handleInitGitRepo( - _event: Electron.IpcMainInvokeEvent, - projectPath: string, - branchName: string, - createGitignore: boolean, - gitignoreTemplate?: string -) { - const task = createTask({ - type: 'git.init', - title: 'Initialize repository', - projectPath, - initialLog: `Initializing git repository (${branchName})` - }) - try { - const result = await initGitRepo(projectPath, branchName, createGitignore, gitignoreTemplate) - if (result?.success) { - completeTask(task.id, 'success', 'Repository initialized.') - } else { - completeTask(task.id, 'failed', result?.error || 'Failed to initialize repository.') - } - return result - } catch (err: any) { - log.error('Failed to init git repo:', err) - completeTask(task.id, 'failed', err?.message || 'Failed to initialize repository.') - return { success: false, error: err.message } - } -} - -export async function handleCreateInitialCommit(_event: Electron.IpcMainInvokeEvent, projectPath: string, message: string) { - try { - const result = await createInitialCommit(projectPath, message) - return result - } catch (err: any) { - log.error('Failed to create initial commit:', err) - return { success: false, error: err.message } - } -} - -export async function handleAddRemoteOrigin(_event: Electron.IpcMainInvokeEvent, projectPath: string, remoteUrl: string) { - const task = createTask({ - type: 'git.remote', - title: 'Add remote origin', - projectPath, - initialLog: `Adding remote origin: ${remoteUrl}` - }) - try { - const result = await addRemoteOrigin(projectPath, remoteUrl) - if (result?.success) { - completeTask(task.id, 'success', 'Remote origin added.') - } else { - completeTask(task.id, 'failed', result?.error || 'Failed to add remote origin.') - } - return result - } catch (err: any) { - log.error('Failed to add remote origin:', err) - completeTask(task.id, 'failed', err?.message || 'Failed to add remote origin.') - return { success: false, error: err.message } - } -} +export { + handleStageFiles, + handleUnstageFiles, + handleDiscardChanges, + handleSetGlobalGitUser, + handleListBranches, + handleCreateBranch, + handleCheckoutBranch, + handleDeleteBranch, + handleListRemotes, + handleSetRemoteUrl, + handleRemoveRemote, + handleListTags, + handleCreateTag, + handleDeleteTag, + handleListStashes, + handleCreateStash, + handleApplyStash, + handleDropStash, + handleCreateInitialCommit +} from './git-write-basic-handlers' + +export { + handleCreateCommit, + handlePushCommits, + handlePushSingleCommit, + handleCreateOrOpenPullRequest, + handleCommitPushAndCreatePullRequest, + handleFetchUpdates, + handlePullUpdates, + handleAddRemote, + handleInitGitRepo, + handleAddRemoteOrigin +} from './git-write-tasked-handlers' diff --git a/src/main/ipc/handlers/git-write-tasked-handlers.ts b/src/main/ipc/handlers/git-write-tasked-handlers.ts new file mode 100644 index 0000000..8fbb952 --- /dev/null +++ b/src/main/ipc/handlers/git-write-tasked-handlers.ts @@ -0,0 +1,282 @@ +import log from 'electron-log' +import { + addRemote, + addRemoteOrigin, + createCommit, + fetchUpdates, + initGitRepo, + pullUpdates, + pushCommits, + pushSingleCommit +} from '../../inspectors/git' +import { appendTaskLog, completeTask, createTask } from '../task-manager' +import { createOrOpenPullRequest, logPullRequestError, summarizePullRequestOutcome } from '../../services/github-pull-request' +import { commitPushAndCreatePullRequest } from '../../services/git-stacked-pull-request' + +export async function handleCreateCommit(_event: Electron.IpcMainInvokeEvent, projectPath: string, message: string) { + const task = createTask({ + type: 'git.commit', + title: 'Create commit', + projectPath, + initialLog: `Preparing commit in ${projectPath}` + }) + try { + appendTaskLog(task.id, `Commit message: ${message}`) + await createCommit(projectPath, message) + completeTask(task.id, 'success', 'Commit created successfully.') + return { success: true } + } catch (err: any) { + log.error('Failed to create commit:', err) + completeTask(task.id, 'failed', err?.message || 'Failed to create commit.') + return { success: false, error: err.message } + } +} + +export async function handlePushCommits( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + options?: { remoteName?: string; branchName?: string } +) { + const task = createTask({ + type: 'git.push', + title: 'Push commits', + projectPath, + initialLog: `Pushing commits for ${projectPath}` + }) + try { + await pushCommits(projectPath, options) + completeTask(task.id, 'success', 'Push completed successfully.') + return { success: true } + } catch (err: any) { + log.error('Failed to push commits:', err) + completeTask(task.id, 'failed', err?.message || 'Failed to push commits.') + return { success: false, error: err.message } + } +} + +export async function handlePushSingleCommit( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + commitHash: string, + options?: { remoteName?: string; branchName?: string } +) { + const task = createTask({ + type: 'git.push', + title: 'Push single commit', + projectPath, + initialLog: `Pushing commit ${commitHash} for ${projectPath}` + }) + try { + await pushSingleCommit(projectPath, commitHash, options) + completeTask(task.id, 'success', 'Single-commit push completed successfully.') + return { success: true } + } catch (err: any) { + log.error('Failed to push single commit:', err) + completeTask(task.id, 'failed', err?.message || 'Failed to push single commit.') + return { success: false, error: err.message } + } +} + +export async function handleCreateOrOpenPullRequest( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + input: { + projectName?: string + targetBranch?: string + draft?: boolean + title?: string + body?: string + guideText?: string + provider?: 'groq' | 'gemini' | 'codex' + apiKey?: string + model?: string + } +) { + const task = createTask({ + type: 'git.pr', + title: 'Create pull request', + projectPath, + initialLog: `Preparing pull request for ${projectPath}` + }) + try { + appendTaskLog(task.id, `Target branch: ${String(input?.targetBranch || 'auto').trim() || 'auto'}`) + if (input?.draft !== false) { + appendTaskLog(task.id, 'Draft mode: enabled') + } + const result = await createOrOpenPullRequest(projectPath, input || {}) + completeTask(task.id, 'success', summarizePullRequestOutcome(result)) + return { success: true, ...result } + } catch (err: any) { + const normalized = logPullRequestError('failed to create or open pull request', err) + completeTask(task.id, 'failed', normalized.message) + return { success: false, error: normalized.message } + } +} + +export async function handleCommitPushAndCreatePullRequest( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + input: { + projectName?: string + commitMessage?: string + targetBranch?: string + draft?: boolean + guideText?: string + provider?: 'groq' | 'gemini' | 'codex' + apiKey?: string + model?: string + autoStageAll?: boolean + stageScope?: 'project' | 'repo' + } +) { + const task = createTask({ + type: 'git.stacked', + title: 'Commit, push and create pull request', + projectPath, + initialLog: `Running stacked PR flow for ${projectPath}` + }) + try { + appendTaskLog(task.id, `Target branch: ${String(input?.targetBranch || 'auto').trim() || 'auto'}`) + if (input?.autoStageAll) { + appendTaskLog(task.id, `Auto-stage all: enabled (${input.stageScope === 'project' ? 'project scope' : 'repo scope'})`) + } + if (String(input?.commitMessage || '').trim()) { + appendTaskLog(task.id, 'Commit message: provided manually') + } else if (input?.provider) { + appendTaskLog(task.id, `Commit message: AI generated by ${input.provider}${input?.model ? ` (${input.model})` : ''}`) + } + const result = await commitPushAndCreatePullRequest(projectPath, input || {}, (message) => { + appendTaskLog(task.id, message) + }) + completeTask(task.id, 'success', `Committed and ${summarizePullRequestOutcome(result).toLowerCase()}`) + return { success: true, ...result } + } catch (err: any) { + const normalized = logPullRequestError('failed to run commit/push/pr flow', err) + completeTask(task.id, 'failed', normalized.message) + return { success: false, error: normalized.message } + } +} + +export async function handleFetchUpdates(_event: Electron.IpcMainInvokeEvent, projectPath: string, remoteName?: string) { + const task = createTask({ + type: 'git.fetch', + title: 'Fetch updates', + projectPath, + initialLog: remoteName ? `Fetching from ${remoteName}` : 'Fetching from default remote' + }) + try { + await fetchUpdates(projectPath, remoteName) + completeTask(task.id, 'success', 'Fetch completed successfully.') + return { success: true } + } catch (err: any) { + log.error('Failed to fetch updates:', err) + completeTask(task.id, 'failed', err?.message || 'Failed to fetch updates.') + return { success: false, error: err.message } + } +} + +export async function handlePullUpdates( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + options?: { remoteName?: string; branchName?: string; pushRemoteName?: string } +) { + const remoteName = String(options?.remoteName || '').trim() + const pushRemoteName = String(options?.pushRemoteName || '').trim() + const branchName = String(options?.branchName || '').trim() + const task = createTask({ + type: 'git.pull', + title: remoteName ? `Pull ${remoteName}` : 'Pull updates', + projectPath, + initialLog: remoteName + ? pushRemoteName + ? `Pulling ${branchName || 'current branch'} from ${remoteName} and syncing to ${pushRemoteName}` + : `Pulling ${branchName || 'current branch'} from ${remoteName}` + : 'Pulling latest changes from remote' + }) + try { + await pullUpdates(projectPath, options) + completeTask(task.id, 'success', 'Pull completed successfully.') + return { success: true } + } catch (err: any) { + log.error('Failed to pull updates:', err) + completeTask(task.id, 'failed', err?.message || 'Failed to pull updates.') + return { success: false, error: err.message } + } +} + +export async function handleAddRemote( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + remoteName: string, + remoteUrl: string +) { + const task = createTask({ + type: 'git.remote', + title: `Add remote ${remoteName}`, + projectPath, + initialLog: `Adding remote ${remoteName}: ${remoteUrl}` + }) + try { + const result = await addRemote(projectPath, remoteName, remoteUrl) + if (result?.success) { + completeTask(task.id, 'success', `Remote ${remoteName} added.`) + } else { + completeTask(task.id, 'failed', result?.error || `Failed to add remote ${remoteName}.`) + } + return result + } catch (err: any) { + log.error('Failed to add remote:', err) + completeTask(task.id, 'failed', err?.message || `Failed to add remote ${remoteName}.`) + return { success: false, error: err.message } + } +} + +export async function handleInitGitRepo( + _event: Electron.IpcMainInvokeEvent, + projectPath: string, + branchName: string, + createGitignore: boolean, + gitignoreTemplate?: string +) { + const task = createTask({ + type: 'git.init', + title: 'Initialize repository', + projectPath, + initialLog: `Initializing git repository (${branchName})` + }) + try { + const result = await initGitRepo(projectPath, branchName, createGitignore, gitignoreTemplate) + if (result?.success) { + completeTask(task.id, 'success', 'Repository initialized.') + } else { + completeTask(task.id, 'failed', result?.error || 'Failed to initialize repository.') + } + return result + } catch (err: any) { + log.error('Failed to init git repo:', err) + completeTask(task.id, 'failed', err?.message || 'Failed to initialize repository.') + return { success: false, error: err.message } + } +} + +export async function handleAddRemoteOrigin(_event: Electron.IpcMainInvokeEvent, projectPath: string, remoteUrl: string) { + const task = createTask({ + type: 'git.remote', + title: 'Add remote origin', + projectPath, + initialLog: `Adding remote origin: ${remoteUrl}` + }) + try { + const result = await addRemoteOrigin(projectPath, remoteUrl) + if (result?.success) { + completeTask(task.id, 'success', 'Remote origin added.') + } else { + completeTask(task.id, 'failed', result?.error || 'Failed to add remote origin.') + } + return result + } catch (err: any) { + log.error('Failed to add remote origin:', err) + completeTask(task.id, 'failed', err?.message || 'Failed to add remote origin.') + return { success: false, error: err.message } + } +} diff --git a/src/main/services/github-pull-request-branch.ts b/src/main/services/github-pull-request-branch.ts new file mode 100644 index 0000000..d3afc12 --- /dev/null +++ b/src/main/services/github-pull-request-branch.ts @@ -0,0 +1,183 @@ +import { createGit, getRepoContext } from '../inspectors/git/core' +import { pushCommits } from '../inspectors/git/write' +import { getGitHubPublishContext } from './github-publish' +import { + parseGitHubRemoteRef, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, + parseGitHubRepositoryOwnerLogin +} from './github-remote' +import { resolveDefaultBranch } from './github-pull-request-gh' +import type { BranchHeadContext, BranchState } from './github-pull-request-types' + +function appendUnique(values: string[], next: string | null | undefined) { + const normalized = String(next || '').trim() + if (!normalized || values.includes(normalized)) return + values.push(normalized) +} + +export function parseUpstreamRef(upstreamRef: string | null | undefined): { remoteName: string; branchName: string } | null { + const normalized = String(upstreamRef || '').trim() + if (!normalized || !normalized.includes('/')) return null + + if (normalized.startsWith('refs/remotes/')) { + const remainder = normalized.slice('refs/remotes/'.length) + const slashIndex = remainder.indexOf('/') + if (slashIndex < 0) return null + const remoteName = remainder.slice(0, slashIndex).trim() + const branchName = remainder.slice(slashIndex + 1).trim() + return remoteName && branchName ? { remoteName, branchName } : null + } + + const [remoteName, ...branchParts] = normalized.split('/') + const branchName = branchParts.join('/').trim() + return remoteName && branchName ? { remoteName, branchName } : null +} + +export async function resolveRepoCwd(projectPath: string) { + const git = createGit(projectPath) + const repoContext = await getRepoContext(git, projectPath) + return repoContext.repoRoot +} + +export async function readBranchState(projectPath: string): Promise { + const cwd = await resolveRepoCwd(projectPath) + const git = createGit(cwd) + const [branchRaw, workingTreeRaw, upstreamRefRaw, aheadBehindRaw, remotes] = await Promise.all([ + git.raw(['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => 'HEAD'), + git.raw(['status', '--porcelain=v1']).catch(() => ''), + git.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).catch(() => ''), + git.raw(['rev-list', '--left-right', '--count', 'HEAD...@{u}']).catch(() => ''), + git.getRemotes(true).catch(() => []) + ]) + + const branch = String(branchRaw || '').trim() || null + const upstreamRef = String(upstreamRefRaw || '').trim() || null + const workingTreeLines = String(workingTreeRaw || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + const [aheadText, behindText] = String(aheadBehindRaw || '').trim().split(/\s+/) + + return { + cwd, + branch, + detached: branch === 'HEAD' || !branch, + hasWorkingTreeChanges: workingTreeLines.length > 0, + upstreamRef, + ahead: Number.isNaN(Number.parseInt(aheadText || '0', 10)) ? 0 : Number.parseInt(aheadText || '0', 10), + behind: Number.isNaN(Number.parseInt(behindText || '0', 10)) ? 0 : Number.parseInt(behindText || '0', 10), + remotes: remotes.map((remote) => ({ + name: remote.name, + fetchUrl: String(remote.refs?.fetch || '').trim(), + pushUrl: String(remote.refs?.push || '').trim() + })) + } +} + +export function getPreferredGitHubRemote(remotes: Array<{ name: string; fetchUrl: string; pushUrl: string }>) { + return remotes.find((remote) => remote.name === 'origin' && parseGitHubRemoteRef(remote.pushUrl || remote.fetchUrl)) + ?? remotes.find((remote) => parseGitHubRemoteRef(remote.pushUrl || remote.fetchUrl)) + ?? null +} + +export async function resolveBranchHeadContext(projectPath: string, branchState: BranchState): Promise { + const branch = String(branchState.branch || '').trim() + if (!branch || branchState.detached) { + throw new Error('Cannot resolve a pull request branch from detached HEAD.') + } + + const upstream = parseUpstreamRef(branchState.upstreamRef) + const trackedRemoteName = upstream?.remoteName ?? null + const trackedRemoteBranch = upstream?.branchName || branch + const trackedRemote = trackedRemoteName + ? branchState.remotes.find((remote) => remote.name === trackedRemoteName) || null + : null + const trackedRepositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(trackedRemote?.pushUrl || trackedRemote?.fetchUrl) + const trackedOwnerLogin = parseGitHubRepositoryOwnerLogin(trackedRepositoryNameWithOwner) + + const publishContext = await getGitHubPublishContext(projectPath).catch(() => null) + const upstreamFullName = publishContext?.upstream?.fullName || null + const isCrossRepository = Boolean( + trackedRepositoryNameWithOwner + && upstreamFullName + && trackedRepositoryNameWithOwner !== upstreamFullName + ) + + const headSelectors: string[] = [] + const ownerQualifiedSelector = isCrossRepository && trackedOwnerLogin + ? `${trackedOwnerLogin}:${trackedRemoteBranch}` + : trackedRemoteBranch + appendUnique(headSelectors, ownerQualifiedSelector) + appendUnique(headSelectors, trackedRemoteName ? `${trackedRemoteName}:${trackedRemoteBranch}` : null) + appendUnique(headSelectors, branch) + appendUnique(headSelectors, trackedRemoteBranch !== branch ? trackedRemoteBranch : null) + + return { + headBranch: trackedRemoteBranch, + headSelectors, + preferredHeadSelector: ownerQualifiedSelector, + remoteName: trackedRemoteName, + headRepositoryNameWithOwner: trackedRepositoryNameWithOwner, + headRepositoryOwnerLogin: trackedOwnerLogin, + isCrossRepository + } +} + +export async function resolveBaseBranch( + cwd: string, + branch: string, + upstreamRef: string | null, + isCrossRepository: boolean, + preferredBaseBranch?: string | null +) { + const normalizedPreferred = String(preferredBaseBranch || '').trim() + if (normalizedPreferred) { + return normalizedPreferred + } + + const git = createGit(cwd) + const configured = String(await git.raw(['config', '--get', `branch.${branch}.gh-merge-base`]).catch(() => '')).trim() + if (configured) return configured + + const upstream = parseUpstreamRef(upstreamRef) + if (upstream && !isCrossRepository && upstream.branchName && upstream.branchName !== branch) { + return upstream.branchName + } + + return await resolveDefaultBranch(cwd).catch(() => 'main') +} + +export async function ensureNoWorkingTreeChanges(branchState: BranchState) { + if (branchState.detached) { + throw new Error('Detached HEAD: checkout a branch before creating a PR.') + } + if (branchState.hasWorkingTreeChanges) { + throw new Error('Commit local changes before creating a PR.') + } + if (branchState.behind > 0 && branchState.ahead > 0) { + throw new Error('Branch has diverged from upstream. Rebase or merge before creating a PR.') + } + if (branchState.behind > 0) { + throw new Error('Branch is behind upstream. Pull or rebase before creating a PR.') + } +} + +export async function pushCurrentBranchIfNeeded(projectPath: string, branchState: BranchState) { + const preferredRemote = getPreferredGitHubRemote(branchState.remotes) + if (!preferredRemote) { + throw new Error('Add a GitHub remote before creating a PR.') + } + + const branch = String(branchState.branch || '').trim() + if (!branch) { + throw new Error('Detached HEAD: checkout a branch before creating a PR.') + } + + if (!branchState.upstreamRef || branchState.ahead > 0) { + const upstream = parseUpstreamRef(branchState.upstreamRef) + await pushCommits(branchState.cwd, { + remoteName: upstream?.remoteName || preferredRemote.name, + branchName: branch + }) + } +} diff --git a/src/main/services/github-pull-request-draft.ts b/src/main/services/github-pull-request-draft.ts new file mode 100644 index 0000000..a2db9a2 --- /dev/null +++ b/src/main/services/github-pull-request-draft.ts @@ -0,0 +1,140 @@ +import { generateGitPullRequestDraftWithProvider } from '../ai/git-text' +import { createGit } from '../inspectors/git/core' +import type { DraftInput, EnsuredDraft } from './github-pull-request-types' + +async function buildRangeContext(cwd: string, baseBranch: string) { + const git = createGit(cwd) + const [commitSummaryRaw, diffSummaryRaw, diffPatchRaw, commitCountRaw] = await Promise.all([ + git.raw(['log', '--reverse', '--format=- %s', `${baseBranch}..HEAD`]).catch(() => ''), + git.raw(['diff', '--stat', `${baseBranch}...HEAD`]).catch(() => ''), + git.raw(['diff', '--unified=3', `${baseBranch}...HEAD`]).catch(() => ''), + git.raw(['rev-list', '--count', `${baseBranch}..HEAD`]).catch(() => '0') + ]) + + const commitCount = Number.parseInt(String(commitCountRaw || '0').trim(), 10) + if (!Number.isFinite(commitCount) || commitCount <= 0) { + throw new Error('No local branch commits are available to include in a pull request.') + } + + return { + diff: [ + '## Commits', + String(commitSummaryRaw || '').trim() || '(no commit summary available)', + '', + '## Diff Summary', + String(diffSummaryRaw || '').trim() || '(no diff summary available)', + '', + '## Diff Patch', + String(diffPatchRaw || '').trim() || '(no diff patch available)' + ].join('\n'), + commitMessages: String(commitSummaryRaw || '') + .split(/\r?\n/) + .map((line) => line.replace(/^-\s*/, '').trim()) + .filter(Boolean) + } +} + +function buildFallbackPullRequestDraft(input: { + projectName: string + currentBranch: string + targetBranch: string + guideText?: string + commitMessages?: string[] +}) { + const normalizedProjectName = String(input.projectName || 'project').trim() || 'project' + const normalizedCurrentBranch = String(input.currentBranch || '').trim() + const normalizedTargetBranch = String(input.targetBranch || '').trim() || 'main' + const title = normalizedCurrentBranch + ? `Update ${normalizedProjectName} (${normalizedCurrentBranch} -> ${normalizedTargetBranch})` + : `Update ${normalizedProjectName}` + const uniqueMessages = Array.from(new Set((input.commitMessages || []).map((message) => String(message || '').trim()).filter(Boolean))).slice(0, 6) + const guideNote = String(input.guideText || '').trim() + + const bodyLines = [ + '## Summary', + `- Prepare a pull request for ${normalizedProjectName}.`, + `- Source branch: \`${normalizedCurrentBranch || 'current'}\` into \`${normalizedTargetBranch}\`.`, + '', + '## Changes', + ...(uniqueMessages.length > 0 + ? uniqueMessages.map((message) => `- ${message}`) + : ['- Review the branch diff and expand this summary before publishing.']), + '', + '## Testing', + '- Not yet validated.', + '', + '## Risks', + '- Review the generated title/body and confirm the target branch before publishing.' + ] + + if (guideNote) { + bodyLines.push('', '## Guide Notes', guideNote) + } + + return { + title, + body: bodyLines.join('\n') + } +} + +export async function ensureDraft(cwd: string, branch: string, baseBranch: string, input: DraftInput): Promise { + const providedTitle = String(input.title || '').trim() + const providedBody = String(input.body || '').trim() + if (providedTitle && providedBody) { + return { + title: providedTitle, + body: providedBody, + source: 'provided' + } + } + + const rangeContext = await buildRangeContext(cwd, baseBranch) + const fallbackDraft = buildFallbackPullRequestDraft({ + projectName: input.projectName || 'Project', + currentBranch: branch, + targetBranch: baseBranch, + guideText: input.guideText, + commitMessages: rangeContext.commitMessages + }) + const provider = input.provider + ? { + provider: input.provider, + ...(input.apiKey?.trim() ? { apiKey: input.apiKey.trim() } : {}), + ...(input.model?.trim() ? { model: input.model.trim() } : {}) + } + : null + + if (!provider) { + return { + ...fallbackDraft, + source: 'fallback' + } + } + + const generateResult = await generateGitPullRequestDraftWithProvider({ + ...provider, + draftInput: { + projectName: input.projectName, + currentBranch: branch, + targetBranch: baseBranch, + scopeLabel: 'Current branch changes', + diff: rangeContext.diff, + guideText: input.guideText + } + }) + + if (!generateResult.success || !String(generateResult.title || '').trim() || !String(generateResult.body || '').trim()) { + return { + ...fallbackDraft, + source: 'fallback', + provider: provider.provider + } + } + + return { + title: String(generateResult.title || '').trim(), + body: String(generateResult.body || '').trim(), + source: 'ai', + provider: provider.provider + } +} diff --git a/src/main/services/github-pull-request-gh.ts b/src/main/services/github-pull-request-gh.ts new file mode 100644 index 0000000..8af0d49 --- /dev/null +++ b/src/main/services/github-pull-request-gh.ts @@ -0,0 +1,176 @@ +import { execFile as execFileCallback } from 'child_process' +import { randomUUID } from 'node:crypto' +import { unlink, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { promisify } from 'util' +import { getAugmentedEnv } from '../inspectors/safe-exec' +import { parseGitHubRepositoryOwnerLogin } from './github-remote' +import type { CreatePullRequestRequest, PullRequestInfo, PullRequestState } from './github-pull-request-types' + +const execFileAsync = promisify(execFileCallback) +const GH_TIMEOUT_MS = 30_000 +const GITHUB_PULL_REQUEST_JSON_FIELDS = 'number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner' + +export async function runGh(cwd: string, args: string[]) { + try { + const result = await execFileAsync('gh', args, { + cwd, + timeout: GH_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 1024 * 1024, + env: getAugmentedEnv() + }) + return { + stdout: String(result.stdout || ''), + stderr: String(result.stderr || '') + } + } catch (error: any) { + const message = String(error?.stderr || error?.stdout || error?.message || '').trim() + const lower = message.toLowerCase() + + if (error?.code === 'ENOENT') { + throw new Error('GitHub CLI (`gh`) is required but was not found on PATH.') + } + if ( + lower.includes('not logged in') + || lower.includes('gh auth login') + || lower.includes('authentication failed') + || lower.includes('no oauth token') + ) { + throw new Error('GitHub CLI is not authenticated. Run `gh auth login` and retry.') + } + if ( + lower.includes('could not resolve to a pullrequest') + || lower.includes('pull request not found') + || lower.includes('no pull requests found for branch') + ) { + throw new Error('Pull request not found for the current branch.') + } + + throw new Error(message || 'GitHub CLI command failed.') + } +} + +function normalizePullRequestState(input: { state?: string | null; mergedAt?: string | null }): PullRequestState { + if ((input.mergedAt || '').trim()) return 'merged' + if (input.state === 'CLOSED' || input.state === 'closed') return 'closed' + return 'open' +} + +function toPullRequestSummary(entry: any): PullRequestInfo | null { + if (!entry || typeof entry !== 'object') return null + const number = Number(entry.number) + const title = String(entry.title || '').trim() + const url = String(entry.url || '').trim() + const baseBranch = String(entry.baseRefName || '').trim() + const headBranch = String(entry.headRefName || '').trim() + if (!Number.isInteger(number) || number <= 0 || !title || !url || !baseBranch || !headBranch) { + return null + } + + const headRepositoryNameWithOwner = typeof entry.headRepository?.nameWithOwner === 'string' + ? String(entry.headRepository.nameWithOwner).trim() + : null + const headRepositoryOwnerLogin = typeof entry.headRepositoryOwner?.login === 'string' + ? String(entry.headRepositoryOwner.login).trim() + : parseGitHubRepositoryOwnerLogin(headRepositoryNameWithOwner) + + return { + number, + title, + url, + baseBranch, + headBranch, + state: normalizePullRequestState({ state: entry.state, mergedAt: entry.mergedAt }), + updatedAt: typeof entry.updatedAt === 'string' && entry.updatedAt.trim() ? entry.updatedAt.trim() : null, + ...(typeof entry.isCrossRepository === 'boolean' ? { isCrossRepository: entry.isCrossRepository } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}) + } +} + +function parsePullRequestList(raw: string): PullRequestInfo[] { + if (!raw.trim()) return [] + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed + .map((entry) => toPullRequestSummary(entry)) + .filter((entry): entry is PullRequestInfo => Boolean(entry)) + } catch { + return [] + } +} + +async function listPullRequests(cwd: string, headSelector: string, state: 'open' | 'all') { + const result = await runGh(cwd, [ + 'pr', + 'list', + '--head', + headSelector, + '--state', + state, + '--limit', + '20', + '--json', + GITHUB_PULL_REQUEST_JSON_FIELDS + ]) + return parsePullRequestList(result.stdout) +} + +export async function findOpenPullRequest(cwd: string, headSelectors: string[]) { + for (const headSelector of headSelectors) { + const matches = await listPullRequests(cwd, headSelector, 'open').catch(() => []) + if (matches[0]) { + return matches[0] + } + } + return null +} + +export async function findLatestPullRequest(cwd: string, headSelectors: string[]) { + const byNumber = new Map() + for (const headSelector of headSelectors) { + const matches = await listPullRequests(cwd, headSelector, 'all').catch(() => []) + for (const match of matches) { + byNumber.set(match.number, match) + } + } + + const parsed = Array.from(byNumber.values()).sort((left, right) => { + const leftTime = left.updatedAt ? Date.parse(left.updatedAt) : 0 + const rightTime = right.updatedAt ? Date.parse(right.updatedAt) : 0 + return rightTime - leftTime + }) + return parsed.find((entry) => entry.state === 'open') || parsed[0] || null +} + +export async function resolveDefaultBranch(cwd: string) { + const result = await runGh(cwd, ['repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name']) + const branch = String(result.stdout || '').trim() + return branch || 'main' +} + +export async function createPullRequest(cwd: string, input: CreatePullRequestRequest) { + const bodyFile = join(tmpdir(), `devscope-pr-body-${process.pid}-${randomUUID()}.md`) + await writeFile(bodyFile, input.body, 'utf8') + try { + const result = await runGh(cwd, [ + 'pr', + 'create', + '--base', + input.baseBranch, + '--head', + input.headSelector, + '--title', + input.title, + '--body-file', + bodyFile, + ...(input.draft ? ['--draft'] : []) + ]) + return result.stdout.trim() + } finally { + await unlink(bodyFile).catch(() => undefined) + } +} diff --git a/src/main/services/github-pull-request-types.ts b/src/main/services/github-pull-request-types.ts new file mode 100644 index 0000000..a21a304 --- /dev/null +++ b/src/main/services/github-pull-request-types.ts @@ -0,0 +1,53 @@ +import type { + DevScopeCreatePullRequestInput, + DevScopePullRequestDraftSource, + DevScopePullRequestProvider, + DevScopePullRequestSummary +} from '../../shared/contracts/devscope-git-contracts' + +export type PullRequestState = 'open' | 'closed' | 'merged' + +export type PullRequestInfo = DevScopePullRequestSummary & { + updatedAt: string | null + isCrossRepository?: boolean + headRepositoryNameWithOwner?: string | null + headRepositoryOwnerLogin?: string | null +} + +export type BranchState = { + cwd: string + branch: string | null + detached: boolean + hasWorkingTreeChanges: boolean + upstreamRef: string | null + ahead: number + behind: number + remotes: Array<{ name: string; fetchUrl: string; pushUrl: string }> +} + +export type BranchHeadContext = { + headBranch: string + headSelectors: string[] + preferredHeadSelector: string + remoteName: string | null + headRepositoryNameWithOwner: string | null + headRepositoryOwnerLogin: string | null + isCrossRepository: boolean +} + +export type EnsuredDraft = { + title: string + body: string + source: DevScopePullRequestDraftSource + provider?: DevScopePullRequestProvider +} + +export type CreatePullRequestRequest = { + baseBranch: string + headSelector: string + title: string + body: string + draft: boolean +} + +export type DraftInput = DevScopeCreatePullRequestInput diff --git a/src/main/services/github-pull-request.ts b/src/main/services/github-pull-request.ts index cdc6935..aa6d715 100644 --- a/src/main/services/github-pull-request.ts +++ b/src/main/services/github-pull-request.ts @@ -1,552 +1,34 @@ -import { execFile as execFileCallback } from 'child_process' import log from 'electron-log' -import { randomUUID } from 'node:crypto' -import { unlink, writeFile } from 'fs/promises' -import { tmpdir } from 'os' -import { join } from 'path' -import { promisify } from 'util' -import { generateGitPullRequestDraftWithProvider } from '../ai/git-text' -import { createGit, getRepoContext } from '../inspectors/git/core' -import { getAugmentedEnv } from '../inspectors/safe-exec' -import { pushCommits } from '../inspectors/git/write' -import { getGitHubPublishContext } from './github-publish' +import { ensureDraft } from './github-pull-request-draft' +import { createPullRequest, findLatestPullRequest, findOpenPullRequest, runGh } from './github-pull-request-gh' import { - parseGitHubRemoteRef, - parseGitHubRepositoryNameWithOwnerFromRemoteUrl, - parseGitHubRepositoryOwnerLogin -} from './github-remote' + ensureNoWorkingTreeChanges, + getPreferredGitHubRemote, + pushCurrentBranchIfNeeded, + readBranchState, + resolveBaseBranch, + resolveBranchHeadContext, + resolveRepoCwd +} from './github-pull-request-branch' +import type { PullRequestState } from './github-pull-request-types' import type { - DevScopePullRequestSummary, DevScopeCreatePullRequestInput, DevScopePullRequestDraftSource, - DevScopePullRequestProvider + DevScopePullRequestProvider, + DevScopePullRequestSummary } from '../../shared/contracts/devscope-git-contracts' -const execFileAsync = promisify(execFileCallback) -const GH_TIMEOUT_MS = 30_000 -const GITHUB_PULL_REQUEST_JSON_FIELDS = 'number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner' - -type PullRequestState = 'open' | 'closed' | 'merged' - -type PullRequestInfo = DevScopePullRequestSummary & { - updatedAt: string | null - isCrossRepository?: boolean - headRepositoryNameWithOwner?: string | null - headRepositoryOwnerLogin?: string | null -} - -type BranchState = { - cwd: string - branch: string | null - detached: boolean - hasWorkingTreeChanges: boolean - upstreamRef: string | null - ahead: number - behind: number - remotes: Array<{ name: string; fetchUrl: string; pushUrl: string }> -} - -type BranchHeadContext = { - headBranch: string - headSelectors: string[] - preferredHeadSelector: string - remoteName: string | null - headRepositoryNameWithOwner: string | null - headRepositoryOwnerLogin: string | null - isCrossRepository: boolean -} - -type EnsuredDraft = { - title: string - body: string - source: DevScopePullRequestDraftSource - provider?: DevScopePullRequestProvider -} - function toServiceError(err: unknown, fallback: string): Error { if (err instanceof Error && err.message) return err return new Error(fallback) } -async function runGh(cwd: string, args: string[]) { - try { - const result = await execFileAsync('gh', args, { - cwd, - timeout: GH_TIMEOUT_MS, - windowsHide: true, - maxBuffer: 1024 * 1024, - env: getAugmentedEnv() - }) - return { - stdout: String(result.stdout || ''), - stderr: String(result.stderr || '') - } - } catch (error: any) { - const message = String(error?.stderr || error?.stdout || error?.message || '').trim() - const lower = message.toLowerCase() - - if (error?.code === 'ENOENT') { - throw new Error('GitHub CLI (`gh`) is required but was not found on PATH.') - } - if ( - lower.includes('not logged in') - || lower.includes('gh auth login') - || lower.includes('authentication failed') - || lower.includes('no oauth token') - ) { - throw new Error('GitHub CLI is not authenticated. Run `gh auth login` and retry.') - } - if ( - lower.includes('could not resolve to a pullrequest') - || lower.includes('pull request not found') - || lower.includes('no pull requests found for branch') - ) { - throw new Error('Pull request not found for the current branch.') - } - - throw new Error(message || 'GitHub CLI command failed.') - } -} - -function normalizePullRequestState(input: { state?: string | null; mergedAt?: string | null }): PullRequestState { - if ((input.mergedAt || '').trim()) return 'merged' - if (input.state === 'CLOSED' || input.state === 'closed') return 'closed' - return 'open' -} - -function toPullRequestSummary(entry: any): PullRequestInfo | null { - if (!entry || typeof entry !== 'object') return null - const number = Number(entry.number) - const title = String(entry.title || '').trim() - const url = String(entry.url || '').trim() - const baseBranch = String(entry.baseRefName || '').trim() - const headBranch = String(entry.headRefName || '').trim() - if (!Number.isInteger(number) || number <= 0 || !title || !url || !baseBranch || !headBranch) { - return null - } - - const headRepositoryNameWithOwner = typeof entry.headRepository?.nameWithOwner === 'string' - ? String(entry.headRepository.nameWithOwner).trim() - : null - const headRepositoryOwnerLogin = typeof entry.headRepositoryOwner?.login === 'string' - ? String(entry.headRepositoryOwner.login).trim() - : parseGitHubRepositoryOwnerLogin(headRepositoryNameWithOwner) - - return { - number, - title, - url, - baseBranch, - headBranch, - state: normalizePullRequestState({ state: entry.state, mergedAt: entry.mergedAt }), - updatedAt: typeof entry.updatedAt === 'string' && entry.updatedAt.trim() ? entry.updatedAt.trim() : null, - ...(typeof entry.isCrossRepository === 'boolean' ? { isCrossRepository: entry.isCrossRepository } : {}), - ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), - ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}) - } -} - -function parsePullRequestList(raw: string): PullRequestInfo[] { - if (!raw.trim()) return [] - try { - const parsed = JSON.parse(raw) - if (!Array.isArray(parsed)) return [] - return parsed - .map((entry) => toPullRequestSummary(entry)) - .filter((entry): entry is PullRequestInfo => Boolean(entry)) - } catch { - return [] - } -} - -function appendUnique(values: string[], next: string | null | undefined) { - const normalized = String(next || '').trim() - if (!normalized || values.includes(normalized)) return - values.push(normalized) -} - -function parseUpstreamRef(upstreamRef: string | null | undefined): { remoteName: string; branchName: string } | null { - const normalized = String(upstreamRef || '').trim() - if (!normalized || !normalized.includes('/')) return null - - if (normalized.startsWith('refs/remotes/')) { - const remainder = normalized.slice('refs/remotes/'.length) - const slashIndex = remainder.indexOf('/') - if (slashIndex < 0) return null - const remoteName = remainder.slice(0, slashIndex).trim() - const branchName = remainder.slice(slashIndex + 1).trim() - return remoteName && branchName ? { remoteName, branchName } : null - } - - const [remoteName, ...branchParts] = normalized.split('/') - const branchName = branchParts.join('/').trim() - return remoteName && branchName ? { remoteName, branchName } : null -} - -async function resolveRepoCwd(projectPath: string) { - const git = createGit(projectPath) - const repoContext = await getRepoContext(git, projectPath) - return repoContext.repoRoot -} - export async function ensurePullRequestPrerequisites(projectPath: string): Promise { const cwd = await resolveRepoCwd(projectPath) await runGh(cwd, ['--version']) await runGh(cwd, ['auth', 'status']) } -async function readBranchState(projectPath: string): Promise { - const cwd = await resolveRepoCwd(projectPath) - const git = createGit(cwd) - const [branchRaw, workingTreeRaw, upstreamRefRaw, aheadBehindRaw, remotes] = await Promise.all([ - git.raw(['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => 'HEAD'), - git.raw(['status', '--porcelain=v1']).catch(() => ''), - git.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).catch(() => ''), - git.raw(['rev-list', '--left-right', '--count', 'HEAD...@{u}']).catch(() => ''), - git.getRemotes(true).catch(() => []) - ]) - - const branch = String(branchRaw || '').trim() || null - const upstreamRef = String(upstreamRefRaw || '').trim() || null - const workingTreeLines = String(workingTreeRaw || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - const [aheadText, behindText] = String(aheadBehindRaw || '').trim().split(/\s+/) - - return { - cwd, - branch, - detached: branch === 'HEAD' || !branch, - hasWorkingTreeChanges: workingTreeLines.length > 0, - upstreamRef, - ahead: Number.isNaN(Number.parseInt(aheadText || '0', 10)) ? 0 : Number.parseInt(aheadText || '0', 10), - behind: Number.isNaN(Number.parseInt(behindText || '0', 10)) ? 0 : Number.parseInt(behindText || '0', 10), - remotes: remotes.map((remote) => ({ - name: remote.name, - fetchUrl: String(remote.refs?.fetch || '').trim(), - pushUrl: String(remote.refs?.push || '').trim() - })) - } -} - -function getPreferredGitHubRemote(remotes: Array<{ name: string; fetchUrl: string; pushUrl: string }>) { - return remotes.find((remote) => remote.name === 'origin' && parseGitHubRemoteRef(remote.pushUrl || remote.fetchUrl)) - ?? remotes.find((remote) => parseGitHubRemoteRef(remote.pushUrl || remote.fetchUrl)) - ?? null -} - -async function resolveBranchHeadContext(projectPath: string, branchState: BranchState): Promise { - const branch = String(branchState.branch || '').trim() - if (!branch || branchState.detached) { - throw new Error('Cannot resolve a pull request branch from detached HEAD.') - } - - const upstream = parseUpstreamRef(branchState.upstreamRef) - const trackedRemoteName = upstream?.remoteName ?? null - const trackedRemoteBranch = upstream?.branchName || branch - const trackedRemote = trackedRemoteName - ? branchState.remotes.find((remote) => remote.name === trackedRemoteName) || null - : null - const trackedRepositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(trackedRemote?.pushUrl || trackedRemote?.fetchUrl) - const trackedOwnerLogin = parseGitHubRepositoryOwnerLogin(trackedRepositoryNameWithOwner) - - const publishContext = await getGitHubPublishContext(projectPath).catch(() => null) - const upstreamFullName = publishContext?.upstream?.fullName || null - const isCrossRepository = Boolean( - trackedRepositoryNameWithOwner - && upstreamFullName - && trackedRepositoryNameWithOwner !== upstreamFullName - ) - - const headSelectors: string[] = [] - const ownerQualifiedSelector = isCrossRepository && trackedOwnerLogin - ? `${trackedOwnerLogin}:${trackedRemoteBranch}` - : trackedRemoteBranch - appendUnique(headSelectors, ownerQualifiedSelector) - appendUnique(headSelectors, trackedRemoteName ? `${trackedRemoteName}:${trackedRemoteBranch}` : null) - appendUnique(headSelectors, branch) - appendUnique(headSelectors, trackedRemoteBranch !== branch ? trackedRemoteBranch : null) - - return { - headBranch: trackedRemoteBranch, - headSelectors, - preferredHeadSelector: ownerQualifiedSelector, - remoteName: trackedRemoteName, - headRepositoryNameWithOwner: trackedRepositoryNameWithOwner, - headRepositoryOwnerLogin: trackedOwnerLogin, - isCrossRepository - } -} - -async function listPullRequests(cwd: string, headSelector: string, state: 'open' | 'all') { - const result = await runGh(cwd, [ - 'pr', - 'list', - '--head', - headSelector, - '--state', - state, - '--limit', - '20', - '--json', - GITHUB_PULL_REQUEST_JSON_FIELDS - ]) - return parsePullRequestList(result.stdout) -} - -async function findOpenPullRequest(cwd: string, headSelectors: string[]) { - for (const headSelector of headSelectors) { - const matches = await listPullRequests(cwd, headSelector, 'open').catch(() => []) - if (matches[0]) { - return matches[0] - } - } - return null -} - -async function findLatestPullRequest(cwd: string, headSelectors: string[]) { - const byNumber = new Map() - for (const headSelector of headSelectors) { - const matches = await listPullRequests(cwd, headSelector, 'all').catch(() => []) - for (const match of matches) { - byNumber.set(match.number, match) - } - } - - const parsed = Array.from(byNumber.values()).sort((left, right) => { - const leftTime = left.updatedAt ? Date.parse(left.updatedAt) : 0 - const rightTime = right.updatedAt ? Date.parse(right.updatedAt) : 0 - return rightTime - leftTime - }) - return parsed.find((entry) => entry.state === 'open') || parsed[0] || null -} - -async function resolveDefaultBranch(cwd: string) { - const result = await runGh(cwd, ['repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name']) - const branch = String(result.stdout || '').trim() - return branch || 'main' -} - -async function resolveBaseBranch(cwd: string, branch: string, upstreamRef: string | null, isCrossRepository: boolean, preferredBaseBranch?: string | null) { - const normalizedPreferred = String(preferredBaseBranch || '').trim() - if (normalizedPreferred) { - return normalizedPreferred - } - - const git = createGit(cwd) - const configured = String(await git.raw(['config', '--get', `branch.${branch}.gh-merge-base`]).catch(() => '')).trim() - if (configured) return configured - - const upstream = parseUpstreamRef(upstreamRef) - if (upstream && !isCrossRepository && upstream.branchName && upstream.branchName !== branch) { - return upstream.branchName - } - - return await resolveDefaultBranch(cwd).catch(() => 'main') -} - -async function ensureNoWorkingTreeChanges(branchState: BranchState) { - if (branchState.detached) { - throw new Error('Detached HEAD: checkout a branch before creating a PR.') - } - if (branchState.hasWorkingTreeChanges) { - throw new Error('Commit local changes before creating a PR.') - } - if (branchState.behind > 0 && branchState.ahead > 0) { - throw new Error('Branch has diverged from upstream. Rebase or merge before creating a PR.') - } - if (branchState.behind > 0) { - throw new Error('Branch is behind upstream. Pull or rebase before creating a PR.') - } -} - -async function pushCurrentBranchIfNeeded(projectPath: string, branchState: BranchState) { - const preferredRemote = getPreferredGitHubRemote(branchState.remotes) - if (!preferredRemote) { - throw new Error('Add a GitHub remote before creating a PR.') - } - - const branch = String(branchState.branch || '').trim() - if (!branch) { - throw new Error('Detached HEAD: checkout a branch before creating a PR.') - } - - if (!branchState.upstreamRef || branchState.ahead > 0) { - const upstream = parseUpstreamRef(branchState.upstreamRef) - await pushCommits(branchState.cwd, { - remoteName: upstream?.remoteName || preferredRemote.name, - branchName: branch - }) - } -} - -async function buildRangeContext(cwd: string, baseBranch: string) { - const git = createGit(cwd) - const [commitSummaryRaw, diffSummaryRaw, diffPatchRaw, commitCountRaw] = await Promise.all([ - git.raw(['log', '--reverse', '--format=- %s', `${baseBranch}..HEAD`]).catch(() => ''), - git.raw(['diff', '--stat', `${baseBranch}...HEAD`]).catch(() => ''), - git.raw(['diff', '--unified=3', `${baseBranch}...HEAD`]).catch(() => ''), - git.raw(['rev-list', '--count', `${baseBranch}..HEAD`]).catch(() => '0') - ]) - - const commitCount = Number.parseInt(String(commitCountRaw || '0').trim(), 10) - if (!Number.isFinite(commitCount) || commitCount <= 0) { - throw new Error('No local branch commits are available to include in a pull request.') - } - - return { - diff: [ - '## Commits', - String(commitSummaryRaw || '').trim() || '(no commit summary available)', - '', - '## Diff Summary', - String(diffSummaryRaw || '').trim() || '(no diff summary available)', - '', - '## Diff Patch', - String(diffPatchRaw || '').trim() || '(no diff patch available)' - ].join('\n'), - commitMessages: String(commitSummaryRaw || '') - .split(/\r?\n/) - .map((line) => line.replace(/^-\s*/, '').trim()) - .filter(Boolean) - } -} - -function buildFallbackPullRequestDraft(input: { - projectName: string - currentBranch: string - targetBranch: string - guideText?: string - commitMessages?: string[] -}) { - const normalizedProjectName = String(input.projectName || 'project').trim() || 'project' - const normalizedCurrentBranch = String(input.currentBranch || '').trim() - const normalizedTargetBranch = String(input.targetBranch || '').trim() || 'main' - const title = normalizedCurrentBranch - ? `Update ${normalizedProjectName} (${normalizedCurrentBranch} -> ${normalizedTargetBranch})` - : `Update ${normalizedProjectName}` - const uniqueMessages = Array.from(new Set((input.commitMessages || []).map((message) => String(message || '').trim()).filter(Boolean))).slice(0, 6) - const guideNote = String(input.guideText || '').trim() - - const bodyLines = [ - '## Summary', - `- Prepare a pull request for ${normalizedProjectName}.`, - `- Source branch: \`${normalizedCurrentBranch || 'current'}\` into \`${normalizedTargetBranch}\`.`, - '', - '## Changes', - ...(uniqueMessages.length > 0 - ? uniqueMessages.map((message) => `- ${message}`) - : ['- Review the branch diff and expand this summary before publishing.']), - '', - '## Testing', - '- Not yet validated.', - '', - '## Risks', - '- Review the generated title/body and confirm the target branch before publishing.' - ] - - if (guideNote) { - bodyLines.push('', '## Guide Notes', guideNote) - } - - return { - title, - body: bodyLines.join('\n') - } -} - -async function ensureDraft(cwd: string, branch: string, baseBranch: string, input: DevScopeCreatePullRequestInput): Promise { - const providedTitle = String(input.title || '').trim() - const providedBody = String(input.body || '').trim() - if (providedTitle && providedBody) { - return { - title: providedTitle, - body: providedBody, - source: 'provided' - } - } - - const rangeContext = await buildRangeContext(cwd, baseBranch) - const fallbackDraft = buildFallbackPullRequestDraft({ - projectName: input.projectName || 'Project', - currentBranch: branch, - targetBranch: baseBranch, - guideText: input.guideText, - commitMessages: rangeContext.commitMessages - }) - const provider = input.provider - ? { - provider: input.provider, - ...(input.apiKey?.trim() ? { apiKey: input.apiKey.trim() } : {}), - ...(input.model?.trim() ? { model: input.model.trim() } : {}) - } - : null - - if (!provider) { - return { - ...fallbackDraft, - source: 'fallback' - } - } - - const generateResult = await generateGitPullRequestDraftWithProvider({ - ...provider, - draftInput: { - projectName: input.projectName, - currentBranch: branch, - targetBranch: baseBranch, - scopeLabel: 'Current branch changes', - diff: rangeContext.diff, - guideText: input.guideText - } - }) - - if (!generateResult.success || !String(generateResult.title || '').trim() || !String(generateResult.body || '').trim()) { - return { - ...fallbackDraft, - source: 'fallback', - provider: provider.provider - } - } - - return { - title: String(generateResult.title || '').trim(), - body: String(generateResult.body || '').trim(), - source: 'ai', - provider: provider.provider - } -} - -async function createPullRequest(cwd: string, input: { - baseBranch: string - headSelector: string - title: string - body: string - draft: boolean -}) { - const bodyFile = join(tmpdir(), `devscope-pr-body-${process.pid}-${randomUUID()}.md`) - await writeFile(bodyFile, input.body, 'utf8') - try { - const result = await runGh(cwd, [ - 'pr', - 'create', - '--base', - input.baseBranch, - '--head', - input.headSelector, - '--title', - input.title, - '--body-file', - bodyFile, - ...(input.draft ? ['--draft'] : []) - ]) - return result.stdout.trim() - } finally { - await unlink(bodyFile).catch(() => undefined) - } -} - export async function getCurrentBranchPullRequest(projectPath: string): Promise { const branchState = await readBranchState(projectPath) if (branchState.detached || !branchState.branch) { @@ -629,6 +111,7 @@ export async function createOrOpenPullRequest( onProgress?.('Preparing PR...') const draft = await ensureDraft(branchState.cwd, branch, baseBranch, input) + onProgress?.('Creating PR...') const createStdout = await createPullRequest(branchState.cwd, { baseBranch, diff --git a/src/renderer/src/pages/project-details/ProjectDetailsPageView.tsx b/src/renderer/src/pages/project-details/ProjectDetailsPageView.tsx index 07c7c71..50c9607 100644 --- a/src/renderer/src/pages/project-details/ProjectDetailsPageView.tsx +++ b/src/renderer/src/pages/project-details/ProjectDetailsPageView.tsx @@ -3,7 +3,12 @@ import { ProjectDetailsContent } from './ProjectDetailsContent' import { ProjectDetailsErrorView, ProjectDetailsLoadingView } from './ProjectDetailsStateViews' import { ProjectDetailsOverlays } from './ProjectDetailsOverlays' import { ProjectDetailsTransientUi } from './ProjectDetailsTransientUi' -import { getParentFolderPath } from './projectDetailsPageHelpers' +import { + buildProjectDetailsContentProps, + buildProjectDetailsFileSystemModalProps, + buildProjectDetailsOverlayProps, + buildProjectDetailsTransientUiProps +} from './projectDetailsPageViewProps' export function ProjectDetailsPageView(props: any) { const { @@ -246,257 +251,10 @@ export function ProjectDetailsPageView(props: any) { maxWidth: isCondensedLayout ? undefined : '1600px' }} > - { - await loadProjectDetails() - }} - onRunScript={runScript} - scriptPredictions={scriptPredictions} - selectedCommit={selectedCommit} - commitDiff={commitDiff} - loadingDiff={loadingDiff} - setSelectedCommit={setSelectedCommit} - setCommitDiff={setCommitDiff} - showAuthorMismatch={showAuthorMismatch} - gitUser={gitUser} - repoOwner={repoOwner} - handleAuthorMismatchConfirm={handleAuthorMismatchConfirm} - setShowAuthorMismatch={setShowAuthorMismatch} - dontShowAuthorWarning={!settings.gitWarnOnAuthorMismatch} - setDontShowAuthorWarning={(value: boolean) => updateSettings({ gitWarnOnAuthorMismatch: !value })} - showInitModal={showInitModal} - setShowInitModal={setShowInitModal} - setInitStep={setInitStep} - initStep={initStep} - branchName={branchName} - setBranchName={setBranchName} - customBranchName={customBranchName} - setCustomBranchName={setCustomBranchName} - createGitignore={createGitignore} - setCreateGitignore={setCreateGitignore} - gitignoreTemplate={gitignoreTemplate} - setGitignoreTemplate={setGitignoreTemplate} - availableTemplates={availableTemplates} - availablePatterns={availablePatterns} - selectedPatterns={selectedPatterns} - setSelectedPatterns={setSelectedPatterns} - patternSearch={patternSearch} - setPatternSearch={setPatternSearch} - createInitialCommit={createInitialCommit} - setCreateInitialCommit={setCreateInitialCommit} - initialCommitMessage={initialCommitMessage} - setInitialCommitMessage={setInitialCommitMessage} - isInitializing={isInitializing} - handleInitGit={handleInitGit} - remoteUrl={remoteUrl} - setRemoteUrl={setRemoteUrl} - isAddingRemote={isAddingRemote} - handleAddRemote={handleAddRemote} - handleSkipRemote={handleSkipRemote} - /> - openTerminal({ displayName: projectTerminalLabel, id: 'main', category: 'project' }, projectRootPath)} - scriptCount={Object.keys(project.scripts || {}).length} - dependencyCount={Object.keys(project.dependencies || {}).length + Object.keys(project.devDependencies || {}).length} - installedIdes={installedIdes} - loadingInstalledIdes={loadingInstalledIdes} - openingIdeId={openingIdeId} - onOpenProjectInIde={handleOpenProjectInIde} - handleCopyPath={handleCopyPath} - copiedPath={copiedPath} - handleOpenInExplorer={handleOpenInExplorer} - goBack={goBack} - activeTab={activeTab} - setActiveTab={setActiveTab} - fileTree={fileTree} - loadingGit={loadingGit} - loadingGitHistory={loadingGitHistory} - loadingFiles={loadingFiles} - changedFiles={changedFiles} - stagedFiles={stagedFiles} - unstagedFiles={unstagedFiles} - unpushedCommits={unpushedCommits} - onBrowseFolder={() => { - const encodedPath = encodeURIComponent(projectRootPath) - navigate(`/folder-browse/${encodedPath}`) - }} - onShowScriptsModal={() => setShowScriptsModal(true)} - onShowDependenciesModal={() => setShowDependenciesModal(true)} - loadProjectDetails={async () => { - await Promise.all([loadProjectDetails(), loadInstalledIdes()]) - }} - refreshFileTree={props.refreshFileTree} - onToggleAllFolders={handleToggleAllFolders} - readmeContentRef={readmeContentRef} - readmeExpanded={readmeExpanded} - readmeNeedsExpand={readmeNeedsExpand} - setReadmeExpanded={setReadmeExpanded} - fileSearch={fileSearch} - setFileSearch={setFileSearch} - setIsExpandingFolders={setIsExpandingFolders} - expandedFolders={expandedFolders} - setExpandedFolders={setExpandedFolders} - loadingFolderPaths={loadingFolderPaths} - allFolderPathsSet={allFolderPathsSet} - isExpandingFolders={isExpandingFolders} - showHidden={showHidden} - setShowHidden={setShowHidden} - sortBy={sortBy} - setSortBy={setSortBy} - sortAsc={sortAsc} - setSortAsc={setSortAsc} - visibleFileList={visibleFileList} - openPreview={openPreview} - onFileTreeOpen={handleFileTreeOpen} - onToggleFolder={handleToggleFolder} - onFileTreeOpenWith={handleFileTreeOpenWith} - onFileTreeOpenInExplorer={handleFileTreeOpenInExplorer} - onFileTreeCopyPath={handleFileTreeCopyPath} - onFileTreeCopy={handleFileTreeCopy} - onFileTreeRename={handleFileTreeRename} - onFileTreeDelete={handleFileTreeDelete} - onFileTreePaste={handleFileTreePaste} - onFileTreeMove={handleFileTreeMove} - onFileTreeCreateFile={handleFileTreeCreateFile} - onFileTreeCreateFolder={handleFileTreeCreateFolder} - hasFileClipboardItem={Boolean(fileClipboardItem)} - gitUser={gitUser} - repoOwner={repoOwner} - gitError={gitError} - gitView={gitView} - setGitView={setGitView} - refreshGitData={refreshGitData} - isGitRepo={isGitRepo} - setShowInitModal={setShowInitModal} - currentBranch={currentBranch} - targetBranch={targetBranch} - setTargetBranch={setTargetBranch} - branches={branches} - isSwitchingBranch={isSwitchingBranch} - handleSwitchBranch={handleSwitchBranch} - commitMessage={commitMessage} - setCommitMessage={setCommitMessage} - handleGenerateCommitMessage={handleGenerateCommitMessage} - isGeneratingCommitMessage={isGeneratingCommitMessage} - isCommitting={isCommitting} - isStackedActionRunning={isStackedActionRunning} - settings={settings} - handleCommit={handleCommit} - handleCommitPushAndCreatePullRequest={handleCommitPushAndCreatePullRequest} - handleDangerouslyStageCommitPushAndCreatePullRequest={handleDangerouslyStageCommitPushAndCreatePullRequest} - handleStageFile={handleStageFile} - handleUnstageFile={handleUnstageFile} - handleStageAll={handleStageAll} - handleUnstageAll={handleUnstageAll} - handleDiscardUnstagedFile={handleDiscardUnstagedFile} - handleDiscardUnstagedAll={handleDiscardUnstagedAll} - ensureStatsForPaths={ensureWorkingChangeStats} - hasRemote={hasRemote} - gitSyncStatus={gitSyncStatus} - incomingCommits={incomingCommits} - setInitStep={setInitStep} - handleFetch={handleFetch} - handlePush={handlePush} - handlePull={handlePull} - isPushing={isPushing} - isFetching={isFetching} - isPulling={isPulling} - lastFetched={lastFetched} - lastPulled={lastPulled} - gitHistory={gitHistory} - gitHistoryTotalCount={gitHistoryTotalCount} - historyHasMore={historyHasMore} - loadingMoreHistory={loadingMoreHistory} - loadMoreGitHistory={loadMoreGitHistory} - remotes={remotes} - tags={tags} - stashes={stashes} - decodedPath={decodedPath} - changesPage={changesPage} - setChangesPage={setChangesPage} - ITEMS_PER_PAGE={ITEMS_PER_PAGE} - handleCommitClick={handleCommitClick} - unpushedPage={unpushedPage} - setUnpushedPage={setUnpushedPage} - pullsPage={pullsPage} - setPullsPage={setPullsPage} - COMMITS_PER_PAGE={COMMITS_PER_PAGE} - commitPage={commitPage} - setCommitPage={setCommitPage} - scriptPredictions={scriptPredictions} - scriptIntentContext={scriptIntentContext} - runScript={runScript} - setShowDependenciesModal={setShowDependenciesModal} - showToast={showToast} - /> - { - await Promise.all([refreshVisibleFileTree(getParentFolderPath(previewFile?.path || '') || undefined), refreshGitData()]) - }} - closePreview={closePreview} - toast={toast} - navigate={navigate} - setToast={setToast} - /> - + + + +
) } diff --git a/src/renderer/src/pages/project-details/WorkingChangesView.tsx b/src/renderer/src/pages/project-details/WorkingChangesView.tsx index 2cdef6e..a146850 100644 --- a/src/renderer/src/pages/project-details/WorkingChangesView.tsx +++ b/src/renderer/src/pages/project-details/WorkingChangesView.tsx @@ -4,45 +4,11 @@ import { resolvePreferredGitTextProvider } from '@/lib/gitAi' import { getProjectPullRequestConfig } from '@/lib/pullRequestWorkflow' import { FileDiffDetailModal } from './FileDiffDetailModal' import { ConfirmModal } from '@/components/ui/ConfirmModal' -import { Checkbox, Input } from '@/components/ui/FormControls' import { WorkingChangesSection } from './WorkingChangesSection' import type { DiffMode, WorkingChangeItem } from './workingChangesTypes' import { getDiffCounts, getDiffKey } from './workingChangesUtils' - -function slugifyBranchSegment(value: string) { - return String(value || '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 40) -} - -function buildBranchSeed(commitMessage: string) { - const fromCommit = slugifyBranchSegment(commitMessage) - if (fromCommit) return fromCommit - - const now = new Date() - const yyyy = now.getFullYear() - const mm = String(now.getMonth() + 1).padStart(2, '0') - const dd = String(now.getDate()).padStart(2, '0') - const hh = String(now.getHours()).padStart(2, '0') - const min = String(now.getMinutes()).padStart(2, '0') - return `update-${yyyy}${mm}${dd}-${hh}${min}` -} - -function buildProposedBranchName(commitMessage: string, branchNames: string[]) { - const existing = new Set(branchNames.map((name) => String(name || '').trim().toLowerCase()).filter(Boolean)) - const seed = buildBranchSeed(commitMessage) - const base = `feature/${seed}` - if (!existing.has(base.toLowerCase())) return base - - for (let index = 2; index < 100; index += 1) { - const next = `${base}-${index}` - if (!existing.has(next.toLowerCase())) return next - } - - return `${base}-${Date.now()}` -} +import { BranchGuardModal, buildProposedBranchName } from './workingChangesBranchGuard' +import { useStackedTaskStatus } from './useStackedTaskStatus' export function WorkingChangesView({ stagedFiles, @@ -108,7 +74,6 @@ export function WorkingChangesView({ const [isDiffModalLoading, setIsDiffModalLoading] = useState(false) const [revertTarget, setRevertTarget] = useState<{ file?: WorkingChangeItem; scope: 'file' | 'all' } | null>(null) const [showDangerMenu, setShowDangerMenu] = useState(false) - const [stackedTaskStatusText, setStackedTaskStatusText] = useState('') const [showBranchGuardModal, setShowBranchGuardModal] = useState(false) const [branchGuardMode, setBranchGuardMode] = useState<'safe' | 'danger'>('safe') const [proposedBranchName, setProposedBranchName] = useState('') @@ -121,6 +86,7 @@ export function WorkingChangesView({ const hasProviderForAutoCommit = Boolean(resolvedProvider) const hasOnlyStagedChanges = stagedFiles.length > 0 && unstagedFiles.length === 0 const hasAnyChanges = stagedFiles.length > 0 || unstagedFiles.length > 0 + const stackedTaskStatusText = useStackedTaskStatus(projectPath, isStackedActionRunning) const runStackedActionByMode = async (mode: 'safe' | 'danger') => { if (mode === 'danger') { @@ -316,61 +282,6 @@ export function WorkingChangesView({ } }, [showDangerMenu]) - useEffect(() => { - const normalizedProjectPath = String(projectPath || '').trim().toLowerCase() - if (!normalizedProjectPath) return - - const applyTask = (task: any) => { - if (!task || task.type !== 'git.stacked') return false - if (String(task.projectPath || '').trim().toLowerCase() !== normalizedProjectPath) return false - if (task.status !== 'running') { - setStackedTaskStatusText('') - return true - } - - const latestLog = Array.isArray(task.logs) - ? [...task.logs] - .reverse() - .map((entry) => String(entry?.message || '').trim()) - .find((message) => message && !/^Target branch:|^Auto-stage all:|^Commit message:/i.test(message)) - : '' - setStackedTaskStatusText(latestLog || 'Starting...') - return true - } - - void window.devscope.listActiveTasks?.(projectPath).then((result) => { - if (!result?.success || !Array.isArray(result.tasks)) return - const matchingTask = result.tasks.find((task: any) => applyTask(task)) - if (!matchingTask) { - setStackedTaskStatusText('') - } - }).catch(() => undefined) - - const unsubscribe = window.devscope.onTaskEvent?.((event) => { - if (event.type === 'remove') { - void window.devscope.listActiveTasks?.(projectPath).then((result) => { - if (!result?.success || !Array.isArray(result.tasks)) { - setStackedTaskStatusText('') - return - } - const matchingTask = result.tasks.find((task: any) => applyTask(task)) - if (!matchingTask) { - setStackedTaskStatusText('') - } - }).catch(() => { - setStackedTaskStatusText('') - }) - return - } - if (event.type !== 'upsert' || !event.task) return - applyTask(event.task) - }) - - return () => { - unsubscribe?.() - } - }, [isStackedActionRunning, projectPath]) - return ( <>
@@ -502,78 +413,21 @@ export function WorkingChangesView({ />
- {showBranchGuardModal ? ( -
{ - if (isCreatingBranchForStackedFlow) return - setShowBranchGuardModal(false) - }} - > -
event.stopPropagation()} - > -
-
- Same branch -
-
-

Create a branch first?

-

- Current branch and target branch are both {currentBranch}. -

-
-
-
Proposed branch
- { - setProposedBranchName(value) - if (branchGuardError) setBranchGuardError('') - }} - placeholder="feature/my-change" - disabled={isCreatingBranchForStackedFlow} - /> -
-
- -
- {branchGuardError ? ( -
- {branchGuardError} -
- ) : null} -
-
- - -
-
-
- ) : null} + { + setProposedBranchName(value) + if (branchGuardError) setBranchGuardError('') + }} + autoCreateNextTime={autoCreateNextTime} + setAutoCreateNextTime={setAutoCreateNextTime} + branchGuardError={branchGuardError} + onCancel={() => setShowBranchGuardModal(false)} + onConfirm={() => { void confirmBranchGuard() }} + /> { - if (!params.decodedPath || !params.commitMessage.trim()) return - - params.setIsCommitting(true) - try { - if (params.stagedFiles.length === 0) { - throw new Error('No staged files to commit') - } - - const commitResult = await window.devscope.createCommit(params.decodedPath, params.commitMessage) - if (!commitResult?.success) { - throw new Error(commitResult?.error || 'Failed to create commit') - } - - params.setCommitMessage('') - await params.refreshGitData(false, { mode: 'unpushed' }) - void params.refreshGitData(false, { quiet: true, mode: 'full' }) - params.showToast('Commit created successfully.') - } catch (err: any) { - params.showToast(`Failed to commit: ${err.message}`, undefined, undefined, 'error') - } finally { - params.setIsCommitting(false) - } - } - - const handleCommitClick = async (commit: GitCommit) => { - if (!params.decodedPath) return - - params.setSelectedCommit(commit) - params.setLoadingDiff(true) - params.setCommitDiff('') - - try { - const result = await window.devscope.getCommitDiff(params.decodedPath, commit.hash) - if (result.success) { - params.setCommitDiff(result.diff) - } else { - params.setCommitDiff(`Error loading diff: ${result.error}`) - } - } catch (err: any) { - params.setCommitDiff(`Error: ${err.message}`) - } finally { - params.setLoadingDiff(false) - } - } - - const handleCommit = async () => { - if (!params.decodedPath || !params.commitMessage.trim() || params.stagedFiles.length === 0) return - - const shouldWarn = params.settings.gitWarnOnAuthorMismatch !== false - if (shouldWarn && params.gitUser && params.repoOwner && params.gitUser.name !== params.repoOwner) { - params.setShowAuthorMismatch(true) - return - } - - await performCommit() - } - const { - handleStageFile, - handleUnstageFile, - handleStageAll, - handleUnstageAll, - handleDiscardUnstagedFile, - handleDiscardUnstagedAll - } = createGitWorkingTreeActions(params, bulkActionScope, applyOptimisticDetails) - - const handleCommitPushAndCreatePullRequest = async () => { - if (!params.decodedPath || params.stagedFiles.length === 0) return - - const provider = resolvePreferredPullRequestProvider(params.settings) - if (!params.commitMessage.trim() && !provider) { - params.showToast( - 'Enter a commit message or configure an AI provider first.', - 'Open AI Settings', - '/settings/ai', - 'info' - ) - return - } - - params.setIsStackedActionRunning(true) - try { - const prConfig = getProjectPullRequestConfig(params.settings, params.decodedPath) - const guideText = await resolvePullRequestGuideText( - params.settings, - params.decodedPath, - prConfig.guideSource, - prConfig.guide - ) - const result = await window.devscope.commitPushAndCreatePullRequest(params.decodedPath, { - projectName: params.projectName, - commitMessage: params.commitMessage.trim() || undefined, - targetBranch: prConfig.targetBranch, - draft: prConfig.draft, - guideText, - provider: provider?.provider, - apiKey: provider?.apiKey, - model: provider?.model - }) - - if (!result?.success) { - throw new Error(result?.error || 'Failed to commit, push, and create the pull request.') - } - - params.setCommitMessage('') - await params.refreshGitData(false, { mode: 'unpushed' }) - void params.refreshGitData(false, { quiet: true, mode: 'full' }) - window.open(result.pullRequest.url, '_blank', 'noopener,noreferrer') - const prNumberLabel = result.pullRequest.number > 0 ? ` #${result.pullRequest.number}` : '' - params.showToast( - result.status === 'opened_existing' - ? `Committed changes and opened PR${prNumberLabel}.` - : `Committed changes and created PR${prNumberLabel}.` - ) - } catch (err: any) { - params.showToast(`Failed to commit, push & create PR: ${err.message || 'Unknown error'}`, undefined, undefined, 'error') - } finally { - params.setIsStackedActionRunning(false) - } - } - - const handleDangerouslyStageCommitPushAndCreatePullRequest = async () => { - if (!params.decodedPath || (params.stagedFiles.length === 0 && params.unstagedFiles.length === 0)) return - - const provider = resolvePreferredPullRequestProvider(params.settings) - if (!params.commitMessage.trim() && !provider) { - params.showToast( - 'Enter a commit message or configure an AI provider first.', - 'Open AI Settings', - '/settings/ai', - 'info' - ) - return - } - - params.setIsStackedActionRunning(true) - try { - const prConfig = getProjectPullRequestConfig(params.settings, params.decodedPath) - const guideText = await resolvePullRequestGuideText( - params.settings, - params.decodedPath, - prConfig.guideSource, - prConfig.guide - ) - const result = await window.devscope.commitPushAndCreatePullRequest(params.decodedPath, { - projectName: params.projectName, - commitMessage: params.commitMessage.trim() || undefined, - targetBranch: prConfig.targetBranch, - draft: prConfig.draft, - guideText, - provider: provider?.provider, - apiKey: provider?.apiKey, - model: provider?.model, - autoStageAll: true, - stageScope: params.settings.gitBulkActionScope === 'project' ? 'project' : 'repo' - }) - - if (!result?.success) { - throw new Error(result?.error || 'Failed to stage, commit, push, and create the pull request.') - } - - params.setCommitMessage('') - await params.refreshGitData(false, { mode: 'unpushed' }) - void params.refreshGitData(false, { quiet: true, mode: 'full' }) - window.open(result.pullRequest.url, '_blank', 'noopener,noreferrer') - const prNumberLabel = result.pullRequest.number > 0 ? ` #${result.pullRequest.number}` : '' - params.showToast( - result.status === 'opened_existing' - ? `Staged all changes, committed, and opened PR${prNumberLabel}.` - : `Staged all changes, committed, and created PR${prNumberLabel}.` - ) - } catch (err: any) { - params.showToast(`Failed to dangerously stage, commit, push & create PR: ${err.message || 'Unknown error'}`, undefined, undefined, 'error') - } finally { - params.setIsStackedActionRunning(false) - } - } - - const handleGenerateCommitMessage = async () => { - if (!params.decodedPath) return - if (params.stagedFiles.length === 0) { - params.showToast('Stage files first to generate an AI commit message.', undefined, undefined, 'info') - return - } - - const selectedProvider = resolvePreferredGitTextProvider(params.settings) - - if (!selectedProvider) { - params.showToast( - 'No AI provider is configured for commit generation.', - 'Open AI Settings', - '/settings/ai' - ) - return - } - - params.setIsGeneratingCommitMessage(true) - try { - const stagedDiffResult = await window.devscope.getWorkingDiff(params.decodedPath, undefined, 'staged') - if (!stagedDiffResult?.success) { - throw new Error(stagedDiffResult?.error || 'Failed to read staged changes') - } - - const stagedDiff = String(stagedDiffResult.diff || '').trim() - if (!stagedDiff || stagedDiff === 'No changes') { - throw new Error('No staged diff available to generate a commit message.') - } - - const generateResult = await window.devscope.generateCommitMessage( - selectedProvider.provider, - selectedProvider.apiKey || '', - stagedDiff, - selectedProvider.model - ) - - if (!generateResult?.success) { - throw new Error(generateResult?.error || 'Failed to generate commit message') - } - if (!generateResult.message) { - throw new Error('Failed to generate commit message') - } - - params.setCommitMessage(generateResult.message.trim()) - params.showToast('Commit message generated successfully.') - } catch (err: any) { - params.showToast(`AI generation failed: ${err.message || 'Unknown error'}`, undefined, undefined, 'error') - } finally { - params.setIsGeneratingCommitMessage(false) - } - } - - const handlePush = async ( - publishPlanOverride?: GitPublishPlan, - options?: { commitHash?: string; commitMessage?: string }, - pushOptions?: { remoteName?: string; branchName?: string } - ) => { - if (!params.decodedPath) return - const targetCommitHash = String(options?.commitHash || '').trim() - const publishPlan = publishPlanOverride ?? buildGitPublishPlan({ - currentBranch: params.currentBranch, - branches: params.branches, - remotes: params.remotes, - unpushedCommits: params.unpushedCommits, - selectedCommitHash: targetCommitHash || undefined, - intent: targetCommitHash ? 'push-range' : 'push-all' - }) - - params.setIsPushing(true) - try { - let retriedAfterTransientError = false - const runPush = async () => { - const pushResult = targetCommitHash - ? await window.devscope.pushSingleCommit(params.decodedPath, targetCommitHash, pushOptions) - : await window.devscope.pushCommits(params.decodedPath, pushOptions) - if (!pushResult?.success) { - throw new Error(pushResult?.error || (targetCommitHash ? 'Failed to push selected commit' : 'Failed to push commits')) - } - } - - try { - await runPush() - } catch (initialError: any) { - const initialMessage = String(initialError?.message || initialError || '') - if (!isTransientPushError(initialMessage)) { - throw initialError - } - - params.showToast('Push interrupted by network timeout. Retrying once...') - await new Promise((resolve) => setTimeout(resolve, 1200)) - await runPush() - retriedAfterTransientError = true - params.showToast('Push succeeded after retry.') - } - - await params.refreshGitData(false, { mode: 'unpushed' }) - if (!retriedAfterTransientError) { - params.showToast(describeGitPublishSuccess( - publishPlan, - { commitHash: targetCommitHash || undefined, currentBranch: params.currentBranch } - )) - } - } catch (err: any) { - const rawMessage = String(err?.message || err || 'Failed to push commits') - params.showToast(`Failed to push: ${summarizePushError(rawMessage)}`, undefined, undefined, 'error') - } finally { - params.setIsPushing(false) - } - } - - const handleSwitchBranch = async () => { - if (!params.decodedPath || !params.targetBranch || params.targetBranch === params.currentBranch) return - - params.setIsSwitchingBranch(true) - try { - const checkoutResult = await window.devscope.checkoutBranch(params.decodedPath, params.targetBranch, { - autoStash: true, - autoCleanupLock: true - }) - if (!checkoutResult?.success) { - throw new Error(checkoutResult?.error || 'Failed to switch branch') - } - - await params.refreshGitData(true) - if (checkoutResult?.cleanedLock && checkoutResult?.stashed) { - params.showToast(`Recovered stale Git lock and auto-stashed changes (${checkoutResult.stashRef || 'stash@{0}'}).`) - } else if (checkoutResult?.cleanedLock) { - params.showToast('Recovered stale Git lock and switched branch.') - } else if (checkoutResult?.stashed) { - params.showToast(`Switched branch after auto-stashing local changes (${checkoutResult.stashRef || 'stash@{0}'}).`) - } - } catch (err: any) { - params.showToast(`Failed to switch branch: ${err.message}`, undefined, undefined, 'error') - } finally { - params.setIsSwitchingBranch(false) - } - } - - const handleInitGit = async () => { - if (!params.decodedPath) return - - params.setIsInitializing(true) - try { - let gitignoreContent: string | undefined - if (params.createGitignore && params.gitignoreTemplate) { - if (params.gitignoreTemplate === 'Custom') { - const result = await window.devscope.generateCustomGitignoreContent(Array.from(params.selectedPatterns)) - if (result.success) { - gitignoreContent = result.content - } - } else { - const result = await window.devscope.generateGitignoreContent(params.gitignoreTemplate) - if (result.success) { - gitignoreContent = result.content - } - } - } - - const finalBranchName = params.branchName === 'custom' ? params.customBranchName : params.branchName - - const initResult = await window.devscope.initGitRepo( - params.decodedPath, - finalBranchName, - params.createGitignore, - gitignoreContent - ) - - if (!initResult.success) { - params.showToast(`Failed to initialize git: ${initResult.error}`, undefined, undefined, 'error') - params.setIsInitializing(false) - return - } - - params.setIsGitRepo(true) - - if (params.createInitialCommit) { - const commitResult = await window.devscope.createInitialCommit( - params.decodedPath, - params.initialCommitMessage - ) - - if (!commitResult.success) { - params.showToast( - `Git initialized but failed to create initial commit: ${commitResult.error}`, - undefined, - undefined, - 'error' - ) - } - } - - await params.refreshGitData(true) - params.setInitStep('remote') - } catch (err: any) { - params.showToast(`Failed to initialize git: ${err.message}`, undefined, undefined, 'error') - } finally { - params.setIsInitializing(false) - } - } - - const handleAddRemote = async () => { - if (!params.decodedPath || !params.remoteUrl.trim()) return - - params.setIsAddingRemote(true) - try { - const result = await window.devscope.addRemoteOrigin(params.decodedPath, params.remoteUrl) - - if (!result.success) { - params.showToast(`Failed to add remote: ${result.error}`, undefined, undefined, 'error') - params.setIsAddingRemote(false) - return - } - - params.setShowInitModal(false) - params.setInitStep('config') - params.setRemoteUrl('') - params.setIsGitRepo(true) - params.setHasRemote(true) - - await params.refreshGitData(true) - } catch (err: any) { - params.showToast(`Failed to add remote: ${err.message}`, undefined, undefined, 'error') - } finally { - params.setIsAddingRemote(false) - } - } - - const handleSkipRemote = async () => { - params.setShowInitModal(false) - params.setInitStep('config') - params.setRemoteUrl('') - params.setIsGitRepo(true) - params.setHasRemote(false) - - await params.refreshGitData(true) - } - - const handleAuthorMismatchConfirm = () => { - params.setShowAuthorMismatch(false) - void performCommit() - } - - const handleFetch = async (remoteName?: string, successLabel?: string) => { - if (!params.decodedPath) return - - params.setIsFetching(true) - try { - const result = await window.devscope.fetchUpdates(params.decodedPath, remoteName) - if (!result?.success) { - throw new Error(result?.error || 'Failed to fetch updates') - } - - params.setLastFetched(Date.now()) - await params.refreshGitData(false, { quiet: true, mode: 'pulls' }) - params.showToast(successLabel || (remoteName ? `Fetched ${remoteName}.` : 'Fetched remote updates.')) - } catch (err: any) { - params.showToast(`Failed to fetch: ${err.message}`, undefined, undefined, 'error') - } finally { - params.setIsFetching(false) - } - } - - const handlePull = async (options?: { remoteName?: string; branchName?: string; pushRemoteName?: string; successLabel?: string }) => { - if (!params.decodedPath) return - - params.setIsPulling(true) - try { - const result = await window.devscope.pullUpdates(params.decodedPath, options) - if (!result?.success) { - throw new Error(result?.error || 'Failed to pull updates') - } - - params.setLastPulled(Date.now()) - await params.refreshGitData(true, { mode: 'full' }) - params.showToast(options?.successLabel || 'Pulled remote updates successfully.') - } catch (err: any) { - params.showToast(`Failed to pull: ${err.message}`, undefined, undefined, 'error') - } finally { - params.setIsPulling(false) - } - } - - const handleOpenInExplorer = async () => { - if (params.projectPath) { - try { - const result = await window.devscope.openInExplorer?.(params.projectPath) - if (result && !result.success) { - params.showToast(`Failed to open folder: ${result.error}`, undefined, undefined, 'error') - } - } catch (err) { - params.showToast(`Failed to invoke openInExplorer: ${err}`, undefined, undefined, 'error') - } - } - } - return { - handleCommitClick, - handleCommit, - handleCommitPushAndCreatePullRequest, - handleDangerouslyStageCommitPushAndCreatePullRequest, - handleGenerateCommitMessage, - handleFetch, - handlePush, - handlePull, - handleStageFile, - handleUnstageFile, - handleStageAll, - handleUnstageAll, - handleDiscardUnstagedFile, - handleDiscardUnstagedAll, - handleSwitchBranch, - handleInitGit, - handleAddRemote, - handleSkipRemote, - handleAuthorMismatchConfirm, - handleOpenInExplorer + ...createGitWorkingTreeActions(params, bulkActionScope, applyOptimisticDetails), + ...createGitCommitAndPullRequestActions(params), + ...createGitSyncActions(params), + ...createGitProjectSetupActions(params) } } diff --git a/src/renderer/src/pages/project-details/gitCommitAndPullRequestActions.ts b/src/renderer/src/pages/project-details/gitCommitAndPullRequestActions.ts new file mode 100644 index 0000000..d2b228a --- /dev/null +++ b/src/renderer/src/pages/project-details/gitCommitAndPullRequestActions.ts @@ -0,0 +1,211 @@ +import { resolvePreferredGitTextProvider } from '@/lib/gitAi' +import { + getProjectPullRequestConfig, + resolvePreferredPullRequestProvider, + resolvePullRequestGuideText +} from '@/lib/pullRequestWorkflow' +import type { DevScopeCommitPushPullRequestInput } from '@shared/contracts/devscope-api' +import type { GitCommit } from './types' +import type { GitActionParams } from './gitActionTypes' + +export function createGitCommitAndPullRequestActions(params: GitActionParams) { + const performCommit = async () => { + if (!params.decodedPath || !params.commitMessage.trim()) return + + params.setIsCommitting(true) + try { + if (params.stagedFiles.length === 0) { + throw new Error('No staged files to commit') + } + + const commitResult = await window.devscope.createCommit(params.decodedPath, params.commitMessage) + if (!commitResult?.success) { + throw new Error(commitResult?.error || 'Failed to create commit') + } + + params.setCommitMessage('') + await params.refreshGitData(false, { mode: 'unpushed' }) + void params.refreshGitData(false, { quiet: true, mode: 'full' }) + params.showToast('Commit created successfully.') + } catch (err: any) { + params.showToast(`Failed to commit: ${err.message}`, undefined, undefined, 'error') + } finally { + params.setIsCommitting(false) + } + } + + const handleCommitClick = async (commit: GitCommit) => { + if (!params.decodedPath) return + + params.setSelectedCommit(commit) + params.setLoadingDiff(true) + params.setCommitDiff('') + + try { + const result = await window.devscope.getCommitDiff(params.decodedPath, commit.hash) + if (result.success) { + params.setCommitDiff(result.diff) + } else { + params.setCommitDiff(`Error loading diff: ${result.error}`) + } + } catch (err: any) { + params.setCommitDiff(`Error: ${err.message}`) + } finally { + params.setLoadingDiff(false) + } + } + + const handleCommit = async () => { + if (!params.decodedPath || !params.commitMessage.trim() || params.stagedFiles.length === 0) return + + const shouldWarn = params.settings.gitWarnOnAuthorMismatch !== false + if (shouldWarn && params.gitUser && params.repoOwner && params.gitUser.name !== params.repoOwner) { + params.setShowAuthorMismatch(true) + return + } + + await performCommit() + } + + const buildPullRequestInput = async (autoStageAll: boolean) => { + const prConfig = getProjectPullRequestConfig(params.settings, params.decodedPath) + const guideText = await resolvePullRequestGuideText( + params.settings, + params.decodedPath, + prConfig.guideSource, + prConfig.guide + ) + const provider = resolvePreferredPullRequestProvider(params.settings) + const stageScope: DevScopeCommitPushPullRequestInput['stageScope'] = + params.settings.gitBulkActionScope === 'project' ? 'project' : 'repo' + + return { + provider, + prConfig, + request: { + projectName: params.projectName, + commitMessage: params.commitMessage.trim() || undefined, + targetBranch: prConfig.targetBranch, + draft: prConfig.draft, + guideText, + provider: provider?.provider, + apiKey: provider?.apiKey, + model: provider?.model, + ...(autoStageAll + ? { autoStageAll: true, stageScope } + : {}) + } + } + } + + const runStackedPullRequestFlow = async (autoStageAll: boolean) => { + if (!params.decodedPath || (autoStageAll ? params.stagedFiles.length === 0 && params.unstagedFiles.length === 0 : params.stagedFiles.length === 0)) { + return + } + + const { provider, request } = await buildPullRequestInput(autoStageAll) + if (!params.commitMessage.trim() && !provider) { + params.showToast( + 'Enter a commit message or configure an AI provider first.', + 'Open AI Settings', + '/settings/ai', + 'info' + ) + return + } + + params.setIsStackedActionRunning(true) + try { + const result = await window.devscope.commitPushAndCreatePullRequest(params.decodedPath, request) + if (!result?.success) { + throw new Error(result?.error || `Failed to ${autoStageAll ? 'stage, commit, push, and create the pull request.' : 'commit, push, and create the pull request.'}`) + } + + params.setCommitMessage('') + await params.refreshGitData(false, { mode: 'unpushed' }) + void params.refreshGitData(false, { quiet: true, mode: 'full' }) + window.open(result.pullRequest.url, '_blank', 'noopener,noreferrer') + const prNumberLabel = result.pullRequest.number > 0 ? ` #${result.pullRequest.number}` : '' + params.showToast( + autoStageAll + ? result.status === 'opened_existing' + ? `Staged all changes, committed, and opened PR${prNumberLabel}.` + : `Staged all changes, committed, and created PR${prNumberLabel}.` + : result.status === 'opened_existing' + ? `Committed changes and opened PR${prNumberLabel}.` + : `Committed changes and created PR${prNumberLabel}.` + ) + } catch (err: any) { + params.showToast( + autoStageAll + ? `Failed to dangerously stage, commit, push & create PR: ${err.message || 'Unknown error'}` + : `Failed to commit, push & create PR: ${err.message || 'Unknown error'}`, + undefined, + undefined, + 'error' + ) + } finally { + params.setIsStackedActionRunning(false) + } + } + + const handleGenerateCommitMessage = async () => { + if (!params.decodedPath) return + if (params.stagedFiles.length === 0) { + params.showToast('Stage files first to generate an AI commit message.', undefined, undefined, 'info') + return + } + + const selectedProvider = resolvePreferredGitTextProvider(params.settings) + if (!selectedProvider) { + params.showToast('No AI provider is configured for commit generation.', 'Open AI Settings', '/settings/ai') + return + } + + params.setIsGeneratingCommitMessage(true) + try { + const stagedDiffResult = await window.devscope.getWorkingDiff(params.decodedPath, undefined, 'staged') + if (!stagedDiffResult?.success) { + throw new Error(stagedDiffResult?.error || 'Failed to read staged changes') + } + + const stagedDiff = String(stagedDiffResult.diff || '').trim() + if (!stagedDiff || stagedDiff === 'No changes') { + throw new Error('No staged diff available to generate a commit message.') + } + + const generateResult = await window.devscope.generateCommitMessage( + selectedProvider.provider, + selectedProvider.apiKey || '', + stagedDiff, + selectedProvider.model + ) + + if (!generateResult?.success) { + throw new Error(generateResult.error || 'Failed to generate commit message') + } + if (!generateResult.message) { + throw new Error('Failed to generate commit message') + } + + params.setCommitMessage(generateResult.message.trim()) + params.showToast('Commit message generated successfully.') + } catch (err: any) { + params.showToast(`AI generation failed: ${err.message || 'Unknown error'}`, undefined, undefined, 'error') + } finally { + params.setIsGeneratingCommitMessage(false) + } + } + + return { + handleCommitClick, + handleCommit, + handleCommitPushAndCreatePullRequest: async () => runStackedPullRequestFlow(false), + handleDangerouslyStageCommitPushAndCreatePullRequest: async () => runStackedPullRequestFlow(true), + handleGenerateCommitMessage, + handleAuthorMismatchConfirm: () => { + params.setShowAuthorMismatch(false) + void performCommit() + } + } +} diff --git a/src/renderer/src/pages/project-details/gitProjectSetupActions.ts b/src/renderer/src/pages/project-details/gitProjectSetupActions.ts new file mode 100644 index 0000000..b66b618 --- /dev/null +++ b/src/renderer/src/pages/project-details/gitProjectSetupActions.ts @@ -0,0 +1,105 @@ +import type { GitActionParams } from './gitActionTypes' + +export function createGitProjectSetupActions(params: GitActionParams) { + return { + handleInitGit: async () => { + if (!params.decodedPath) return + + params.setIsInitializing(true) + try { + let gitignoreContent: string | undefined + if (params.createGitignore && params.gitignoreTemplate) { + if (params.gitignoreTemplate === 'Custom') { + const result = await window.devscope.generateCustomGitignoreContent(Array.from(params.selectedPatterns)) + if (result.success) { + gitignoreContent = result.content + } + } else { + const result = await window.devscope.generateGitignoreContent(params.gitignoreTemplate) + if (result.success) { + gitignoreContent = result.content + } + } + } + + const finalBranchName = params.branchName === 'custom' ? params.customBranchName : params.branchName + const initResult = await window.devscope.initGitRepo( + params.decodedPath, + finalBranchName, + params.createGitignore, + gitignoreContent + ) + + if (!initResult.success) { + params.showToast(`Failed to initialize git: ${initResult.error}`, undefined, undefined, 'error') + params.setIsInitializing(false) + return + } + + params.setIsGitRepo(true) + if (params.createInitialCommit) { + const commitResult = await window.devscope.createInitialCommit(params.decodedPath, params.initialCommitMessage) + if (!commitResult.success) { + params.showToast( + `Git initialized but failed to create initial commit: ${commitResult.error}`, + undefined, + undefined, + 'error' + ) + } + } + + await params.refreshGitData(true) + params.setInitStep('remote') + } catch (err: any) { + params.showToast(`Failed to initialize git: ${err.message}`, undefined, undefined, 'error') + } finally { + params.setIsInitializing(false) + } + }, + handleAddRemote: async () => { + if (!params.decodedPath || !params.remoteUrl.trim()) return + + params.setIsAddingRemote(true) + try { + const result = await window.devscope.addRemoteOrigin(params.decodedPath, params.remoteUrl) + if (!result.success) { + params.showToast(`Failed to add remote: ${result.error}`, undefined, undefined, 'error') + params.setIsAddingRemote(false) + return + } + + params.setShowInitModal(false) + params.setInitStep('config') + params.setRemoteUrl('') + params.setIsGitRepo(true) + params.setHasRemote(true) + await params.refreshGitData(true) + } catch (err: any) { + params.showToast(`Failed to add remote: ${err.message}`, undefined, undefined, 'error') + } finally { + params.setIsAddingRemote(false) + } + }, + handleSkipRemote: async () => { + params.setShowInitModal(false) + params.setInitStep('config') + params.setRemoteUrl('') + params.setIsGitRepo(true) + params.setHasRemote(false) + await params.refreshGitData(true) + }, + handleOpenInExplorer: async () => { + if (params.projectPath) { + try { + const result = await window.devscope.openInExplorer?.(params.projectPath) + if (result && !result.success) { + params.showToast(`Failed to open folder: ${result.error}`, undefined, undefined, 'error') + } + } catch (err) { + params.showToast(`Failed to invoke openInExplorer: ${err}`, undefined, undefined, 'error') + } + } + } + } +} diff --git a/src/renderer/src/pages/project-details/gitSyncActions.ts b/src/renderer/src/pages/project-details/gitSyncActions.ts new file mode 100644 index 0000000..9dfe5eb --- /dev/null +++ b/src/renderer/src/pages/project-details/gitSyncActions.ts @@ -0,0 +1,132 @@ +import { buildGitPublishPlan, describeGitPublishSuccess, type GitPublishPlan } from '@/lib/gitPublishPlanner' +import type { GitActionParams } from './gitActionTypes' +import { isTransientPushError, summarizePushError } from './gitActionHelpers' + +export function createGitSyncActions(params: GitActionParams) { + const handlePush = async ( + publishPlanOverride?: GitPublishPlan, + options?: { commitHash?: string; commitMessage?: string }, + pushOptions?: { remoteName?: string; branchName?: string } + ) => { + if (!params.decodedPath) return + const targetCommitHash = String(options?.commitHash || '').trim() + const publishPlan = publishPlanOverride ?? buildGitPublishPlan({ + currentBranch: params.currentBranch, + branches: params.branches, + remotes: params.remotes, + unpushedCommits: params.unpushedCommits, + selectedCommitHash: targetCommitHash || undefined, + intent: targetCommitHash ? 'push-range' : 'push-all' + }) + + params.setIsPushing(true) + try { + let retriedAfterTransientError = false + const runPush = async () => { + const pushResult = targetCommitHash + ? await window.devscope.pushSingleCommit(params.decodedPath, targetCommitHash, pushOptions) + : await window.devscope.pushCommits(params.decodedPath, pushOptions) + if (!pushResult?.success) { + throw new Error(pushResult?.error || (targetCommitHash ? 'Failed to push selected commit' : 'Failed to push commits')) + } + } + + try { + await runPush() + } catch (initialError: any) { + const initialMessage = String(initialError?.message || initialError || '') + if (!isTransientPushError(initialMessage)) { + throw initialError + } + + params.showToast('Push interrupted by network timeout. Retrying once...') + await new Promise((resolve) => setTimeout(resolve, 1200)) + await runPush() + retriedAfterTransientError = true + params.showToast('Push succeeded after retry.') + } + + await params.refreshGitData(false, { mode: 'unpushed' }) + if (!retriedAfterTransientError) { + params.showToast(describeGitPublishSuccess( + publishPlan, + { commitHash: targetCommitHash || undefined, currentBranch: params.currentBranch } + )) + } + } catch (err: any) { + const rawMessage = String(err?.message || err || 'Failed to push commits') + params.showToast(`Failed to push: ${summarizePushError(rawMessage)}`, undefined, undefined, 'error') + } finally { + params.setIsPushing(false) + } + } + + return { + handleFetch: async (remoteName?: string, successLabel?: string) => { + if (!params.decodedPath) return + + params.setIsFetching(true) + try { + const result = await window.devscope.fetchUpdates(params.decodedPath, remoteName) + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch updates') + } + + params.setLastFetched(Date.now()) + await params.refreshGitData(false, { quiet: true, mode: 'pulls' }) + params.showToast(successLabel || (remoteName ? `Fetched ${remoteName}.` : 'Fetched remote updates.')) + } catch (err: any) { + params.showToast(`Failed to fetch: ${err.message}`, undefined, undefined, 'error') + } finally { + params.setIsFetching(false) + } + }, + handlePush, + handlePull: async (options?: { remoteName?: string; branchName?: string; pushRemoteName?: string; successLabel?: string }) => { + if (!params.decodedPath) return + + params.setIsPulling(true) + try { + const result = await window.devscope.pullUpdates(params.decodedPath, options) + if (!result?.success) { + throw new Error(result?.error || 'Failed to pull updates') + } + + params.setLastPulled(Date.now()) + await params.refreshGitData(true, { mode: 'full' }) + params.showToast(options?.successLabel || 'Pulled remote updates successfully.') + } catch (err: any) { + params.showToast(`Failed to pull: ${err.message}`, undefined, undefined, 'error') + } finally { + params.setIsPulling(false) + } + }, + handleSwitchBranch: async () => { + if (!params.decodedPath || !params.targetBranch || params.targetBranch === params.currentBranch) return + + params.setIsSwitchingBranch(true) + try { + const checkoutResult = await window.devscope.checkoutBranch(params.decodedPath, params.targetBranch, { + autoStash: true, + autoCleanupLock: true + }) + if (!checkoutResult?.success) { + throw new Error(checkoutResult?.error || 'Failed to switch branch') + } + + await params.refreshGitData(true) + if (checkoutResult?.cleanedLock && checkoutResult?.stashed) { + params.showToast(`Recovered stale Git lock and auto-stashed changes (${checkoutResult.stashRef || 'stash@{0}'}).`) + } else if (checkoutResult?.cleanedLock) { + params.showToast('Recovered stale Git lock and switched branch.') + } else if (checkoutResult?.stashed) { + params.showToast(`Switched branch after auto-stashing local changes (${checkoutResult.stashRef || 'stash@{0}'}).`) + } + } catch (err: any) { + params.showToast(`Failed to switch branch: ${err.message}`, undefined, undefined, 'error') + } finally { + params.setIsSwitchingBranch(false) + } + } + } +} diff --git a/src/renderer/src/pages/project-details/projectDetailsPageViewProps.ts b/src/renderer/src/pages/project-details/projectDetailsPageViewProps.ts new file mode 100644 index 0000000..6b6e7e2 --- /dev/null +++ b/src/renderer/src/pages/project-details/projectDetailsPageViewProps.ts @@ -0,0 +1,267 @@ +import { getParentFolderPath } from './projectDetailsPageHelpers' + +export function buildProjectDetailsOverlayProps(props: any) { + return { + project: props.project, + showScriptsModal: props.showScriptsModal, + setShowScriptsModal: props.setShowScriptsModal, + showDependenciesModal: props.showDependenciesModal, + setShowDependenciesModal: props.setShowDependenciesModal, + onDependenciesUpdated: async () => { + await props.loadProjectDetails() + }, + onRunScript: props.runScript, + scriptPredictions: props.scriptPredictions, + selectedCommit: props.selectedCommit, + commitDiff: props.commitDiff, + loadingDiff: props.loadingDiff, + setSelectedCommit: props.setSelectedCommit, + setCommitDiff: props.setCommitDiff, + showAuthorMismatch: props.showAuthorMismatch, + gitUser: props.gitUser, + repoOwner: props.repoOwner, + handleAuthorMismatchConfirm: props.handleAuthorMismatchConfirm, + setShowAuthorMismatch: props.setShowAuthorMismatch, + dontShowAuthorWarning: !props.settings.gitWarnOnAuthorMismatch, + setDontShowAuthorWarning: (value: boolean) => props.updateSettings({ gitWarnOnAuthorMismatch: !value }), + showInitModal: props.showInitModal, + setShowInitModal: props.setShowInitModal, + setInitStep: props.setInitStep, + initStep: props.initStep, + branchName: props.branchName, + setBranchName: props.setBranchName, + customBranchName: props.customBranchName, + setCustomBranchName: props.setCustomBranchName, + createGitignore: props.createGitignore, + setCreateGitignore: props.setCreateGitignore, + gitignoreTemplate: props.gitignoreTemplate, + setGitignoreTemplate: props.setGitignoreTemplate, + availableTemplates: props.availableTemplates, + availablePatterns: props.availablePatterns, + selectedPatterns: props.selectedPatterns, + setSelectedPatterns: props.setSelectedPatterns, + patternSearch: props.patternSearch, + setPatternSearch: props.setPatternSearch, + createInitialCommit: props.createInitialCommit, + setCreateInitialCommit: props.setCreateInitialCommit, + initialCommitMessage: props.initialCommitMessage, + setInitialCommitMessage: props.setInitialCommitMessage, + isInitializing: props.isInitializing, + handleInitGit: props.handleInitGit, + remoteUrl: props.remoteUrl, + setRemoteUrl: props.setRemoteUrl, + isAddingRemote: props.isAddingRemote, + handleAddRemote: props.handleAddRemote, + handleSkipRemote: props.handleSkipRemote + } +} + +export function buildProjectDetailsContentProps(props: any) { + return { + isCondensedLayout: props.isCondensedLayout, + themeColor: props.themeColor, + project: props.project, + isProjectLive: props.isProjectLive, + activePorts: props.activePorts, + formatRelTime: props.formatRelTime, + onOpenTerminal: () => props.openTerminal({ displayName: props.projectTerminalLabel, id: 'main', category: 'project' }, props.projectRootPath), + scriptCount: Object.keys(props.project.scripts || {}).length, + dependencyCount: Object.keys(props.project.dependencies || {}).length + Object.keys(props.project.devDependencies || {}).length, + installedIdes: props.installedIdes, + loadingInstalledIdes: props.loadingInstalledIdes, + openingIdeId: props.openingIdeId, + onOpenProjectInIde: props.handleOpenProjectInIde, + handleCopyPath: props.handleCopyPath, + copiedPath: props.copiedPath, + handleOpenInExplorer: props.handleOpenInExplorer, + goBack: props.goBack, + activeTab: props.activeTab, + setActiveTab: props.setActiveTab, + fileTree: props.fileTree, + loadingGit: props.loadingGit, + loadingGitHistory: props.loadingGitHistory, + loadingFiles: props.loadingFiles, + changedFiles: props.changedFiles, + stagedFiles: props.stagedFiles, + unstagedFiles: props.unstagedFiles, + unpushedCommits: props.unpushedCommits, + onBrowseFolder: () => { + const encodedPath = encodeURIComponent(props.projectRootPath) + props.navigate(`/folder-browse/${encodedPath}`) + }, + onShowScriptsModal: () => props.setShowScriptsModal(true), + onShowDependenciesModal: () => props.setShowDependenciesModal(true), + loadProjectDetails: async () => { + await Promise.all([props.loadProjectDetails(), props.loadInstalledIdes()]) + }, + refreshFileTree: props.refreshFileTree, + onToggleAllFolders: props.handleToggleAllFolders, + readmeContentRef: props.readmeContentRef, + readmeExpanded: props.readmeExpanded, + readmeNeedsExpand: props.readmeNeedsExpand, + setReadmeExpanded: props.setReadmeExpanded, + fileSearch: props.fileSearch, + setFileSearch: props.setFileSearch, + setIsExpandingFolders: props.setIsExpandingFolders, + expandedFolders: props.expandedFolders, + setExpandedFolders: props.setExpandedFolders, + loadingFolderPaths: props.loadingFolderPaths, + allFolderPathsSet: props.allFolderPathsSet, + isExpandingFolders: props.isExpandingFolders, + showHidden: props.showHidden, + setShowHidden: props.setShowHidden, + sortBy: props.sortBy, + setSortBy: props.setSortBy, + sortAsc: props.sortAsc, + setSortAsc: props.setSortAsc, + visibleFileList: props.visibleFileList, + openPreview: props.openPreview, + onFileTreeOpen: props.handleFileTreeOpen, + onToggleFolder: props.handleToggleFolder, + onFileTreeOpenWith: props.handleFileTreeOpenWith, + onFileTreeOpenInExplorer: props.handleFileTreeOpenInExplorer, + onFileTreeCopyPath: props.handleFileTreeCopyPath, + onFileTreeCopy: props.handleFileTreeCopy, + onFileTreeRename: props.handleFileTreeRename, + onFileTreeDelete: props.handleFileTreeDelete, + onFileTreePaste: props.handleFileTreePaste, + onFileTreeMove: props.handleFileTreeMove, + onFileTreeCreateFile: props.handleFileTreeCreateFile, + onFileTreeCreateFolder: props.handleFileTreeCreateFolder, + hasFileClipboardItem: Boolean(props.fileClipboardItem), + gitUser: props.gitUser, + repoOwner: props.repoOwner, + gitError: props.gitError, + gitView: props.gitView, + setGitView: props.setGitView, + refreshGitData: props.refreshGitData, + isGitRepo: props.isGitRepo, + setShowInitModal: props.setShowInitModal, + currentBranch: props.currentBranch, + targetBranch: props.targetBranch, + setTargetBranch: props.setTargetBranch, + branches: props.branches, + isSwitchingBranch: props.isSwitchingBranch, + handleSwitchBranch: props.handleSwitchBranch, + commitMessage: props.commitMessage, + setCommitMessage: props.setCommitMessage, + handleGenerateCommitMessage: props.handleGenerateCommitMessage, + isGeneratingCommitMessage: props.isGeneratingCommitMessage, + isCommitting: props.isCommitting, + isStackedActionRunning: props.isStackedActionRunning, + settings: props.settings, + handleCommit: props.handleCommit, + handleCommitPushAndCreatePullRequest: props.handleCommitPushAndCreatePullRequest, + handleDangerouslyStageCommitPushAndCreatePullRequest: props.handleDangerouslyStageCommitPushAndCreatePullRequest, + handleStageFile: props.handleStageFile, + handleUnstageFile: props.handleUnstageFile, + handleStageAll: props.handleStageAll, + handleUnstageAll: props.handleUnstageAll, + handleDiscardUnstagedFile: props.handleDiscardUnstagedFile, + handleDiscardUnstagedAll: props.handleDiscardUnstagedAll, + ensureStatsForPaths: props.ensureWorkingChangeStats, + hasRemote: props.hasRemote, + gitSyncStatus: props.gitSyncStatus, + incomingCommits: props.incomingCommits, + setInitStep: props.setInitStep, + handleFetch: props.handleFetch, + handlePush: props.handlePush, + handlePull: props.handlePull, + isPushing: props.isPushing, + isFetching: props.isFetching, + isPulling: props.isPulling, + lastFetched: props.lastFetched, + lastPulled: props.lastPulled, + gitHistory: props.gitHistory, + gitHistoryTotalCount: props.gitHistoryTotalCount, + historyHasMore: props.historyHasMore, + loadingMoreHistory: props.loadingMoreHistory, + loadMoreGitHistory: props.loadMoreGitHistory, + remotes: props.remotes, + tags: props.tags, + stashes: props.stashes, + decodedPath: props.decodedPath, + changesPage: props.changesPage, + setChangesPage: props.setChangesPage, + ITEMS_PER_PAGE: props.ITEMS_PER_PAGE, + handleCommitClick: props.handleCommitClick, + unpushedPage: props.unpushedPage, + setUnpushedPage: props.setUnpushedPage, + pullsPage: props.pullsPage, + setPullsPage: props.setPullsPage, + COMMITS_PER_PAGE: props.COMMITS_PER_PAGE, + commitPage: props.commitPage, + setCommitPage: props.setCommitPage, + scriptPredictions: props.scriptPredictions, + scriptIntentContext: props.scriptIntentContext, + runScript: props.runScript, + setShowDependenciesModal: props.setShowDependenciesModal, + showToast: props.showToast + } +} + +export function buildProjectDetailsTransientUiProps(props: any) { + return { + projectPath: props.project.path, + pendingScriptRun: props.pendingScriptRun, + scriptPortInput: props.scriptPortInput, + setScriptPortInput: props.setScriptPortInput, + setScriptRunError: props.setScriptRunError, + scriptExposeNetwork: props.scriptExposeNetwork, + setScriptExposeNetwork: props.setScriptExposeNetwork, + scriptAdvancedOpen: props.scriptAdvancedOpen, + setScriptAdvancedOpen: props.setScriptAdvancedOpen, + scriptExtraArgsInput: props.scriptExtraArgsInput, + setScriptExtraArgsInput: props.setScriptExtraArgsInput, + scriptEnvInput: props.scriptEnvInput, + setScriptEnvInput: props.setScriptEnvInput, + scriptRunError: props.scriptRunError, + scriptCommandPreview: props.scriptCommandPreview, + scriptRunner: props.scriptRunner, + closeScriptRunModal: props.closeScriptRunModal, + handleConfirmScriptRun: props.handleConfirmScriptRun, + previewFile: props.previewFile, + previewMediaItems: props.previewMediaItems, + previewContent: props.previewContent, + loadingPreview: props.loadingPreview, + previewTruncated: props.previewTruncated, + previewSize: props.previewSize, + previewBytes: props.previewBytes, + previewModifiedAt: props.previewModifiedAt, + openPreview: props.openPreview, + onPreviewSaved: async () => { + await Promise.all([ + props.refreshVisibleFileTree(getParentFolderPath(props.previewFile?.path || '') || undefined), + props.refreshGitData() + ]) + }, + closePreview: props.closePreview, + toast: props.toast, + navigate: props.navigate, + setToast: props.setToast + } +} + +export function buildProjectDetailsFileSystemModalProps(props: any) { + return { + createTarget: props.createTarget, + createErrorMessage: props.createErrorMessage, + submitCreateTarget: props.submitCreateTarget, + setCreateTarget: props.setCreateTarget, + createDraft: props.createDraft, + setCreateDraft: props.setCreateDraft, + setCreateErrorMessage: props.setCreateErrorMessage, + renameTarget: props.renameTarget, + renameDraft: props.renameDraft, + setRenameDraft: props.setRenameDraft, + renameErrorMessage: props.renameErrorMessage, + submitRenameTarget: props.submitRenameTarget, + setRenameTarget: props.setRenameTarget, + setRenameExtensionSuffix: props.setRenameExtensionSuffix, + renameExtensionSuffix: props.renameExtensionSuffix, + setRenameErrorMessage: props.setRenameErrorMessage, + deleteTarget: props.deleteTarget, + confirmDeleteTarget: props.confirmDeleteTarget, + setDeleteTarget: props.setDeleteTarget + } +} diff --git a/src/renderer/src/pages/project-details/useStackedTaskStatus.ts b/src/renderer/src/pages/project-details/useStackedTaskStatus.ts new file mode 100644 index 0000000..1bfb52d --- /dev/null +++ b/src/renderer/src/pages/project-details/useStackedTaskStatus.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react' + +export function useStackedTaskStatus(projectPath: string, isStackedActionRunning: boolean) { + const [stackedTaskStatusText, setStackedTaskStatusText] = useState('') + + useEffect(() => { + const normalizedProjectPath = String(projectPath || '').trim().toLowerCase() + if (!normalizedProjectPath) return + + const applyTask = (task: any) => { + if (!task || task.type !== 'git.stacked') return false + if (String(task.projectPath || '').trim().toLowerCase() !== normalizedProjectPath) return false + if (task.status !== 'running') { + setStackedTaskStatusText('') + return true + } + + const latestLog = Array.isArray(task.logs) + ? [...task.logs] + .reverse() + .map((entry) => String(entry?.message || '').trim()) + .find((message) => message && !/^Target branch:|^Auto-stage all:|^Commit message:/i.test(message)) + : '' + setStackedTaskStatusText(latestLog || 'Starting...') + return true + } + + void window.devscope.listActiveTasks?.(projectPath).then((result) => { + if (!result?.success || !Array.isArray(result.tasks)) return + const matchingTask = result.tasks.find((task: any) => applyTask(task)) + if (!matchingTask) { + setStackedTaskStatusText('') + } + }).catch(() => undefined) + + const unsubscribe = window.devscope.onTaskEvent?.((event) => { + if (event.type === 'remove') { + void window.devscope.listActiveTasks?.(projectPath).then((result) => { + if (!result?.success || !Array.isArray(result.tasks)) { + setStackedTaskStatusText('') + return + } + const matchingTask = result.tasks.find((task: any) => applyTask(task)) + if (!matchingTask) { + setStackedTaskStatusText('') + } + }).catch(() => { + setStackedTaskStatusText('') + }) + return + } + if (event.type !== 'upsert' || !event.task) return + applyTask(event.task) + }) + + return () => { + unsubscribe?.() + } + }, [isStackedActionRunning, projectPath]) + + return stackedTaskStatusText +} diff --git a/src/renderer/src/pages/project-details/workingChangesBranchGuard.tsx b/src/renderer/src/pages/project-details/workingChangesBranchGuard.tsx new file mode 100644 index 0000000..edd389c --- /dev/null +++ b/src/renderer/src/pages/project-details/workingChangesBranchGuard.tsx @@ -0,0 +1,133 @@ +import { RefreshCw } from 'lucide-react' +import { Checkbox, Input } from '@/components/ui/FormControls' + +function slugifyBranchSegment(value: string) { + return String(value || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) +} + +function buildBranchSeed(commitMessage: string) { + const fromCommit = slugifyBranchSegment(commitMessage) + if (fromCommit) return fromCommit + + const now = new Date() + const yyyy = now.getFullYear() + const mm = String(now.getMonth() + 1).padStart(2, '0') + const dd = String(now.getDate()).padStart(2, '0') + const hh = String(now.getHours()).padStart(2, '0') + const min = String(now.getMinutes()).padStart(2, '0') + return `update-${yyyy}${mm}${dd}-${hh}${min}` +} + +export function buildProposedBranchName(commitMessage: string, branchNames: string[]) { + const existing = new Set(branchNames.map((name) => String(name || '').trim().toLowerCase()).filter(Boolean)) + const seed = buildBranchSeed(commitMessage) + const base = `feature/${seed}` + if (!existing.has(base.toLowerCase())) return base + + for (let index = 2; index < 100; index += 1) { + const next = `${base}-${index}` + if (!existing.has(next.toLowerCase())) return next + } + + return `${base}-${Date.now()}` +} + +export function BranchGuardModal({ + isOpen, + isCreating, + currentBranch, + proposedBranchName, + setProposedBranchName, + autoCreateNextTime, + setAutoCreateNextTime, + branchGuardError, + onCancel, + onConfirm +}: { + isOpen: boolean + isCreating: boolean + currentBranch: string + proposedBranchName: string + setProposedBranchName: (value: string) => void + autoCreateNextTime: boolean + setAutoCreateNextTime: (value: boolean) => void + branchGuardError: string + onCancel: () => void + onConfirm: () => void +}) { + if (!isOpen) return null + + return ( +
{ + if (isCreating) return + onCancel() + }} + > +
event.stopPropagation()} + > +
+
+ Same branch +
+
+

Create a branch first?

+

+ Current branch and target branch are both {currentBranch}. +

+
+
+
Proposed branch
+ +
+
+ +
+ {branchGuardError ? ( +
+ {branchGuardError} +
+ ) : null} +
+
+ + +
+
+
+ ) +} From 70f9bc509710d028e8cc9ffb3f5db87ac81c08f2 Mon Sep 17 00:00:00 2001 From: justelson Date: Thu, 26 Mar 2026 22:33:04 +0300 Subject: [PATCH 4/5] feat(assistant): add playground labs and richer workspace flows --- src/main/assistant/codex-app-server.ts | 30 +- src/main/assistant/codex-runtime-events.ts | 21 +- src/main/assistant/codex-runtime-protocol.ts | 120 ++- src/main/assistant/persistence-read.ts | 83 ++- src/main/assistant/persistence-utils.ts | 42 +- src/main/assistant/persistence-write.ts | 105 ++- src/main/assistant/persistence.ts | 12 +- src/main/assistant/playground-service.ts | 136 ++++ src/main/assistant/projector.ts | 8 + src/main/assistant/service-history.ts | 2 + src/main/assistant/service-records.ts | 5 + src/main/assistant/service-runtime-events.ts | 27 + src/main/assistant/service.ts | 267 ++++++- src/main/assistant/transcription-models.ts | 255 +++++++ src/main/ipc/handlers/assistant-handlers.ts | 71 +- src/preload/adapters/assistant-adapter.ts | 27 +- src/preload/adapters/disabled-adapters.ts | 4 +- src/renderer/src/index.css | 23 + .../src/lib/assistant/assistant-store-core.ts | 64 +- .../lib/assistant/assistant-store-hooks.ts | 177 ++++- src/renderer/src/lib/assistant/selectors.ts | 38 + .../lib/assistant/session-hydration-cache.ts | 12 + src/renderer/src/lib/assistant/store.ts | 3 + .../src/lib/settings-assistant-defaults.ts | 11 +- src/renderer/src/lib/settings.tsx | 16 +- .../AssistantAttachmentImageCard.tsx | 88 +++ .../AssistantAttachmentTextPreviewModal.tsx | 110 +++ .../assistant/AssistantComposerSections.tsx | 286 +++++-- .../pages/assistant/AssistantComposerView.tsx | 215 +++++- .../AssistantConnectedSessionsRail.tsx | 20 +- .../AssistantConversationComposerPane.tsx | 75 +- .../assistant/AssistantConversationPane.tsx | 439 ++++++++--- .../AssistantConversationTimelinePane.tsx | 66 +- .../pages/assistant/AssistantDiffPanel.tsx | 7 +- .../AssistantHeaderOpenWithButton.tsx | 435 +++++++++++ .../src/pages/assistant/AssistantPage.tsx | 492 +++---------- .../AssistantPendingPlaygroundLabPanel.tsx | 83 +++ .../AssistantPendingUserInputPanel.tsx | 695 +++++++++++++++--- .../pages/assistant/AssistantPlanPanel.tsx | 107 +-- .../assistant/AssistantProjectGitChip.tsx | 6 +- .../pages/assistant/AssistantSessionsRail.tsx | 34 +- .../assistant/AssistantSessionsRailParts.tsx | 457 +++++++++++- .../assistant/AssistantSessionsRailRows.tsx | 5 +- .../assistant/AssistantThreadDetailsPanel.tsx | 40 +- .../src/pages/assistant/AssistantTimeline.tsx | 85 ++- .../pages/assistant/AssistantTimelineRows.tsx | 383 +++++++++- .../assistant/AssistantTimelineToolCalls.tsx | 191 ++++- .../assistant/ConnectedAssistantPlanPanel.tsx | 94 +++ .../ConnectedAssistantThreadDetailsPanel.tsx | 477 ++++++++++++ .../assistant/assistant-composer-handlers.ts | 30 +- .../assistant/assistant-composer-types.ts | 23 + .../assistant/assistant-file-navigation.ts | 5 +- .../assistant/assistant-pending-user-input.ts | 33 +- .../pages/assistant/assistant-plan-utils.ts | 12 + .../assistant/assistant-proposed-plan.ts | 23 + .../assistant-sessions-rail-utils.ts | 16 +- .../assistant/assistant-timeline-helpers.ts | 90 ++- .../useAssistantComposerController.ts | 138 +++- .../useAssistantComposerProjectData.ts | 11 +- .../assistant/useAssistantPageSidebarState.ts | 14 +- .../useAssistantPageTimelineScroll.ts | 66 +- .../assistant/useAssistantSpeechInput.ts | 444 +++++++++++ .../assistant/useAssistantTimelineEntries.ts | 23 +- .../useAssistantTimelineVirtualizer.ts | 4 + .../assistant/useAssistantTimelineWindow.ts | 52 +- .../src/pages/settings/AISettings.tsx | 392 +--------- .../pages/settings/AssistantDefaultsPanel.tsx | 246 ++++++- .../settings/ai-settings/AISettingsCards.tsx | 261 +++++++ .../settings/ai-settings/aiSettingsConfig.ts | 31 + .../ai-settings/useCodexModelOptions.ts | 52 ++ src/shared/assistant/contracts/ipc.ts | 81 +- src/shared/assistant/contracts/read-model.ts | 53 ++ src/shared/assistant/pricing.ts | 137 ++++ src/shared/assistant/projector.ts | 8 + src/shared/contracts/devscope-api.ts | 26 +- 75 files changed, 7306 insertions(+), 1414 deletions(-) create mode 100644 src/main/assistant/playground-service.ts create mode 100644 src/main/assistant/transcription-models.ts create mode 100644 src/renderer/src/pages/assistant/AssistantAttachmentImageCard.tsx create mode 100644 src/renderer/src/pages/assistant/AssistantAttachmentTextPreviewModal.tsx create mode 100644 src/renderer/src/pages/assistant/AssistantHeaderOpenWithButton.tsx create mode 100644 src/renderer/src/pages/assistant/AssistantPendingPlaygroundLabPanel.tsx create mode 100644 src/renderer/src/pages/assistant/ConnectedAssistantPlanPanel.tsx create mode 100644 src/renderer/src/pages/assistant/ConnectedAssistantThreadDetailsPanel.tsx create mode 100644 src/renderer/src/pages/assistant/assistant-proposed-plan.ts create mode 100644 src/renderer/src/pages/assistant/useAssistantSpeechInput.ts create mode 100644 src/renderer/src/pages/settings/ai-settings/AISettingsCards.tsx create mode 100644 src/renderer/src/pages/settings/ai-settings/aiSettingsConfig.ts create mode 100644 src/renderer/src/pages/settings/ai-settings/useCodexModelOptions.ts create mode 100644 src/shared/assistant/pricing.ts diff --git a/src/main/assistant/codex-app-server.ts b/src/main/assistant/codex-app-server.ts index 9f1acbd..8eafd07 100644 --- a/src/main/assistant/codex-app-server.ts +++ b/src/main/assistant/codex-app-server.ts @@ -34,6 +34,33 @@ import { type SessionContext } from './codex-runtime-protocol' +function toCodexUserInputAnswer(value: unknown): { answers: string[] } { + if (typeof value === 'string') { + return { answers: [value] } + } + + if (Array.isArray(value)) { + return { answers: value.filter((entry): entry is string => typeof entry === 'string') } + } + + if (value && typeof value === 'object') { + const maybeAnswers = (value as { answers?: unknown }).answers + if (Array.isArray(maybeAnswers)) { + return { answers: maybeAnswers.filter((entry): entry is string => typeof entry === 'string') } + } + } + + return { answers: [] } +} + +function toCodexUserInputAnswers( + answers: Record +): Record { + return Object.fromEntries( + Object.entries(answers).map(([questionId, value]) => [questionId, toCodexUserInputAnswer(value)]) + ) +} + export class CodexAppServerRuntime extends EventEmitter { private readonly sessions = new Map() private readonly codexBinary = process.platform === 'win32' ? 'codex.cmd' : 'codex' @@ -351,7 +378,8 @@ export class CodexAppServerRuntime extends EventEmitter { if (!pending) throw new Error(`Unknown user-input request: ${requestId}`) context.pendingUserInputs.delete(requestId) - this.writeMessage(context, { id: pending.jsonRpcId, result: { answers } }) + const codexAnswers = toCodexUserInputAnswers(answers) + this.writeMessage(context, { id: pending.jsonRpcId, result: { answers: codexAnswers } }) this.emitRuntime({ eventId: randomUUID(), type: 'user-input.resolved', diff --git a/src/main/assistant/codex-runtime-events.ts b/src/main/assistant/codex-runtime-events.ts index 3c6ae67..6747200 100644 --- a/src/main/assistant/codex-runtime-events.ts +++ b/src/main/assistant/codex-runtime-events.ts @@ -23,6 +23,23 @@ interface RuntimeEventHandlerDeps { writeMessage: (context: SessionContext, message: Record) => void } +function readResolvedUserInputAnswers(value: unknown): Record { + const rawAnswers = asRecord(value) || {} + return Object.fromEntries( + Object.entries(rawAnswers).map(([questionId, answerValue]) => { + if (typeof answerValue === 'string') return [questionId, answerValue] + if (Array.isArray(answerValue)) { + return [questionId, answerValue.filter((entry): entry is string => typeof entry === 'string')] + } + const answerRecord = asRecord(answerValue) + const nestedAnswers = Array.isArray(answerRecord?.['answers']) + ? answerRecord['answers'].filter((entry): entry is string => typeof entry === 'string') + : [] + return [questionId, nestedAnswers.length <= 1 ? (nestedAnswers[0] || '') : nestedAnswers] + }) + ) +} + export function handleStdoutLine(context: SessionContext, line: string, deps: RuntimeEventHandlerDeps): void { let parsed: JsonRpcMessage try { @@ -274,12 +291,12 @@ function handleNotification( } if (method === 'item/tool/requestUserInput/answered') { - const answers = asRecord(payload['answers']) as Record | undefined + const answers = readResolvedUserInputAnswers(payload['answers']) deps.emitRuntime({ ...eventBase, type: 'user-input.resolved', requestId: asString(payload['requestId']) || eventBase.itemId, - payload: { answers: answers || {} } + payload: { answers } }) return } diff --git a/src/main/assistant/codex-runtime-protocol.ts b/src/main/assistant/codex-runtime-protocol.ts index e30a2e6..5db2e1e 100644 --- a/src/main/assistant/codex-runtime-protocol.ts +++ b/src/main/assistant/codex-runtime-protocol.ts @@ -113,21 +113,135 @@ export function mapRuntimeMode(mode: AssistantRuntimeMode) { return mapRuntimeModeImpl(mode) } +const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) + +You work in 3 phases. Aim for a decision-complete final plan, but do not withhold a materially useful draft plan once enough information exists to produce one. When decisions remain open, keep them explicit and keep driving them to closure. + +## Mode rules + +You are in Plan Mode until a developer message explicitly ends it. + +Plan Mode is not changed by user intent or imperative wording. If a user asks for execution while still in Plan Mode, treat it as a request to plan the execution, not perform it. + +## Plan Mode vs update_plan tool + +Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a block. + +The update_plan tool is a separate checklist/progress tool. It does not enter or exit Plan Mode. Do not use update_plan while in Plan Mode. + +## Execution boundaries + +You may do non-mutating exploration that improves the plan. You must not do mutating work. + +Allowed: + +* reading and searching files, types, configs, and docs +* static analysis and repo exploration +* dry-run style checks that do not edit tracked files +* tests or checks that only write caches/build artifacts outside tracked files + +Not allowed: + +* editing or writing files +* applying patches, migrations, or codegen that changes tracked files +* formatters or linters that rewrite files +* side-effectful commands whose purpose is to carry out the plan + +## Phase 1 - Explore first + +Ground yourself in the actual environment before asking questions. Resolve all discoverable facts through non-mutating exploration first. + +Before asking the user any question, perform at least one targeted exploration pass unless there is no local environment or the prompt itself is immediately contradictory. + +Do not ask questions that can be answered from the repo or system. + +## Phase 2 - Intent chat + +Keep asking until the goal, success criteria, scope, constraints, audience, and key tradeoffs are clear. + +If high-impact ambiguity remains and you cannot produce a credible draft plan yet, ask. + +If you already have enough information for a credible draft implementation plan, produce that draft plan and clearly mark unresolved items instead of withholding the plan entirely. + +## Phase 3 - Implementation chat + +Once intent is stable, keep asking until the spec is decision complete: approach, interfaces, data flow, edge cases, failure modes, tests, rollout, and compatibility constraints. + +## Asking questions + +Strongly prefer using the request_user_input tool for questions. + +Each question must materially change the plan, confirm an important assumption, or choose between meaningful tradeoffs. Do not ask questions that can be answered through exploration. + +For preferences and tradeoffs, provide 2-4 mutually exclusive options and recommend one. + +If the user has already entered a guided question flow, keep unresolved follow-up questions in that guided flow when possible. Prefer another request_user_input round over switching back to normal assistant prose. + +Only ask unresolved questions in normal assistant text if the issue genuinely requires nuanced free-form explanation, longer context, or cannot be represented well in 1-3 short guided questions. + +## Finalization + +Output a block as soon as you have enough information for a credible implementation plan. + +If the plan is not yet decision complete, still output the block and include a clearly labeled "Open decisions" section with recommended defaults and what still needs confirmation. + +When the plan is decision complete, output the final block with no unresolved decisions left. + +Wrap the plan in a block. Use Markdown inside the block. Include: + +* a clear title +* a brief summary +* important public API or interface changes +* test cases and scenarios +* explicit assumptions and defaults +* an "Open decisions" section whenever anything remains unresolved + +Only produce at most one block per turn. +` + +const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default + +You are now in Default mode. Any previous instructions for other modes are no longer active. + +Your active mode changes only when new developer instructions with a different collaboration_mode change it. User requests do not change mode by themselves. + +The request_user_input tool is unavailable in Default mode. Prefer making reasonable assumptions and executing the request. If a question is absolutely necessary, ask directly and concisely. +` + +function buildCollaborationMode( + interactionMode: AssistantInteractionMode, + model: string | undefined, + effort?: 'low' | 'medium' | 'high' | 'xhigh' +) { + return { + mode: interactionMode, + settings: { + ...(model ? { model } : {}), + reasoning_effort: effort || 'medium', + developer_instructions: interactionMode === 'plan' + ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS + : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS + } + } +} + export function buildTurnParams( thread: AssistantThread, prompt: string, model?: string, runtimeMode?: AssistantRuntimeMode, - _interactionMode?: AssistantInteractionMode, + interactionMode?: AssistantInteractionMode, effort?: 'low' | 'medium' | 'high' | 'xhigh', serviceTier?: 'fast' ) { + const effectiveModel = model || thread.model + const effectiveInteractionMode = interactionMode || thread.interactionMode || 'default' const params: Record = { threadId: thread.providerThreadId, input: [{ type: 'text', text: prompt }], - approvalPolicy: mapRuntimeMode(runtimeMode || thread.runtimeMode).approvalPolicy + approvalPolicy: mapRuntimeMode(runtimeMode || thread.runtimeMode).approvalPolicy, + collaborationMode: buildCollaborationMode(effectiveInteractionMode, effectiveModel, effort) } - const effectiveModel = model || thread.model if (effectiveModel) params['model'] = effectiveModel if (effort) params['effort'] = effort if (serviceTier) params['serviceTier'] = serviceTier diff --git a/src/main/assistant/persistence-read.ts b/src/main/assistant/persistence-read.ts index 99c64db..18fbe0e 100644 --- a/src/main/assistant/persistence-read.ts +++ b/src/main/assistant/persistence-read.ts @@ -2,11 +2,14 @@ import type { Database as SqlDatabase, SqlValue } from 'sql.js/dist/sql-asm.js' import type { AssistantActivity, AssistantDomainEvent, + AssistantLatestTurn, AssistantMessage, AssistantPendingApproval, AssistantPendingUserInput, + AssistantPlaygroundLab, AssistantProposedPlan, AssistantSession, + AssistantSessionTurnUsageEntry, AssistantSnapshot, AssistantThread } from '../../shared/assistant/contracts' @@ -44,6 +47,7 @@ export function readAssistantSnapshot(db: SqlDatabase): AssistantSnapshot { snapshotSequence: meta.snapshotSequence, updatedAt: meta.updatedAt, selectedSessionId, + playground: readAssistantPlaygroundState(db), sessions, knownModels: meta.knownModels } @@ -128,6 +132,45 @@ export function readActiveThreadDetails(db: SqlDatabase, sessionId: string, snap } } +export function readAssistantSessionTurnUsage(db: SqlDatabase, sessionId: string): AssistantSessionTurnUsageEntry[] { + const rows = db.exec(` + SELECT + assistant_turns.id, + assistant_threads.session_id, + assistant_turns.thread_id, + assistant_turns.model, + assistant_turns.state, + assistant_turns.requested_at, + assistant_turns.started_at, + assistant_turns.completed_at, + assistant_turns.assistant_message_id, + assistant_turns.effort, + assistant_turns.service_tier, + assistant_turns.usage_json, + assistant_turns.updated_at + FROM assistant_turns + INNER JOIN assistant_threads ON assistant_threads.id = assistant_turns.thread_id + WHERE assistant_threads.session_id = ? + ORDER BY assistant_turns.requested_at ASC, assistant_turns.id ASC + `, [sessionId])[0]?.values || [] + + return rows.map((row) => ({ + id: String(row[0] || ''), + sessionId: String(row[1] || ''), + threadId: String(row[2] || ''), + model: String(row[3] || ''), + state: String(row[4] || 'running') as AssistantLatestTurn['state'], + requestedAt: String(row[5] || new Date(0).toISOString()), + startedAt: toNullableString(row[6]), + completedAt: toNullableString(row[7]), + assistantMessageId: toNullableString(row[8]), + effort: toNullableString(row[9]) as AssistantLatestTurn['effort'], + serviceTier: toNullableString(row[10]) as AssistantLatestTurn['serviceTier'], + usage: parseJson(row[11], null), + updatedAt: String(row[12] || new Date(0).toISOString()) + })) +} + function readAssistantMeta(db: SqlDatabase): AssistantMetaRow { const rows = db.exec('SELECT key, value FROM assistant_meta') const values = new Map() @@ -140,6 +183,7 @@ function readAssistantMeta(db: SqlDatabase): AssistantMetaRow { snapshotSequence: Number(values.get('snapshotSequence') || '0') || 0, updatedAt: values.get('updatedAt') || new Date(0).toISOString(), selectedSessionId: values.get('selectedSessionId') || null, + playgroundRootPath: values.get('playgroundRootPath') || null, knownModels: parseJson(values.get('knownModels') || '', []) } } @@ -147,7 +191,7 @@ function readAssistantMeta(db: SqlDatabase): AssistantMetaRow { function readAssistantSessionSummaries(db: SqlDatabase): AssistantSession[] { const sessions = new Map() const sessionRows = db.exec(` - SELECT id, title, project_path, archived, created_at, updated_at, active_thread_id + SELECT id, title, mode, project_path, playground_lab_id, pending_lab_request_json, archived, created_at, updated_at, active_thread_id FROM assistant_sessions ORDER BY updated_at DESC, id DESC `)[0]?.values || [] @@ -156,11 +200,14 @@ function readAssistantSessionSummaries(db: SqlDatabase): AssistantSession[] { const session: AssistantSession = { id: String(row[0] || ''), title: String(row[1] || 'New Session'), - projectPath: toNullableString(row[2]), - archived: toNumber(row[3]) === 1, - createdAt: String(row[4] || new Date(0).toISOString()), - updatedAt: String(row[5] || new Date(0).toISOString()), - activeThreadId: toNullableString(row[6]), + mode: String(row[2] || 'work') === 'playground' ? 'playground' : 'work', + projectPath: toNullableString(row[3]), + playgroundLabId: toNullableString(row[4]), + pendingLabRequest: parseJson(row[5], null), + archived: toNumber(row[6]) === 1, + createdAt: String(row[7] || new Date(0).toISOString()), + updatedAt: String(row[8] || new Date(0).toISOString()), + activeThreadId: toNullableString(row[9]), threadIds: [], threads: [] } @@ -219,6 +266,30 @@ function readAssistantSessionSummaries(db: SqlDatabase): AssistantSession[] { return [...sessions.values()] } +function readAssistantPlaygroundState(db: SqlDatabase): AssistantSnapshot['playground'] { + const rootPath = readAssistantMeta(db).playgroundRootPath + const labRows = db.exec(` + SELECT id, title, root_path, source, repo_url, created_at, updated_at + FROM assistant_playground_labs + ORDER BY updated_at DESC, id DESC + `)[0]?.values || [] + + const labs: AssistantPlaygroundLab[] = labRows.map((row) => ({ + id: String(row[0] || ''), + title: String(row[1] || 'Lab'), + rootPath: String(row[2] || ''), + source: String(row[3] || 'empty') as AssistantPlaygroundLab['source'], + repoUrl: toNullableString(row[4]), + createdAt: String(row[5] || new Date(0).toISOString()), + updatedAt: String(row[6] || new Date(0).toISOString()) + })) + + return { + rootPath, + labs + } +} + function removeInvalidSessions(db: SqlDatabase, sessions: AssistantSession[]): AssistantSession[] { const invalidSessionIds = sessions.filter(shouldDeleteInvalidSession).map((session) => session.id) if (invalidSessionIds.length === 0) return sessions diff --git a/src/main/assistant/persistence-utils.ts b/src/main/assistant/persistence-utils.ts index 343bc90..8402447 100644 --- a/src/main/assistant/persistence-utils.ts +++ b/src/main/assistant/persistence-utils.ts @@ -15,10 +15,11 @@ export interface AssistantMetaRow { snapshotSequence: number updatedAt: string selectedSessionId: string | null + playgroundRootPath: string | null knownModels: AssistantSnapshot['knownModels'] } -export const PERSISTENCE_VERSION = 3 +export const PERSISTENCE_VERSION = 5 export const PERSISTENCE_FLUSH_DEBOUNCE_MS = 1500 export function jsonStringify(value: unknown): string { @@ -91,7 +92,10 @@ export function initializeAssistantPersistenceSchema(db: SqlDatabase): void { CREATE TABLE IF NOT EXISTS assistant_sessions ( id TEXT PRIMARY KEY, title TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'work', project_path TEXT, + playground_lab_id TEXT, + pending_lab_request_json TEXT, archived INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -115,6 +119,21 @@ export function initializeAssistantPersistenceSchema(db: SqlDatabase): void { active_plan_json TEXT, FOREIGN KEY(session_id) REFERENCES assistant_sessions(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS assistant_turns ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + model TEXT NOT NULL, + state TEXT NOT NULL, + requested_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + assistant_message_id TEXT, + effort TEXT, + service_tier TEXT, + usage_json TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY(thread_id) REFERENCES assistant_threads(id) ON DELETE CASCADE + ); CREATE TABLE IF NOT EXISTS assistant_messages ( id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, @@ -175,11 +194,32 @@ export function initializeAssistantPersistenceSchema(db: SqlDatabase): void { resolved_at TEXT, FOREIGN KEY(thread_id) REFERENCES assistant_threads(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS assistant_playground_labs ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + root_path TEXT NOT NULL, + source TEXT NOT NULL, + repo_url TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); CREATE INDEX IF NOT EXISTS idx_assistant_threads_session ON assistant_threads(session_id, updated_at DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_assistant_turns_thread ON assistant_turns(thread_id, requested_at ASC, id ASC); CREATE INDEX IF NOT EXISTS idx_assistant_messages_thread ON assistant_messages(thread_id, created_at ASC, id ASC); CREATE INDEX IF NOT EXISTS idx_assistant_activities_thread ON assistant_activities(thread_id, created_at ASC, id ASC); CREATE INDEX IF NOT EXISTS idx_assistant_plans_thread ON assistant_proposed_plans(thread_id, created_at ASC, id ASC); CREATE INDEX IF NOT EXISTS idx_assistant_approvals_thread ON assistant_pending_approvals(thread_id, created_at ASC, id ASC); CREATE INDEX IF NOT EXISTS idx_assistant_user_inputs_thread ON assistant_pending_user_inputs(thread_id, created_at ASC, id ASC); + CREATE INDEX IF NOT EXISTS idx_assistant_playground_labs_updated ON assistant_playground_labs(updated_at DESC, id DESC); `) + ensureTableColumn(db, 'assistant_sessions', 'mode', `TEXT NOT NULL DEFAULT 'work'`) + ensureTableColumn(db, 'assistant_sessions', 'playground_lab_id', 'TEXT') + ensureTableColumn(db, 'assistant_sessions', 'pending_lab_request_json', 'TEXT') +} + +function ensureTableColumn(db: SqlDatabase, tableName: string, columnName: string, definition: string): void { + const rows = db.exec(`PRAGMA table_info(${tableName})`)[0]?.values || [] + const hasColumn = rows.some((row) => String(row[1] || '') === columnName) + if (hasColumn) return + db.run(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`) } diff --git a/src/main/assistant/persistence-write.ts b/src/main/assistant/persistence-write.ts index 0527d37..613c592 100644 --- a/src/main/assistant/persistence-write.ts +++ b/src/main/assistant/persistence-write.ts @@ -2,9 +2,11 @@ import type { Database as SqlDatabase, SqlValue } from 'sql.js/dist/sql-asm.js' import type { AssistantActivity, AssistantDomainEvent, + AssistantLatestTurn, AssistantMessage, AssistantPendingApproval, AssistantPendingUserInput, + AssistantPlaygroundLab, AssistantProposedPlan, AssistantSession, AssistantSnapshot, @@ -40,6 +42,10 @@ export function persistAssistantEvent(db: SqlDatabase, event: AssistantDomainEve case 'session.selected': if (session) upsertAssistantSession(db, session) break + case 'playground.updated': + replaceAssistantPlaygroundLabs(db, snapshot.playground.labs) + upsertAssistantMeta(db, 'playgroundRootPath', snapshot.playground.rootPath || '') + break case 'session.deleted': db.run('DELETE FROM assistant_sessions WHERE id = ?', [event.payload['sessionId'] as SqlValue]) break @@ -53,11 +59,18 @@ export function persistAssistantEvent(db: SqlDatabase, event: AssistantDomainEve if (thread) { upsertAssistantThreadSummary(db, thread.sessionId, thread.thread) const patch = (event.payload['patch'] as Record | undefined) || {} + const removedTurnIds = Array.isArray(event.payload['removedTurnIds']) + ? event.payload['removedTurnIds'].map((entry) => String(entry || '')).filter(Boolean) + : [] if (Object.prototype.hasOwnProperty.call(patch, 'messages')) replaceAssistantMessages(db, thread.thread) if (Object.prototype.hasOwnProperty.call(patch, 'activities')) replaceAssistantActivities(db, thread.thread) if (Object.prototype.hasOwnProperty.call(patch, 'proposedPlans')) replaceAssistantProposedPlans(db, thread.thread) if (Object.prototype.hasOwnProperty.call(patch, 'pendingApprovals')) replaceAssistantPendingApprovals(db, thread.thread) if (Object.prototype.hasOwnProperty.call(patch, 'pendingUserInputs')) replaceAssistantPendingUserInputs(db, thread.thread) + if (removedTurnIds.length > 0) deleteAssistantTurns(db, removedTurnIds) + if (Object.prototype.hasOwnProperty.call(patch, 'latestTurn') && thread.thread.latestTurn) { + upsertAssistantTurn(db, thread.thread.id, thread.thread.model, thread.thread.latestTurn) + } } if (session) upsertAssistantSession(db, session) break @@ -76,7 +89,10 @@ export function persistAssistantEvent(db: SqlDatabase, event: AssistantDomainEve break case 'thread.plan.updated': case 'thread.latest-turn.updated': - if (thread) upsertAssistantThreadSummary(db, thread.sessionId, thread.thread) + if (thread) { + upsertAssistantThreadSummary(db, thread.sessionId, thread.thread) + if (thread.thread.latestTurn) upsertAssistantTurn(db, thread.thread.id, thread.thread.model, thread.thread.latestTurn) + } break case 'thread.proposed-plan.upserted': if (thread) { @@ -116,6 +132,7 @@ export function persistAssistantEvent(db: SqlDatabase, event: AssistantDomainEve export function replaceAssistantSnapshot(db: SqlDatabase, snapshot: AssistantSnapshot): void { runSqlTransaction(db, () => { + db.run('DELETE FROM assistant_turns') db.run('DELETE FROM assistant_pending_user_inputs') db.run('DELETE FROM assistant_pending_approvals') db.run('DELETE FROM assistant_proposed_plans') @@ -123,12 +140,16 @@ export function replaceAssistantSnapshot(db: SqlDatabase, snapshot: AssistantSna db.run('DELETE FROM assistant_messages') db.run('DELETE FROM assistant_threads') db.run('DELETE FROM assistant_sessions') + db.run('DELETE FROM assistant_playground_labs') persistAssistantSnapshotMeta(db, snapshot) + replaceAssistantPlaygroundLabs(db, snapshot.playground.labs) + upsertAssistantMeta(db, 'playgroundRootPath', snapshot.playground.rootPath || '') for (const session of snapshot.sessions) { upsertAssistantSession(db, session) for (const thread of session.threads) { upsertAssistantThreadSummary(db, session.id, thread) + if (thread.latestTurn) upsertAssistantTurn(db, thread.id, thread.model, thread.latestTurn) replaceAssistantMessages(db, thread) replaceAssistantActivities(db, thread) replaceAssistantProposedPlans(db, thread) @@ -153,11 +174,16 @@ export function upsertAssistantMeta(db: SqlDatabase, key: string, value: string) function upsertAssistantSession(db: SqlDatabase, session: AssistantSession): void { db.run(` - INSERT INTO assistant_sessions (id, title, project_path, archived, created_at, updated_at, active_thread_id) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO assistant_sessions ( + id, title, mode, project_path, playground_lab_id, pending_lab_request_json, archived, created_at, updated_at, active_thread_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, + mode = excluded.mode, project_path = excluded.project_path, + playground_lab_id = excluded.playground_lab_id, + pending_lab_request_json = excluded.pending_lab_request_json, archived = excluded.archived, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -165,7 +191,10 @@ function upsertAssistantSession(db: SqlDatabase, session: AssistantSession): voi `, [ session.id, session.title, + session.mode, session.projectPath, + session.playgroundLabId, + jsonStringify(session.pendingLabRequest), sqlBool(session.archived), session.createdAt, session.updatedAt, @@ -173,6 +202,35 @@ function upsertAssistantSession(db: SqlDatabase, session: AssistantSession): voi ]) } +function replaceAssistantPlaygroundLabs(db: SqlDatabase, labs: AssistantPlaygroundLab[]): void { + db.run('DELETE FROM assistant_playground_labs') + for (const lab of labs) { + upsertAssistantPlaygroundLab(db, lab) + } +} + +function upsertAssistantPlaygroundLab(db: SqlDatabase, lab: AssistantPlaygroundLab): void { + db.run(` + INSERT INTO assistant_playground_labs (id, title, root_path, source, repo_url, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + root_path = excluded.root_path, + source = excluded.source, + repo_url = excluded.repo_url, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `, [ + lab.id, + lab.title, + lab.rootPath, + lab.source, + lab.repoUrl, + lab.createdAt, + lab.updatedAt + ]) +} + function upsertAssistantThreadSummary(db: SqlDatabase, sessionId: string, thread: AssistantThread): void { db.run(` INSERT INTO assistant_threads ( @@ -239,6 +297,47 @@ function replaceAssistantPendingUserInputs(db: SqlDatabase, thread: AssistantThr for (const input of thread.pendingUserInputs) upsertAssistantPendingUserInput(db, thread.id, input) } +function upsertAssistantTurn(db: SqlDatabase, threadId: string, model: string, turn: AssistantLatestTurn): void { + db.run(` + INSERT INTO assistant_turns ( + id, thread_id, model, state, requested_at, started_at, completed_at, + assistant_message_id, effort, service_tier, usage_json, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + thread_id = excluded.thread_id, + model = excluded.model, + state = excluded.state, + requested_at = excluded.requested_at, + started_at = excluded.started_at, + completed_at = excluded.completed_at, + assistant_message_id = excluded.assistant_message_id, + effort = excluded.effort, + service_tier = excluded.service_tier, + usage_json = excluded.usage_json, + updated_at = excluded.updated_at + `, [ + turn.id, + threadId, + model, + turn.state, + turn.requestedAt, + turn.startedAt, + turn.completedAt, + turn.assistantMessageId, + turn.effort || null, + turn.serviceTier || null, + jsonStringify(turn.usage), + turn.completedAt || turn.startedAt || turn.requestedAt + ]) +} + +function deleteAssistantTurns(db: SqlDatabase, turnIds: string[]): void { + if (turnIds.length === 0) return + const placeholders = turnIds.map(() => '?').join(', ') + db.run(`DELETE FROM assistant_turns WHERE id IN (${placeholders})`, turnIds) +} + function upsertAssistantMessage(db: SqlDatabase, threadId: string, message: AssistantMessage): void { db.run(` INSERT INTO assistant_messages (id, thread_id, role, text, turn_id, streaming, created_at, updated_at) diff --git a/src/main/assistant/persistence.ts b/src/main/assistant/persistence.ts index 16e7091..f0f782c 100644 --- a/src/main/assistant/persistence.ts +++ b/src/main/assistant/persistence.ts @@ -6,11 +6,16 @@ import log from 'electron-log' import initSqlJs, { type Database as SqlDatabase } from 'sql.js/dist/sql-asm.js' import type { AssistantDomainEvent, + AssistantSessionTurnUsageEntry, AssistantSnapshot } from '../../shared/assistant/contracts' import { createDefaultSnapshot, recoverPersistedSnapshot } from './projector' import { hydrateFocusedSessionSnapshot } from './persistence-snapshot' -import { readActiveThreadDetails, readAssistantPersistenceRecord } from './persistence-read' +import { + readActiveThreadDetails, + readAssistantPersistenceRecord, + readAssistantSessionTurnUsage +} from './persistence-read' import { initializeAssistantPersistenceSchema, PERSISTENCE_FLUSH_DEBOUNCE_MS, @@ -97,6 +102,11 @@ export class AssistantPersistence { )) } + async readSessionTurnUsage(sessionId: string): Promise { + await this.ensureInitialized() + return this.enqueue(() => readAssistantSessionTurnUsage(this.requireDb(), sessionId)) + } + async flush(): Promise { await this.ensureInitialized() this.clearPendingEventTimer() diff --git a/src/main/assistant/playground-service.ts b/src/main/assistant/playground-service.ts new file mode 100644 index 0000000..848081e --- /dev/null +++ b/src/main/assistant/playground-service.ts @@ -0,0 +1,136 @@ +import { access, mkdir, stat } from 'node:fs/promises' +import { basename, join, relative, resolve } from 'node:path' +import type { AssistantPlaygroundLab } from '../../shared/assistant/contracts' +import { createGit } from '../inspectors/git/core' +import { createAssistantId, nowIso, sanitizeOptionalPath } from './utils' + +function ensureNonEmptyPath(value: string | null | undefined, label: string): string { + const normalized = sanitizeOptionalPath(value) + if (!normalized) throw new Error(`${label} is required.`) + return resolve(normalized) +} + +function sanitizeLabSlug(input: string): string { + return String(input || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) || 'lab' +} + +function isPathInside(parentPath: string, childPath: string): boolean { + const relativePath = relative(parentPath, childPath) + return relativePath === '' || (!relativePath.startsWith('..') && !relativePath.includes(':')) +} + +async function ensureDirectoryExists(directoryPath: string): Promise { + await mkdir(directoryPath, { recursive: true }) + const details = await stat(directoryPath) + if (!details.isDirectory()) { + throw new Error(`Expected a directory at ${directoryPath}.`) + } +} + +async function ensurePathExists(directoryPath: string): Promise { + await access(directoryPath) +} + +async function chooseUniqueChildFolder(rootPath: string, preferredName: string): Promise { + const normalizedRoot = resolve(rootPath) + const baseSlug = sanitizeLabSlug(preferredName) + + for (let index = 0; index < 1000; index += 1) { + const suffix = index === 0 ? '' : `-${index + 1}` + const candidate = join(normalizedRoot, `${baseSlug}${suffix}`) + try { + await access(candidate) + } catch { + return candidate + } + } + + throw new Error('Could not find an available folder name for the new Playground lab.') +} + +export function derivePlaygroundLabTitle(input?: string | null, repoUrl?: string | null, existingFolderPath?: string | null): string { + const explicit = String(input || '').trim() + if (explicit) return explicit + const repoCandidate = String(repoUrl || '').trim() + if (repoCandidate) { + const normalized = repoCandidate.replace(/\/+$/, '') + const name = normalized.split('/').pop()?.replace(/\.git$/i, '').trim() + if (name) return name + } + const existingFolderName = String(existingFolderPath || '').trim() + if (existingFolderName) { + const name = basename(existingFolderName) + if (name) return name + } + return 'Lab' +} + +export async function createPlaygroundLabRecord(params: { + rootPath: string + title?: string + source: AssistantPlaygroundLab['source'] + repoUrl?: string + existingFolderPath?: string +}): Promise { + const rootPath = ensureNonEmptyPath(params.rootPath, 'Playground root') + await ensureDirectoryExists(rootPath) + + const createdAt = nowIso() + const title = derivePlaygroundLabTitle(params.title, params.repoUrl, params.existingFolderPath) + + if (params.source === 'existing-folder') { + const existingFolderPath = ensureNonEmptyPath(params.existingFolderPath, 'Existing folder') + await ensurePathExists(existingFolderPath) + if (!isPathInside(rootPath, existingFolderPath)) { + throw new Error('Existing folder must be inside the Playground root.') + } + return { + id: createAssistantId('assistant-playground-lab'), + title, + rootPath: existingFolderPath, + source: params.source, + repoUrl: null, + createdAt, + updatedAt: createdAt + } + } + + const labFolderPath = await chooseUniqueChildFolder(rootPath, title) + await mkdir(labFolderPath, { recursive: true }) + + if (params.source === 'git-clone') { + const repoUrl = String(params.repoUrl || '').trim() + if (!repoUrl) throw new Error('Repository URL is required to clone a Playground lab.') + await createGit(rootPath).clone(repoUrl, labFolderPath) + return { + id: createAssistantId('assistant-playground-lab'), + title, + rootPath: labFolderPath, + source: params.source, + repoUrl, + createdAt, + updatedAt: createdAt + } + } + + return { + id: createAssistantId('assistant-playground-lab'), + title, + rootPath: labFolderPath, + source: 'empty', + repoUrl: null, + createdAt, + updatedAt: createdAt + } +} + +export function ensurePlaygroundLabExists(labs: AssistantPlaygroundLab[], labId: string): AssistantPlaygroundLab { + const lab = labs.find((entry) => entry.id === labId) || null + if (!lab) throw new Error('Playground lab not found.') + return lab +} diff --git a/src/main/assistant/projector.ts b/src/main/assistant/projector.ts index a0a33ff..fb2ef46 100644 --- a/src/main/assistant/projector.ts +++ b/src/main/assistant/projector.ts @@ -23,8 +23,16 @@ export function recoverPersistedSnapshot(snapshot: AssistantSnapshot): Assistant const recovered = cloneSnapshot(snapshot) const recoveredAt = nowIso() + recovered.playground = { + rootPath: recovered.playground?.rootPath || null, + labs: Array.isArray(recovered.playground?.labs) ? recovered.playground.labs : [] + } + for (const session of recovered.sessions) { + session.mode = session.mode === 'playground' ? 'playground' : 'work' session.updatedAt = session.updatedAt || recoveredAt + session.playgroundLabId = session.playgroundLabId || null + session.pendingLabRequest = session.pendingLabRequest || null session.threadIds = sortThreadsNewestFirst(session.threadIds || [], session.threads || []) if (!session.threadIds.includes(session.activeThreadId || '')) { session.activeThreadId = session.threadIds[0] || null diff --git a/src/main/assistant/service-history.ts b/src/main/assistant/service-history.ts index ce58e2d..3047d9e 100644 --- a/src/main/assistant/service-history.ts +++ b/src/main/assistant/service-history.ts @@ -8,6 +8,7 @@ type UserTurnEntry = { type AssistantDeleteMessagePlan = { rollbackTurnCount: number | null + removedTurnIds: string[] patch: Pick< AssistantThread, | 'messages' @@ -95,6 +96,7 @@ export function buildDeleteMessagePlan(thread: AssistantThread, messageId: strin return { rollbackTurnCount: targetTurnIndex >= 0 ? Math.max(1, orderedTurnIds.length - targetTurnIndex) : null, + removedTurnIds: [...removedTurnIds], patch: { messages: keptMessages, activities: thread.activities.filter((activity) => !activity.turnId || !removedTurnIds.has(activity.turnId)), diff --git a/src/main/assistant/service-records.ts b/src/main/assistant/service-records.ts index db093b5..0fd4e97 100644 --- a/src/main/assistant/service-records.ts +++ b/src/main/assistant/service-records.ts @@ -9,14 +9,19 @@ import type { export function createAssistantSessionRecord(params: { sessionId: string title: string + mode?: AssistantSession['mode'] projectPath: string | null + playgroundLabId?: string | null createdAt: string thread: AssistantThread }): AssistantSession { return { id: params.sessionId, title: params.title, + mode: params.mode || 'work', projectPath: params.projectPath, + playgroundLabId: params.playgroundLabId ?? null, + pendingLabRequest: null, archived: false, createdAt: params.createdAt, updatedAt: params.createdAt, diff --git a/src/main/assistant/service-runtime-events.ts b/src/main/assistant/service-runtime-events.ts index 062129f..bb88836 100644 --- a/src/main/assistant/service-runtime-events.ts +++ b/src/main/assistant/service-runtime-events.ts @@ -291,6 +291,7 @@ export function handleAssistantRuntimeEvent(event: AssistantRuntimeEvent, deps: if (event.type === 'user-input.requested' || event.type === 'user-input.resolved') { const existingThread = deps.requireThread(event.threadId) const current = existingThread.pendingUserInputs.find((entry) => entry.requestId === event.requestId) + const wasAlreadyResolved = current?.status === 'resolved' const userInput: AssistantPendingUserInput = current ? { ...current, @@ -309,6 +310,32 @@ export function handleAssistantRuntimeEvent(event: AssistantRuntimeEvent, deps: resolvedAt: event.type === 'user-input.resolved' ? event.createdAt : null } deps.appendEvent('thread.user-input.updated', event.createdAt, { threadId: event.threadId, userInput }, session.id, event.threadId) + if (event.type === 'user-input.resolved' && !wasAlreadyResolved) { + const answers = event.payload.answers || {} + const answeredCount = Object.values(answers).filter((value) => { + if (Array.isArray(value)) return value.length > 0 + return String(value || '').trim().length > 0 + }).length + deps.appendEvent('thread.activity.appended', event.createdAt, { + threadId: event.threadId, + activity: { + id: createAssistantId('assistant-activity'), + kind: 'user-input.resolved', + tone: 'tool', + summary: 'Consulted user', + detail: `${answeredCount}/${userInput.questions.length} answers captured`, + turnId: event.turnId || null, + createdAt: event.createdAt, + payload: { + requestId: userInput.requestId, + questions: userInput.questions, + answers, + answeredCount, + questionCount: userInput.questions.length + } + } + }, session.id, event.threadId) + } return } diff --git a/src/main/assistant/service.ts b/src/main/assistant/service.ts index 2a5adc9..b0a60be 100644 --- a/src/main/assistant/service.ts +++ b/src/main/assistant/service.ts @@ -1,14 +1,24 @@ +import { app } from 'electron' +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' import log from 'electron-log' import type { AssistantAccountOverview, + AssistantApprovePendingPlaygroundLabRequestInput, + AssistantAttachSessionToPlaygroundLabInput, AssistantClearLogsInput, AssistantConnectOptions, + AssistantCreatePlaygroundLabInput, + AssistantCreateSessionInput, + AssistantDeclinePendingPlaygroundLabRequestInput, AssistantDeleteMessageInput, AssistantDomainEvent, + AssistantGetSessionTurnUsageInput, AssistantLatestTurn, AssistantMessage, AssistantRuntimeStatus, AssistantSendPromptOptions, + AssistantSessionTurnUsagePayload, AssistantThread } from '../../shared/assistant/contracts' import { AssistantTextDeltaBuffer } from './assistant-text-delta-buffer' @@ -30,6 +40,11 @@ import { createAssistantUserMessage, createRunningLatestTurn } from './service-records' +import { + createPlaygroundLabRecord, + derivePlaygroundLabTitle, + ensurePlaygroundLabExists +} from './playground-service' import { type AssistantStateRecord, createAssistantThread, @@ -151,6 +166,39 @@ export class AssistantService { return { success: true as const, overview } } + async getSessionTurnUsage(input?: AssistantGetSessionTurnUsageInput) { + await this.ensureReady() + const session = input?.sessionId + ? requireSession(this.state.snapshot, input.sessionId) + : requireSession(this.state.snapshot, this.state.snapshot.selectedSessionId || '') + const persistedTurns = await this.persistence.readSessionTurnUsage(session.id) + const turnMap = new Map(persistedTurns.map((turn) => [turn.id, turn])) + for (const thread of session.threads) { + if (!thread.latestTurn) continue + turnMap.set(thread.latestTurn.id, { + id: thread.latestTurn.id, + sessionId: session.id, + threadId: thread.id, + model: thread.model, + state: thread.latestTurn.state, + requestedAt: thread.latestTurn.requestedAt, + startedAt: thread.latestTurn.startedAt, + completedAt: thread.latestTurn.completedAt, + assistantMessageId: thread.latestTurn.assistantMessageId, + effort: thread.latestTurn.effort, + serviceTier: thread.latestTurn.serviceTier, + usage: thread.latestTurn.usage || null, + updatedAt: thread.latestTurn.completedAt || thread.latestTurn.startedAt || thread.latestTurn.requestedAt + }) + } + const usage: AssistantSessionTurnUsagePayload = { + sessionId: session.id, + turns: [...turnMap.values()].sort((left, right) => left.requestedAt.localeCompare(right.requestedAt) || left.id.localeCompare(right.id)), + fetchedAt: nowIso() + } + return { success: true as const, usage } + } + async connect(options?: AssistantConnectOptions) { await this.ensureReady() const session = options?.sessionId @@ -159,7 +207,7 @@ export class AssistantService { this.appendEvent(type, occurredAt, payload, sessionId, threadId) }) const thread = requireActiveThread(session) - await this.runtime.connect(thread, session.projectPath || process.cwd()) + await this.runtime.connect(thread, this.getSessionRuntimeCwd(session, thread)) return { success: true as const, threadId: thread.id } } @@ -176,15 +224,23 @@ export class AssistantService { return { success: true as const } } - async createSession(title?: string, projectPath?: string) { + async createSession(input?: AssistantCreateSessionInput) { await this.ensureReady() const createdAt = nowIso() const sessionId = createAssistantId('assistant-session') + const mode = input?.mode === 'playground' ? 'playground' : 'work' + const playgroundLabId = mode === 'playground' ? input?.playgroundLabId || null : null + const playgroundLab = playgroundLabId ? ensurePlaygroundLabExists(this.state.snapshot.playground.labs, playgroundLabId) : null + const projectPath = mode === 'playground' + ? (playgroundLab?.rootPath || null) + : (input?.projectPath?.trim() || null) const thread = createAssistantThread(createdAt, null, projectPath || null) const session = createAssistantSessionRecord({ sessionId, - title: title?.trim() || 'New Session', - projectPath: projectPath?.trim() || null, + title: input?.title?.trim() || (mode === 'playground' ? 'New Playground Chat' : 'New Session'), + mode, + projectPath, + playgroundLabId, createdAt, thread }) @@ -281,7 +337,8 @@ export class AssistantService { this.appendEvent('thread.updated', occurredAt, { threadId: thread.id, - patch: deletePlan.patch + patch: deletePlan.patch, + removedTurnIds: deletePlan.removedTurnIds }, session.id, thread.id) return { success: true as const } @@ -294,12 +351,80 @@ export class AssistantService { sessionId, patch: { projectPath: sanitizeOptionalPath(projectPath), + playgroundLabId: null, + pendingLabRequest: null, updatedAt: nowIso() } }, sessionId) return { success: true as const } } + async setPlaygroundRoot(input: { rootPath: string | null }) { + await this.ensureReady() + const rootPath = sanitizeOptionalPath(input.rootPath) + this.appendEvent('playground.updated', nowIso(), { + playground: { + ...this.state.snapshot.playground, + rootPath + } + }) + return { success: true as const, playground: structuredClone(this.state.snapshot.playground) } + } + + async createPlaygroundLab(input: AssistantCreatePlaygroundLabInput) { + await this.ensureReady() + const rootPath = this.state.snapshot.playground.rootPath + if (!rootPath) throw new Error('Choose a Playground root first.') + + const lab = await createPlaygroundLabRecord({ + rootPath, + title: input.title, + source: input.source, + repoUrl: input.repoUrl, + existingFolderPath: input.existingFolderPath + }) + const nextPlayground = { + ...this.state.snapshot.playground, + labs: [lab, ...this.state.snapshot.playground.labs] + } + const occurredAt = nowIso() + this.appendEvent('playground.updated', occurredAt, { playground: nextPlayground }) + + let sessionId: string | null = null + if (input.openSession) { + const created = await this.createSession({ + title: lab.title, + mode: 'playground', + playgroundLabId: lab.id + }) + sessionId = created.sessionId + } + + return { + success: true as const, + labId: lab.id, + sessionId, + playground: structuredClone(this.state.snapshot.playground) + } + } + + async attachSessionToPlaygroundLab(input: AssistantAttachSessionToPlaygroundLabInput) { + await this.ensureReady() + const session = requireSession(this.state.snapshot, input.sessionId) + const lab = ensurePlaygroundLabExists(this.state.snapshot.playground.labs, input.labId) + this.appendEvent('session.updated', nowIso(), { + sessionId: session.id, + patch: { + mode: 'playground', + projectPath: lab.rootPath, + playgroundLabId: lab.id, + pendingLabRequest: null, + updatedAt: nowIso() + } + }, session.id, session.activeThreadId || undefined) + return { success: true as const, playground: structuredClone(this.state.snapshot.playground) } + } + async newThread(sessionId?: string) { await this.ensureReady() const session = sessionId @@ -340,6 +465,39 @@ export class AssistantService { this.appendEvent(type, occurredAt, payload, sessionId, threadId) }) const thread = requireActiveThread(session) + const labRequest = this.maybeBuildPendingPlaygroundLabRequest(session, input) + if (labRequest) { + const occurredAt = nowIso() + this.appendEvent('session.updated', occurredAt, { + sessionId: session.id, + patch: { + pendingLabRequest: labRequest, + updatedAt: occurredAt + } + }, session.id, thread.id) + this.appendEvent('thread.activity.appended', occurredAt, { + threadId: thread.id, + activity: { + id: createAssistantId('assistant-activity'), + kind: 'playground.lab-requested', + tone: 'info', + summary: labRequest.kind === 'clone-repo' ? 'Playground repo clone requested' : 'Playground lab requested', + detail: labRequest.kind === 'clone-repo' + ? `Approve creating a Playground lab by cloning ${labRequest.repoUrl || 'the provided repository'}.` + : 'Approve creating a Playground lab before filesystem work continues.', + turnId: null, + createdAt: occurredAt, + payload: { + requestId: labRequest.id, + kind: labRequest.kind, + repoUrl: labRequest.repoUrl, + suggestedLabName: labRequest.suggestedLabName + } + } + }, session.id, thread.id) + return { success: true as const, sessionId: session.id, threadId: thread.id, turnId: labRequest.id } + } + const occurredAt = nowIso() const title = isDefaultSessionTitle(session.title) ? deriveSessionTitleFromPrompt(input) : session.title if (title !== session.title) { @@ -355,7 +513,7 @@ export class AssistantService { const userMessage = createAssistantUserMessage(input, occurredAt, createAssistantId('assistant-message')) this.appendEvent('thread.message.user', occurredAt, { threadId: thread.id, message: userMessage }, session.id, thread.id) - const runtimeCwd = session.projectPath || thread.cwd || process.cwd() + const runtimeCwd = this.getSessionRuntimeCwd(session, thread) const updatedThreadPatch: Partial & Pick = { model: options?.model || thread.model, runtimeMode: options?.runtimeMode || thread.runtimeMode, @@ -432,6 +590,66 @@ export class AssistantService { await this.runtime.respondUserInput(target.thread.id, input.requestId, input.answers) return { success: true as const } } + + async approvePendingPlaygroundLabRequest(input: AssistantApprovePendingPlaygroundLabRequestInput) { + await this.ensureReady() + const session = requireSession(this.state.snapshot, input.sessionId) + const pendingLabRequest = session.pendingLabRequest + if (!pendingLabRequest) throw new Error('There is no pending Playground lab request for this chat.') + + const result = await this.createPlaygroundLab({ + title: input.title || pendingLabRequest.suggestedLabName, + source: input.source, + repoUrl: input.repoUrl || pendingLabRequest.repoUrl || undefined, + openSession: false + }) + const lab = ensurePlaygroundLabExists(this.state.snapshot.playground.labs, result.labId) + this.appendEvent('session.updated', nowIso(), { + sessionId: session.id, + patch: { + mode: 'playground', + projectPath: lab.rootPath, + playgroundLabId: lab.id, + pendingLabRequest: null, + updatedAt: nowIso() + } + }, session.id, session.activeThreadId || undefined) + await this.sendPrompt(pendingLabRequest.prompt, { sessionId: session.id }) + return { + success: true as const, + sessionId: session.id, + labId: lab.id, + playground: structuredClone(this.state.snapshot.playground) + } + } + + async declinePendingPlaygroundLabRequest(input: AssistantDeclinePendingPlaygroundLabRequestInput) { + await this.ensureReady() + const session = requireSession(this.state.snapshot, input.sessionId) + if (!session.pendingLabRequest) return { success: true as const } + const thread = requireActiveThread(session) + const occurredAt = nowIso() + this.appendEvent('session.updated', occurredAt, { + sessionId: session.id, + patch: { + pendingLabRequest: null, + updatedAt: occurredAt + } + }, session.id, thread.id) + this.appendEvent('thread.activity.appended', occurredAt, { + threadId: thread.id, + activity: { + id: createAssistantId('assistant-activity'), + kind: 'playground.lab-request.declined', + tone: 'warning', + summary: 'Playground lab request declined', + detail: 'The assistant cannot continue filesystem work for this Playground chat without a lab.', + turnId: null, + createdAt: occurredAt + } + }, session.id, thread.id) + return { success: true as const } + } dispose() { this.assistantTextDeltaBuffer.dispose() this.runtime.dispose() @@ -450,6 +668,43 @@ export class AssistantService { await this.readyPromise } + private getSessionRuntimeCwd(session: ReturnType, thread: AssistantThread): string { + if (session.mode === 'playground') { + return sanitizeOptionalPath(session.projectPath) + || sanitizeOptionalPath(thread.cwd) + || this.getDetachedPlaygroundChatRoot() + } + return session.projectPath || thread.cwd || process.cwd() + } + + private getDetachedPlaygroundChatRoot(): string { + const rootPath = join(app.getPath('userData'), 'assistant', 'playground-chat-only') + mkdirSync(rootPath, { recursive: true }) + return rootPath + } + + private maybeBuildPendingPlaygroundLabRequest(session: ReturnType, prompt: string) { + if (session.mode !== 'playground') return null + if (session.playgroundLabId || sanitizeOptionalPath(session.projectPath)) return null + if (session.pendingLabRequest) return null + + const repoUrlMatch = prompt.match(/https?:\/\/[^\s]+(?:\.git)?/i) + const repoUrl = repoUrlMatch ? repoUrlMatch[0] : null + const needsWorkspace = repoUrl + || /\b(create|build|make|scaffold|generate|implement|code|repo|repository|project|app|workspace|files?)\b/i.test(prompt) + + if (!needsWorkspace) return null + + return { + id: createAssistantId('assistant-playground-lab-request'), + kind: repoUrl ? 'clone-repo' as const : 'create-empty' as const, + prompt, + suggestedLabName: derivePlaygroundLabTitle(undefined, repoUrl, undefined), + repoUrl, + createdAt: nowIso() + } + } + private appendEvent(type: AssistantDomainEvent['type'], occurredAt: string, payload: Record, sessionId?: string, threadId?: string) { const event = createAssistantDomainEvent(this.state.snapshot.snapshotSequence, type, occurredAt, payload, sessionId, threadId) this.state.events.push(event) diff --git a/src/main/assistant/transcription-models.ts b/src/main/assistant/transcription-models.ts new file mode 100644 index 0000000..55e1ea9 --- /dev/null +++ b/src/main/assistant/transcription-models.ts @@ -0,0 +1,255 @@ +import { app } from 'electron' +import { createWriteStream } from 'node:fs' +import { mkdir, rm, stat, unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import type { AssistantTranscriptionModelState } from '../../shared/assistant/contracts' + +const execFileAsync = promisify(execFile) + +const MODEL_ID = 'vosk-model-small-en-us-0.15' +const MODEL_NAME = 'Vosk Small English (US)' +const MODEL_DOWNLOAD_URL = 'https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip' + +const createMissingState = (): AssistantTranscriptionModelState => ({ + provider: 'vosk', + modelId: MODEL_ID, + modelName: MODEL_NAME, + status: 'missing', + installPath: null, + downloadUrl: MODEL_DOWNLOAD_URL, + error: null +}) + +class AssistantTranscriptionModelManager { + private inFlightDownload: Promise | null = null + private inFlightPythonSetup: Promise | null = null + private state: AssistantTranscriptionModelState = createMissingState() + + private getRootDirectory() { + return join(app.getPath('userData'), 'assistant', 'transcription') + } + + private getDownloadsDirectory() { + return join(this.getRootDirectory(), 'downloads') + } + + private getModelsDirectory() { + return join(this.getRootDirectory(), 'models') + } + + private getArchivePath() { + return join(this.getDownloadsDirectory(), `${MODEL_ID}.zip`) + } + + private getInstallPath() { + return join(this.getModelsDirectory(), MODEL_ID) + } + + private getRuntimeDirectory() { + return join(this.getRootDirectory(), 'runtime') + } + + private getRunnerPath() { + return join(this.getRuntimeDirectory(), 'vosk_transcribe.py') + } + + private async detectInstalledState(): Promise { + const installPath = this.getInstallPath() + try { + const installStats = await stat(installPath) + if (!installStats.isDirectory()) throw new Error('Installed model path is not a directory.') + return { + provider: 'vosk', + modelId: MODEL_ID, + modelName: MODEL_NAME, + status: 'ready', + installPath, + downloadUrl: MODEL_DOWNLOAD_URL, + error: null + } + } catch { + return createMissingState() + } + } + + async getState(): Promise { + if (this.state.status === 'downloading') return this.state + this.state = await this.detectInstalledState() + return this.state + } + + async downloadModel(): Promise { + if (this.inFlightDownload) return this.inFlightDownload + + this.inFlightDownload = this.performDownload().finally(() => { + this.inFlightDownload = null + }) + + return this.inFlightDownload + } + + private async performDownload(): Promise { + const archivePath = this.getArchivePath() + const installPath = this.getInstallPath() + + this.state = { + provider: 'vosk', + modelId: MODEL_ID, + modelName: MODEL_NAME, + status: 'downloading', + installPath: null, + downloadUrl: MODEL_DOWNLOAD_URL, + error: null + } + + try { + await mkdir(this.getDownloadsDirectory(), { recursive: true }) + await mkdir(this.getModelsDirectory(), { recursive: true }) + await rm(installPath, { recursive: true, force: true }) + await unlink(archivePath).catch(() => undefined) + + const response = await fetch(MODEL_DOWNLOAD_URL) + if (!response.ok || !response.body) { + throw new Error(`Model download failed (${response.status}).`) + } + + await pipeline(Readable.fromWeb(response.body as any), createWriteStream(archivePath)) + await execFileAsync('powershell.exe', [ + '-NoProfile', + '-Command', + `Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${this.getModelsDirectory().replace(/'/g, "''")}' -Force` + ]) + await unlink(archivePath).catch(() => undefined) + + this.state = await this.detectInstalledState() + return this.state + } catch (error) { + this.state = { + provider: 'vosk', + modelId: MODEL_ID, + modelName: MODEL_NAME, + status: 'error', + installPath: null, + downloadUrl: MODEL_DOWNLOAD_URL, + error: error instanceof Error ? error.message : 'Failed to download transcription model.' + } + return this.state + } + } + + private async ensurePythonPackage() { + if (this.inFlightPythonSetup) return this.inFlightPythonSetup + this.inFlightPythonSetup = (async () => { + try { + await execFileAsync('python', ['-c', 'import vosk']) + return + } catch {} + + await execFileAsync('python', ['-m', 'pip', 'install', '--user', 'vosk']) + })().finally(() => { + this.inFlightPythonSetup = null + }) + + return this.inFlightPythonSetup + } + + private async ensureRunnerScript() { + await mkdir(this.getRuntimeDirectory(), { recursive: true }) + const runnerPath = this.getRunnerPath() + const script = ` +import json +import sys +import wave + +from vosk import KaldiRecognizer, Model, SetLogLevel + +SetLogLevel(-1) + +def fail(message: str): + print(json.dumps({"success": False, "error": message})) + raise SystemExit(0) + +if len(sys.argv) < 3: + fail("Missing transcription arguments.") + +model_path = sys.argv[1] +audio_path = sys.argv[2] + +try: + wf = wave.open(audio_path, "rb") +except Exception as exc: + fail(f"Failed to open audio: {exc}") + +if wf.getnchannels() != 1 or wf.getsampwidth() != 2: + fail("Audio must be mono PCM16 WAV.") + +try: + model = Model(model_path) + rec = KaldiRecognizer(model, wf.getframerate()) +except Exception as exc: + fail(f"Failed to initialize Vosk: {exc}") + +parts = [] +while True: + data = wf.readframes(4000) + if len(data) == 0: + break + if rec.AcceptWaveform(data): + try: + value = json.loads(rec.Result()).get("text", "").strip() + if value: + parts.append(value) + except Exception: + pass + +try: + final_value = json.loads(rec.FinalResult()).get("text", "").strip() + if final_value: + parts.append(final_value) +except Exception: + pass + +print(json.dumps({"success": True, "text": " ".join(part for part in parts if part).strip()})) +`.trim() + await writeFile(runnerPath, script, 'utf8') + return runnerPath + } + + async transcribeWav(audioBuffer: ArrayBuffer): Promise { + const state = await this.getState() + if (state.status !== 'ready' || !state.installPath) { + throw new Error('Local Vosk model is not installed yet.') + } + + await this.ensurePythonPackage() + const runnerPath = await this.ensureRunnerScript() + const audioPath = join(this.getRuntimeDirectory(), `input-${Date.now()}.wav`) + + try { + await writeFile(audioPath, Buffer.from(audioBuffer)) + const { stdout } = await execFileAsync('python', [runnerPath, state.installPath, audioPath], { + maxBuffer: 1024 * 1024 * 8 + }) + const parsed = JSON.parse(String(stdout || '{}')) as { success?: boolean; text?: string; error?: string } + if (!parsed.success) { + throw new Error(parsed.error || 'Local transcription failed.') + } + return String(parsed.text || '').trim() + } finally { + await unlink(audioPath).catch(() => undefined) + } + } +} + +let transcriptionModelManager: AssistantTranscriptionModelManager | null = null + +export function getAssistantTranscriptionModelManager() { + if (!transcriptionModelManager) { + transcriptionModelManager = new AssistantTranscriptionModelManager() + } + return transcriptionModelManager +} diff --git a/src/main/ipc/handlers/assistant-handlers.ts b/src/main/ipc/handlers/assistant-handlers.ts index 24aa539..61aa2f5 100644 --- a/src/main/ipc/handlers/assistant-handlers.ts +++ b/src/main/ipc/handlers/assistant-handlers.ts @@ -1,15 +1,24 @@ import log from 'electron-log' import type { AssistantApprovalResponseInput, + AssistantApprovePendingPlaygroundLabRequestInput, + AssistantAttachSessionToPlaygroundLabInput, AssistantClearLogsInput, AssistantConnectOptions, + AssistantCreatePlaygroundLabInput, + AssistantCreateSessionInput, + AssistantDeclinePendingPlaygroundLabRequestInput, AssistantDeleteMessageInput, + AssistantGetSessionTurnUsageInput, AssistantPersistClipboardImageInput, AssistantSendPromptOptions, + AssistantSetPlaygroundRootInput, + AssistantTranscribeAudioInput, AssistantUserInputResponseInput } from '../../../shared/assistant/contracts' import { getAssistantService } from '../../assistant' import { persistAssistantClipboardImage } from '../../assistant/clipboard-attachments' +import { getAssistantTranscriptionModelManager } from '../../assistant/transcription-models' async function withAssistantResult(work: () => Promise | T): Promise { try { @@ -47,6 +56,11 @@ export function handleAssistantGetAccountOverview() { return withAssistantResult(() => getAssistantService().getAccountOverview()) } +export function handleAssistantGetSessionTurnUsage(_event: Electron.IpcMainInvokeEvent, input?: AssistantGetSessionTurnUsageInput) { + log.info('IPC: assistant:getSessionTurnUsage', { sessionId: input?.sessionId }) + return withAssistantResult(() => getAssistantService().getSessionTurnUsage(input)) +} + export function handleAssistantListModels(_event: Electron.IpcMainInvokeEvent, forceRefresh?: boolean) { log.info('IPC: assistant:listModels', { forceRefresh: Boolean(forceRefresh) }) return withAssistantResult(() => getAssistantService().listModels(Boolean(forceRefresh))) @@ -62,9 +76,9 @@ export function handleAssistantDisconnect(_event: Electron.IpcMainInvokeEvent, s return withAssistantResult(() => getAssistantService().disconnect(sessionId)) } -export function handleAssistantCreateSession(_event: Electron.IpcMainInvokeEvent, title?: string, projectPath?: string) { - log.info('IPC: assistant:createSession', { title, projectPath }) - return withAssistantResult(() => getAssistantService().createSession(title, projectPath)) +export function handleAssistantCreateSession(_event: Electron.IpcMainInvokeEvent, input?: AssistantCreateSessionInput) { + log.info('IPC: assistant:createSession', { input }) + return withAssistantResult(() => getAssistantService().createSession(input)) } export function handleAssistantSelectSession(_event: Electron.IpcMainInvokeEvent, sessionId: string) { @@ -102,6 +116,31 @@ export function handleAssistantSetSessionProjectPath(_event: Electron.IpcMainInv return withAssistantResult(() => getAssistantService().setSessionProjectPath(sessionId, projectPath)) } +export function handleAssistantSetPlaygroundRoot(_event: Electron.IpcMainInvokeEvent, input: AssistantSetPlaygroundRootInput) { + log.info('IPC: assistant:setPlaygroundRoot', { hasRootPath: Boolean(input?.rootPath) }) + return withAssistantResult(() => getAssistantService().setPlaygroundRoot(input)) +} + +export function handleAssistantCreatePlaygroundLab(_event: Electron.IpcMainInvokeEvent, input: AssistantCreatePlaygroundLabInput) { + log.info('IPC: assistant:createPlaygroundLab', { source: input?.source, openSession: input?.openSession === true }) + return withAssistantResult(() => getAssistantService().createPlaygroundLab(input)) +} + +export function handleAssistantAttachSessionToPlaygroundLab(_event: Electron.IpcMainInvokeEvent, input: AssistantAttachSessionToPlaygroundLabInput) { + log.info('IPC: assistant:attachSessionToPlaygroundLab', { sessionId: input?.sessionId, labId: input?.labId }) + return withAssistantResult(() => getAssistantService().attachSessionToPlaygroundLab(input)) +} + +export function handleAssistantApprovePendingPlaygroundLabRequest(_event: Electron.IpcMainInvokeEvent, input: AssistantApprovePendingPlaygroundLabRequestInput) { + log.info('IPC: assistant:approvePendingPlaygroundLabRequest', { sessionId: input?.sessionId, source: input?.source }) + return withAssistantResult(() => getAssistantService().approvePendingPlaygroundLabRequest(input)) +} + +export function handleAssistantDeclinePendingPlaygroundLabRequest(_event: Electron.IpcMainInvokeEvent, input: AssistantDeclinePendingPlaygroundLabRequestInput) { + log.info('IPC: assistant:declinePendingPlaygroundLabRequest', { sessionId: input?.sessionId }) + return withAssistantResult(() => getAssistantService().declinePendingPlaygroundLabRequest(input)) +} + export function handleAssistantPersistClipboardImage(_event: Electron.IpcMainInvokeEvent, input: AssistantPersistClipboardImageInput) { return withAssistantResult(async () => ({ success: true as const, @@ -133,3 +172,29 @@ export function handleAssistantRespondUserInput(_event: Electron.IpcMainInvokeEv log.info('IPC: assistant:respondUserInput', { requestId: input?.requestId }) return withAssistantResult(() => getAssistantService().respondUserInput(input)) } + +export function handleAssistantGetTranscriptionModelState() { + log.info('IPC: assistant:getTranscriptionModelState') + return withAssistantResult(async () => ({ + success: true as const, + state: await getAssistantTranscriptionModelManager().getState() + })) +} + +export function handleAssistantDownloadTranscriptionModel() { + log.info('IPC: assistant:downloadTranscriptionModel') + return withAssistantResult(async () => ({ + success: true as const, + state: await getAssistantTranscriptionModelManager().downloadModel() + })) +} + +export function handleAssistantTranscribeAudioWithLocalModel(_event: Electron.IpcMainInvokeEvent, input: AssistantTranscribeAudioInput) { + log.info('IPC: assistant:transcribeAudioWithLocalModel', { + byteLength: input?.audioBuffer ? input.audioBuffer.byteLength : 0 + }) + return withAssistantResult(async () => ({ + success: true as const, + text: await getAssistantTranscriptionModelManager().transcribeWav(input.audioBuffer) + })) +} diff --git a/src/preload/adapters/assistant-adapter.ts b/src/preload/adapters/assistant-adapter.ts index 540e21e..eb86188 100644 --- a/src/preload/adapters/assistant-adapter.ts +++ b/src/preload/adapters/assistant-adapter.ts @@ -1,12 +1,19 @@ import { ipcRenderer } from 'electron' import type { AssistantApprovalResponseInput, + AssistantApprovePendingPlaygroundLabRequestInput, + AssistantAttachSessionToPlaygroundLabInput, AssistantClearLogsInput, AssistantConnectOptions, + AssistantCreatePlaygroundLabInput, + AssistantCreateSessionInput, + AssistantDeclinePendingPlaygroundLabRequestInput, AssistantDeleteMessageInput, AssistantEventStreamPayload, AssistantPersistClipboardImageInput, AssistantSendPromptOptions, + AssistantSetPlaygroundRootInput, + AssistantTranscribeAudioInput, AssistantUserInputResponseInput } from '../../shared/assistant/contracts' import { ASSISTANT_IPC, assertAssistantIpcContract } from '../../shared/assistant/contracts' @@ -15,10 +22,6 @@ export function createAssistantAdapter() { assertAssistantIpcContract() return { - getAIRuntimeStatus: async () => { - const status = await ipcRenderer.invoke(ASSISTANT_IPC.getStatus) - return { success: true as const, status } - }, assistant: { subscribe: () => ipcRenderer.invoke(ASSISTANT_IPC.subscribe), unsubscribe: () => ipcRenderer.invoke(ASSISTANT_IPC.unsubscribe), @@ -26,10 +29,11 @@ export function createAssistantAdapter() { getSnapshot: () => ipcRenderer.invoke(ASSISTANT_IPC.getSnapshot), getStatus: () => ipcRenderer.invoke(ASSISTANT_IPC.getStatus), getAccountOverview: () => ipcRenderer.invoke(ASSISTANT_IPC.getAccountOverview), + getSessionTurnUsage: (input?: { sessionId?: string }) => ipcRenderer.invoke(ASSISTANT_IPC.getSessionTurnUsage, input), listModels: (forceRefresh = false) => ipcRenderer.invoke(ASSISTANT_IPC.listModels, forceRefresh), connect: (options?: AssistantConnectOptions) => ipcRenderer.invoke(ASSISTANT_IPC.connect, options), disconnect: (sessionId?: string) => ipcRenderer.invoke(ASSISTANT_IPC.disconnect, sessionId), - createSession: (title?: string, projectPath?: string) => ipcRenderer.invoke(ASSISTANT_IPC.createSession, title, projectPath), + createSession: (input?: AssistantCreateSessionInput) => ipcRenderer.invoke(ASSISTANT_IPC.createSession, input), selectSession: (sessionId: string) => ipcRenderer.invoke(ASSISTANT_IPC.selectSession, sessionId), renameSession: (sessionId: string, title: string) => ipcRenderer.invoke(ASSISTANT_IPC.renameSession, sessionId, title), archiveSession: (sessionId: string, archived = true) => ipcRenderer.invoke(ASSISTANT_IPC.archiveSession, sessionId, archived), @@ -38,6 +42,16 @@ export function createAssistantAdapter() { clearLogs: (input?: AssistantClearLogsInput) => ipcRenderer.invoke(ASSISTANT_IPC.clearLogs, input), setSessionProjectPath: (sessionId: string, projectPath: string | null) => ipcRenderer.invoke(ASSISTANT_IPC.setSessionProjectPath, sessionId, projectPath), + setPlaygroundRoot: (input: AssistantSetPlaygroundRootInput) => + ipcRenderer.invoke(ASSISTANT_IPC.setPlaygroundRoot, input), + createPlaygroundLab: (input: AssistantCreatePlaygroundLabInput) => + ipcRenderer.invoke(ASSISTANT_IPC.createPlaygroundLab, input), + attachSessionToPlaygroundLab: (input: AssistantAttachSessionToPlaygroundLabInput) => + ipcRenderer.invoke(ASSISTANT_IPC.attachSessionToPlaygroundLab, input), + approvePendingPlaygroundLabRequest: (input: AssistantApprovePendingPlaygroundLabRequestInput) => + ipcRenderer.invoke(ASSISTANT_IPC.approvePendingPlaygroundLabRequest, input), + declinePendingPlaygroundLabRequest: (input: AssistantDeclinePendingPlaygroundLabRequestInput) => + ipcRenderer.invoke(ASSISTANT_IPC.declinePendingPlaygroundLabRequest, input), persistClipboardImage: (input: AssistantPersistClipboardImageInput) => ipcRenderer.invoke(ASSISTANT_IPC.persistClipboardImage, input), newThread: (sessionId?: string) => ipcRenderer.invoke(ASSISTANT_IPC.newThread, sessionId), @@ -47,6 +61,9 @@ export function createAssistantAdapter() { ipcRenderer.invoke(ASSISTANT_IPC.respondApproval, input), respondUserInput: (input: AssistantUserInputResponseInput) => ipcRenderer.invoke(ASSISTANT_IPC.respondUserInput, input), + getTranscriptionModelState: () => ipcRenderer.invoke(ASSISTANT_IPC.getTranscriptionModelState), + downloadTranscriptionModel: () => ipcRenderer.invoke(ASSISTANT_IPC.downloadTranscriptionModel), + transcribeAudioWithLocalModel: (input: AssistantTranscribeAudioInput) => ipcRenderer.invoke(ASSISTANT_IPC.transcribeAudioWithLocalModel, input), onEvent: (callback: (payload: AssistantEventStreamPayload) => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: AssistantEventStreamPayload) => { callback(payload) diff --git a/src/preload/adapters/disabled-adapters.ts b/src/preload/adapters/disabled-adapters.ts index 4149bbd..2d34d3d 100644 --- a/src/preload/adapters/disabled-adapters.ts +++ b/src/preload/adapters/disabled-adapters.ts @@ -32,8 +32,6 @@ export function createDisabledAdapters() { onSessionClosed: () => () => { }, onOutput: () => () => { }, onStatusChange: () => () => { } - }, - getAIRuntimeStatus: () => Promise.resolve(disabledFeature('AI Runtime')), - getAIAgents: () => Promise.resolve({ success: true, agents: [] }) + } } } diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index 6515b24..9d92701 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -368,6 +368,29 @@ } } +@keyframes subtle-recording-ripple { + 0% { + opacity: 0; + transform: scale(1); + } + 30% { + opacity: 1; + } + 100% { + opacity: 0; + transform: scale(1.14); + } +} + +.animate-subtle-recording-ripple { + animation: subtle-recording-ripple 1.35s ease-out infinite; +} + +.animate-subtle-recording-ripple-delayed { + animation: subtle-recording-ripple 1.35s ease-out infinite; + animation-delay: 0.4s; +} + @keyframes modalBackdropIn { from { opacity: 0; diff --git a/src/renderer/src/lib/assistant/assistant-store-core.ts b/src/renderer/src/lib/assistant/assistant-store-core.ts index 0942a3c..77a7142 100644 --- a/src/renderer/src/lib/assistant/assistant-store-core.ts +++ b/src/renderer/src/lib/assistant/assistant-store-core.ts @@ -1,7 +1,12 @@ import type { AssistantApprovalResponseInput, + AssistantApprovePendingPlaygroundLabRequestInput, + AssistantAttachSessionToPlaygroundLabInput, AssistantClearLogsInput, AssistantConnectOptions, + AssistantCreatePlaygroundLabInput, + AssistantCreateSessionInput, + AssistantDeclinePendingPlaygroundLabRequestInput, AssistantDeleteMessageInput, AssistantDomainEvent, AssistantModelInfo, @@ -16,6 +21,7 @@ import { collapseAssistantDeltaEvents } from './event-batching' import { applyCachedSessionSelection, cacheHydratedSelectedSession, + hasCachedSessionSelection, type CachedHydratedThreadState } from './session-hydration-cache' @@ -38,6 +44,8 @@ const INITIAL_STATUS: AssistantRuntimeStatus = { reason: null } +const ASSISTANT_DELTA_EVENT_FLUSH_DELAY_MS = 64 + function deriveAssistantRuntimeStatus(snapshot: AssistantSnapshot, currentStatus: AssistantRuntimeStatus): AssistantRuntimeStatus { const selectedSession = snapshot.sessions.find((session) => session.id === snapshot.selectedSessionId) || null const activeThread = selectedSession?.threads.find((thread) => thread.id === selectedSession.activeThreadId) || null @@ -70,6 +78,7 @@ class AssistantStore { private modelRefreshPromise: Promise> | null = null private pendingAssistantEvents: AssistantDomainEvent[] = [] private pendingAssistantEventFlushFrame: number | null = null + private pendingAssistantEventFlushTimeout: number | null = null subscribe = (listener: () => void) => { this.listeners.add(listener) @@ -173,8 +182,8 @@ class AssistantStore { await this.hydrate() } - async createSession(title?: string, projectPath?: string) { - return this.runAction(() => window.devscope.assistant.createSession(title, projectPath), true) + async createSession(input?: AssistantCreateSessionInput) { + return this.runAction(() => window.devscope.assistant.createSession(input), true) } async selectSession(sessionId: string, options?: { force?: boolean }) { @@ -183,11 +192,12 @@ class AssistantStore { return { success: true as const, snapshot: this.state.snapshot } } + const canHydrateFromCache = hasCachedSessionSelection(this.state.snapshot, sessionId, this.hydratedSessionCache) this.setState((current) => { const snapshot = applyCachedSessionSelection(current.snapshot, sessionId, this.hydratedSessionCache) return { error: null, - commandPending: true, + commandPending: !canHydrateFromCache, snapshot, status: deriveAssistantRuntimeStatus(snapshot, current.status) } @@ -236,6 +246,26 @@ class AssistantStore { return this.runAction(() => window.devscope.assistant.setSessionProjectPath(sessionId, projectPath), false) } + async setPlaygroundRoot(rootPath: string | null) { + return this.runAction(() => window.devscope.assistant.setPlaygroundRoot({ rootPath }), true) + } + + async createPlaygroundLab(input: AssistantCreatePlaygroundLabInput) { + return this.runAction(() => window.devscope.assistant.createPlaygroundLab(input), true) + } + + async attachSessionToPlaygroundLab(input: AssistantAttachSessionToPlaygroundLabInput) { + return this.runAction(() => window.devscope.assistant.attachSessionToPlaygroundLab(input), true) + } + + async approvePendingPlaygroundLabRequest(input: AssistantApprovePendingPlaygroundLabRequestInput) { + return this.runAction(() => window.devscope.assistant.approvePendingPlaygroundLabRequest(input), true) + } + + async declinePendingPlaygroundLabRequest(input: AssistantDeclinePendingPlaygroundLabRequestInput) { + return this.runAction(() => window.devscope.assistant.declinePendingPlaygroundLabRequest(input), true) + } + async newThread(sessionId?: string) { return this.runAction(() => window.devscope.assistant.newThread(sessionId), true) } @@ -321,6 +351,19 @@ class AssistantStore { private queueAssistantEvent(event: AssistantDomainEvent) { this.pendingAssistantEvents.push(event) + if (event.type === 'thread.message.assistant.delta') { + if (this.pendingAssistantEventFlushFrame !== null || this.pendingAssistantEventFlushTimeout !== null) return + this.pendingAssistantEventFlushTimeout = window.setTimeout(() => { + this.pendingAssistantEventFlushTimeout = null + this.flushPendingAssistantEvents() + }, ASSISTANT_DELTA_EVENT_FLUSH_DELAY_MS) + return + } + + if (this.pendingAssistantEventFlushTimeout !== null) { + window.clearTimeout(this.pendingAssistantEventFlushTimeout) + this.pendingAssistantEventFlushTimeout = null + } if (this.pendingAssistantEventFlushFrame !== null) return this.pendingAssistantEventFlushFrame = window.requestAnimationFrame(() => { @@ -334,6 +377,10 @@ class AssistantStore { window.cancelAnimationFrame(this.pendingAssistantEventFlushFrame) this.pendingAssistantEventFlushFrame = null } + if (this.pendingAssistantEventFlushTimeout !== null) { + window.clearTimeout(this.pendingAssistantEventFlushTimeout) + this.pendingAssistantEventFlushTimeout = null + } if (this.pendingAssistantEvents.length === 0) return const queuedEvents = collapseAssistantDeltaEvents(this.pendingAssistantEvents) @@ -352,10 +399,17 @@ class AssistantStore { window.cancelAnimationFrame(this.pendingAssistantEventFlushFrame) this.pendingAssistantEventFlushFrame = null } + if (this.pendingAssistantEventFlushTimeout !== null) { + window.clearTimeout(this.pendingAssistantEventFlushTimeout) + this.pendingAssistantEventFlushTimeout = null + } this.pendingAssistantEvents = [] } - private async runAction(work: () => Promise, _refreshStatusAfter: boolean) { + private async runAction>( + work: () => Promise>, + _refreshStatusAfter: boolean + ): Promise> { this.setState({ error: null, commandPending: true }) try { const result = await work() @@ -367,7 +421,7 @@ class AssistantStore { } catch (error) { const message = error instanceof Error ? error.message : 'Assistant command failed.' this.setState({ error: message }) - return { success: false as const, error: message } as T + return { success: false as const, error: message } } finally { this.setState({ commandPending: false }) } diff --git a/src/renderer/src/lib/assistant/assistant-store-hooks.ts b/src/renderer/src/lib/assistant/assistant-store-hooks.ts index 4a3d85c..7b906d4 100644 --- a/src/renderer/src/lib/assistant/assistant-store-hooks.ts +++ b/src/renderer/src/lib/assistant/assistant-store-hooks.ts @@ -1,8 +1,13 @@ import { useEffect, useRef, useSyncExternalStore } from 'react' import type { AssistantApprovalResponseInput, + AssistantApprovePendingPlaygroundLabRequestInput, + AssistantAttachSessionToPlaygroundLabInput, AssistantLatestTurn, AssistantModelInfo, + AssistantCreatePlaygroundLabInput, + AssistantCreateSessionInput, + AssistantDeclinePendingPlaygroundLabRequestInput, AssistantSendPromptOptions, AssistantSnapshot } from '@shared/assistant/contracts' @@ -41,8 +46,46 @@ type AssistantWorkspaceSelection = { phaseLabel: string } +type AssistantPageSelection = { + available: boolean + connected: boolean + loading: boolean + bootstrapped: boolean + commandPending: boolean + commandError: string | null + selectedSession: ReturnType + activeThread: ReturnType + activityFeed: ReturnType + pendingApprovals: ReturnType + pendingUserInputs: ReturnType + activePlan: ReturnType + latestProposedPlan: ReturnType + phase: ReturnType + phaseLabel: string +} + +type AssistantConversationSelection = { + knownModels: AssistantModelInfo[] + available: boolean + connected: boolean + loading: boolean + modelsLoading: boolean + commandPending: boolean + selectedSession: ReturnType + activeThread: ReturnType + timelineMessages: ReturnType + activityFeed: ReturnType + pendingUserInputs: ReturnType + activePlan: ReturnType + latestProposedPlan: ReturnType + phase: ReturnType + phaseLabel: string +} + type AssistantSessionsRailSelection = { + snapshot: AssistantSnapshot sessions: AssistantSnapshot['sessions'] + playground: AssistantSnapshot['playground'] activeSessionId: string | null commandPending: boolean } @@ -89,7 +132,12 @@ function areAssistantSessionsEqual( if (!left || !right) return left === right return left.id === right.id && left.title === right.title + && left.mode === right.mode && left.projectPath === right.projectPath + && left.playgroundLabId === right.playgroundLabId + && left.pendingLabRequest?.id === right.pendingLabRequest?.id + && left.pendingLabRequest?.kind === right.pendingLabRequest?.kind + && left.pendingLabRequest?.repoUrl === right.pendingLabRequest?.repoUrl && left.archived === right.archived && left.createdAt === right.createdAt && left.updatedAt === right.updatedAt @@ -217,6 +265,42 @@ function areAssistantWorkspaceSelectionsEqual(left: AssistantWorkspaceSelection, && areAssistantLatestProposedPlansEqual(left.latestProposedPlan, right.latestProposedPlan) } +function areAssistantPageSelectionsEqual(left: AssistantPageSelection, right: AssistantPageSelection): boolean { + return left.available === right.available + && left.connected === right.connected + && left.loading === right.loading + && left.bootstrapped === right.bootstrapped + && left.commandPending === right.commandPending + && left.commandError === right.commandError + && left.phase.key === right.phase.key + && left.phase.label === right.phase.label + && areAssistantSessionsEqual(left.selectedSession, right.selectedSession) + && areAssistantThreadsEqual(left.activeThread, right.activeThread) + && getActivityListSignature(left.activityFeed) === getActivityListSignature(right.activityFeed) + && getPendingApprovalSignature(left.pendingApprovals) === getPendingApprovalSignature(right.pendingApprovals) + && getPendingUserInputSignature(left.pendingUserInputs) === getPendingUserInputSignature(right.pendingUserInputs) + && areAssistantPlansEqual(left.activePlan, right.activePlan) + && areAssistantLatestProposedPlansEqual(left.latestProposedPlan, right.latestProposedPlan) +} + +function areAssistantConversationSelectionsEqual(left: AssistantConversationSelection, right: AssistantConversationSelection): boolean { + return left.available === right.available + && left.connected === right.connected + && left.loading === right.loading + && left.modelsLoading === right.modelsLoading + && left.commandPending === right.commandPending + && left.phase.key === right.phase.key + && left.phase.label === right.phase.label + && areAssistantModelsEqual(left.knownModels, right.knownModels) + && areAssistantSessionsEqual(left.selectedSession, right.selectedSession) + && areAssistantThreadsEqual(left.activeThread, right.activeThread) + && getMessageListSignature(left.timelineMessages) === getMessageListSignature(right.timelineMessages) + && getActivityListSignature(left.activityFeed) === getActivityListSignature(right.activityFeed) + && getPendingUserInputSignature(left.pendingUserInputs) === getPendingUserInputSignature(right.pendingUserInputs) + && areAssistantPlansEqual(left.activePlan, right.activePlan) + && areAssistantLatestProposedPlansEqual(left.latestProposedPlan, right.latestProposedPlan) +} + function getRailSessionSignature(session: AssistantSnapshot['sessions'][number]): string { const activeThread = session.threads.find((thread) => thread.id === session.activeThreadId) || null const earliestCreatedThread = session.threads.reduce((earliest, thread) => { @@ -229,7 +313,11 @@ function getRailSessionSignature(session: AssistantSnapshot['sessions'][number]) return [ session.id, session.title, + session.mode, session.projectPath || '', + session.playgroundLabId || '', + session.pendingLabRequest?.id || '', + session.pendingLabRequest?.kind || '', session.archived ? '1' : '0', session.createdAt, session.activeThreadId || '', @@ -248,6 +336,22 @@ function areAssistantSessionsRailSelectionsEqual(left: AssistantSessionsRailSele if (left.activeSessionId !== right.activeSessionId || left.commandPending !== right.commandPending) { return false } + if (left.playground.rootPath !== right.playground.rootPath || left.playground.labs.length !== right.playground.labs.length) { + return false + } + for (let index = 0; index < left.playground.labs.length; index += 1) { + const leftLab = left.playground.labs[index] + const rightLab = right.playground.labs[index] + if ( + leftLab?.id !== rightLab?.id + || leftLab?.title !== rightLab?.title + || leftLab?.rootPath !== rightLab?.rootPath + || leftLab?.updatedAt !== rightLab?.updatedAt + || leftLab?.repoUrl !== rightLab?.repoUrl + ) { + return false + } + } if (left.sessions.length !== right.sessions.length) return false for (let index = 0; index < left.sessions.length; index += 1) { if (getRailSessionSignature(left.sessions[index]) !== getRailSessionSignature(right.sessions[index])) { @@ -298,7 +402,7 @@ export function useAssistantStoreLifecycle() { const assistantStoreActions = { refresh: () => assistantStore.refresh(), refreshModels: () => assistantStore.refreshModels(true), - createSession: (title?: string, projectPath?: string) => assistantStore.createSession(title, projectPath).then(() => undefined), + createSession: (input?: AssistantCreateSessionInput) => assistantStore.createSession(input).then(() => undefined), selectSession: (sessionId: string, options?: { force?: boolean }) => assistantStore.selectSession(sessionId, options).then(() => undefined), renameSession: (sessionId: string, title: string) => assistantStore.renameSession(sessionId, title).then(() => undefined), archiveSession: (sessionId: string, archived = true) => assistantStore.archiveSession(sessionId, archived).then(() => undefined), @@ -309,6 +413,12 @@ const assistantStoreActions = { clearLogsResult: (sessionId?: string) => assistantStore.clearLogs(sessionId ? { sessionId } : undefined), clearCommandError: () => assistantStore.clearError(), setSessionProjectPath: (sessionId: string, projectPath: string | null) => assistantStore.setSessionProjectPath(sessionId, projectPath).then(() => undefined), + setPlaygroundRoot: (rootPath: string | null) => assistantStore.setPlaygroundRoot(rootPath).then(() => undefined), + createPlaygroundLab: (input: AssistantCreatePlaygroundLabInput) => assistantStore.createPlaygroundLab(input).then(() => undefined), + createPlaygroundLabResult: (input: AssistantCreatePlaygroundLabInput) => assistantStore.createPlaygroundLab(input), + attachSessionToPlaygroundLab: (input: AssistantAttachSessionToPlaygroundLabInput) => assistantStore.attachSessionToPlaygroundLab(input).then(() => undefined), + approvePendingPlaygroundLabRequest: (input: AssistantApprovePendingPlaygroundLabRequestInput) => assistantStore.approvePendingPlaygroundLabRequest(input).then(() => undefined), + declinePendingPlaygroundLabRequest: (input: AssistantDeclinePendingPlaygroundLabRequestInput) => assistantStore.declinePendingPlaygroundLabRequest(input).then(() => undefined), newThread: (sessionId?: string) => assistantStore.newThread(sessionId).then(() => undefined), sendPrompt: (prompt: string, options?: AssistantSendPromptOptions) => assistantStore.sendPrompt(prompt, options).then(() => undefined), sendPromptResult: (prompt: string, options?: AssistantSendPromptOptions) => assistantStore.sendPrompt(prompt, options), @@ -323,12 +433,19 @@ const assistantStoreActions = { createProjectSession: () => assistantStore.createProjectSession().then(() => undefined) } +export function useAssistantStoreActions() { + useAssistantStoreLifecycle() + return assistantStoreActions +} + export function useAssistantSessionsRailStore() { useAssistantStoreLifecycle() const rail = useAssistantStoreSelector((state) => ({ + snapshot: state.snapshot, sessions: state.snapshot.sessions, + playground: state.snapshot.playground, activeSessionId: state.snapshot.selectedSessionId, - commandPending: state.commandPending || state.modelsLoading + commandPending: state.commandPending }), areAssistantSessionsRailSelectionsEqual) return { @@ -351,7 +468,7 @@ export function useAssistantStore() { loading: state.hydrating, bootstrapped: state.hydrated, modelsLoading: state.modelsLoading, - commandPending: state.commandPending || state.modelsLoading, + commandPending: state.commandPending, commandError: state.error, selectedSession, activeThread, @@ -379,3 +496,57 @@ export function useAssistantStore() { } } } + +export function useAssistantPageStore() { + useAssistantStoreLifecycle() + return useAssistantStoreSelector((state) => { + const selectedSession = getSelectedAssistantSession(state.snapshot) + const activeThread = getActiveAssistantThread(selectedSession) + const phase = getAssistantThreadPhase(activeThread) + + return { + available: state.status.available, + connected: state.status.connected, + loading: state.hydrating, + bootstrapped: state.hydrated, + commandPending: state.commandPending, + commandError: state.error, + selectedSession, + activeThread, + activityFeed: getAssistantActivityFeed(activeThread), + pendingApprovals: getAssistantPendingApprovals(activeThread), + pendingUserInputs: getAssistantPendingUserInputs(activeThread), + activePlan: getAssistantActivePlan(activeThread), + latestProposedPlan: getAssistantLatestProposedPlan(activeThread), + phase, + phaseLabel: getAssistantThreadPhaseLabel(activeThread) + } + }, areAssistantPageSelectionsEqual) +} + +export function useAssistantConversationStore() { + useAssistantStoreLifecycle() + return useAssistantStoreSelector((state) => { + const selectedSession = getSelectedAssistantSession(state.snapshot) + const activeThread = getActiveAssistantThread(selectedSession) + const phase = getAssistantThreadPhase(activeThread) + + return { + knownModels: state.snapshot.knownModels, + available: state.status.available, + connected: state.status.connected, + loading: state.hydrating, + modelsLoading: state.modelsLoading, + commandPending: state.commandPending, + selectedSession, + activeThread, + timelineMessages: getAssistantTimelineMessages(activeThread), + activityFeed: getAssistantActivityFeed(activeThread), + pendingUserInputs: getAssistantPendingUserInputs(activeThread), + activePlan: getAssistantActivePlan(activeThread), + latestProposedPlan: getAssistantLatestProposedPlan(activeThread), + phase, + phaseLabel: getAssistantThreadPhaseLabel(activeThread) + } + }, areAssistantConversationSelectionsEqual) +} diff --git a/src/renderer/src/lib/assistant/selectors.ts b/src/renderer/src/lib/assistant/selectors.ts index 81b5bac..8a0d157 100644 --- a/src/renderer/src/lib/assistant/selectors.ts +++ b/src/renderer/src/lib/assistant/selectors.ts @@ -22,6 +22,14 @@ export function getVisibleAssistantSessions(snapshot: AssistantSnapshot, include return snapshot.sessions.filter((session) => includeArchived || !session.archived) } +export function getAssistantSessionsByMode( + snapshot: AssistantSnapshot, + mode: AssistantSession['mode'], + includeArchived = false +): AssistantSession[] { + return snapshot.sessions.filter((session) => session.mode === mode && (includeArchived || !session.archived)) +} + export function getAssistantPendingApprovals(thread: AssistantThread | null): AssistantPendingApproval[] { if (!thread) return [] return thread.pendingApprovals.filter((approval) => approval.status === 'pending') @@ -148,6 +156,36 @@ export function formatAssistantRelativeTime(value: string): string { return new Date(timestamp).toLocaleDateString() } +export function isAssistantSessionBackgroundActive(session: AssistantSession, activeSessionId: string | null): boolean { + const activeThread = getActiveAssistantThread(session) + const phase = getAssistantThreadPhase(activeThread) + + if (phase.key === 'starting' || phase.key === 'running' || phase.key === 'waiting' || phase.key === 'waiting-approval' || phase.key === 'waiting-input') { + return true + } + + if ( + session.id !== activeSessionId + && activeThread?.latestTurn?.state === 'completed' + && activeThread.lastSeenCompletedTurnId !== activeThread.latestTurn.id + ) { + return true + } + + return false +} + +export function getAssistantBackgroundActivitySessions( + snapshot: AssistantSnapshot, + mode: AssistantSession['mode'], + activeSessionId: string | null +): AssistantSession[] { + return snapshot.sessions + .filter((session) => session.mode === mode && !session.archived) + .filter((session) => isAssistantSessionBackgroundActive(session, activeSessionId)) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id)) +} + export function formatAssistantDateTime(value: string): string { const timestamp = Date.parse(value) if (!Number.isFinite(timestamp)) return value diff --git a/src/renderer/src/lib/assistant/session-hydration-cache.ts b/src/renderer/src/lib/assistant/session-hydration-cache.ts index 5c7fbdd..230cf15 100644 --- a/src/renderer/src/lib/assistant/session-hydration-cache.ts +++ b/src/renderer/src/lib/assistant/session-hydration-cache.ts @@ -106,3 +106,15 @@ export function applyCachedSessionSelection( sessions: nextSessions } } + +export function hasCachedSessionSelection( + snapshot: AssistantSnapshot, + sessionId: string, + cache: Map +): boolean { + const cached = cache.get(sessionId) + if (!cached) return false + + const session = snapshot.sessions.find((entry) => entry.id === sessionId) + return Boolean(session?.activeThreadId && session.activeThreadId === cached.threadId) +} diff --git a/src/renderer/src/lib/assistant/store.ts b/src/renderer/src/lib/assistant/store.ts index 59458d0..afd0955 100644 --- a/src/renderer/src/lib/assistant/store.ts +++ b/src/renderer/src/lib/assistant/store.ts @@ -1,5 +1,8 @@ export { assistantStore, type AssistantStoreState } from './assistant-store-core' export { + useAssistantConversationStore, + useAssistantPageStore, + useAssistantStoreActions, useAssistantSessionsRailStore, useAssistantStore, useAssistantStoreLifecycle, diff --git a/src/renderer/src/lib/settings-assistant-defaults.ts b/src/renderer/src/lib/settings-assistant-defaults.ts index 2486744..971f250 100644 --- a/src/renderer/src/lib/settings-assistant-defaults.ts +++ b/src/renderer/src/lib/settings-assistant-defaults.ts @@ -8,6 +8,7 @@ import type { type AssistantDefaultsSubset = Pick< Settings, | 'assistantDefaultModel' + | 'assistantDefaultPromptTemplate' | 'assistantDefaultRuntimeMode' | 'assistantDefaultInteractionMode' | 'assistantDefaultEffort' @@ -85,11 +86,17 @@ export function getAssistantDefaultSpeedLabel(fastModeEnabled: boolean): string export function getAssistantDefaultsPreview(settings: AssistantDefaultsSubset): string { const modelLabel = settings.assistantDefaultModel.trim() || 'Auto model' - return [ + const parts = [ modelLabel, getAssistantDefaultInteractionModeLabel(settings.assistantDefaultInteractionMode), getAssistantDefaultRuntimeModeLabel(settings.assistantDefaultRuntimeMode), getAssistantDefaultEffortLabel(settings.assistantDefaultEffort), getAssistantDefaultSpeedLabel(settings.assistantDefaultFastMode) - ].join(' • ') + ] + + if (settings.assistantDefaultPromptTemplate.trim()) { + parts.push('Template set') + } + + return parts.join(' • ') } diff --git a/src/renderer/src/lib/settings.tsx b/src/renderer/src/lib/settings.tsx index 33bc2d1..50d9961 100644 --- a/src/renderer/src/lib/settings.tsx +++ b/src/renderer/src/lib/settings.tsx @@ -38,6 +38,7 @@ export type AssistantTextStreamingMode = 'stream' | 'chunks' export type AssistantDefaultRuntimeMode = 'approval-required' | 'full-access' export type AssistantDefaultInteractionMode = 'default' | 'plan' export type AssistantDefaultEffort = 'low' | 'medium' | 'high' | 'xhigh' +export type AssistantTranscriptionEngine = 'browser' | 'vosk' export interface PullRequestGuideConfig { mode: PullRequestGuideMode @@ -138,10 +139,13 @@ export interface Settings { assistantUsageDisplayMode: AssistantUsageDisplayMode assistantTextStreamingMode: AssistantTextStreamingMode assistantDefaultModel: string + assistantDefaultPromptTemplate: string assistantDefaultRuntimeMode: AssistantDefaultRuntimeMode assistantDefaultInteractionMode: AssistantDefaultInteractionMode assistantDefaultEffort: AssistantDefaultEffort assistantDefaultFastMode: boolean + assistantTranscriptionEnabled: boolean + assistantTranscriptionEngine: AssistantTranscriptionEngine } const DEFAULT_SETTINGS: Settings = { @@ -197,10 +201,13 @@ const DEFAULT_SETTINGS: Settings = { assistantUsageDisplayMode: 'remaining', assistantTextStreamingMode: 'stream', assistantDefaultModel: '', + assistantDefaultPromptTemplate: '', assistantDefaultRuntimeMode: 'approval-required', assistantDefaultInteractionMode: 'default', assistantDefaultEffort: 'high', - assistantDefaultFastMode: false + assistantDefaultFastMode: false, + assistantTranscriptionEnabled: false, + assistantTranscriptionEngine: 'browser' } const STORAGE_KEY = 'devscope-settings' @@ -340,10 +347,15 @@ function loadSettings(): Settings { assistantUsageDisplayMode: candidate.assistantUsageDisplayMode === 'used' ? 'used' : 'remaining', assistantTextStreamingMode: candidate.assistantTextStreamingMode === 'chunks' ? 'chunks' : 'stream', assistantDefaultModel: typeof candidate.assistantDefaultModel === 'string' ? candidate.assistantDefaultModel.trim() : '', + assistantDefaultPromptTemplate: typeof candidate.assistantDefaultPromptTemplate === 'string' + ? candidate.assistantDefaultPromptTemplate + : '', assistantDefaultRuntimeMode: sanitizeAssistantDefaultRuntimeMode(candidate.assistantDefaultRuntimeMode), assistantDefaultInteractionMode: sanitizeAssistantDefaultInteractionMode(candidate.assistantDefaultInteractionMode), assistantDefaultEffort: sanitizeAssistantDefaultEffort(candidate.assistantDefaultEffort), - assistantDefaultFastMode: !!candidate.assistantDefaultFastMode + assistantDefaultFastMode: !!candidate.assistantDefaultFastMode, + assistantTranscriptionEnabled: candidate.assistantTranscriptionEnabled === true, + assistantTranscriptionEngine: candidate.assistantTranscriptionEngine === 'vosk' ? 'vosk' : 'browser' } } } catch (e) { diff --git a/src/renderer/src/pages/assistant/AssistantAttachmentImageCard.tsx b/src/renderer/src/pages/assistant/AssistantAttachmentImageCard.tsx new file mode 100644 index 0000000..e766b22 --- /dev/null +++ b/src/renderer/src/pages/assistant/AssistantAttachmentImageCard.tsx @@ -0,0 +1,88 @@ +import { X } from 'lucide-react' +import { cn } from '@/lib/utils' + +type AssistantAttachmentImageCardProps = { + name: string + src: string + widthClassName: string + heightClassName: string + onClick?: () => void + onRemove?: () => void + removable?: boolean + removing?: boolean +} + +export function AssistantAttachmentImageCard({ + name, + src, + widthClassName, + heightClassName, + onClick, + onRemove, + removable = false, + removing = false +}: AssistantAttachmentImageCardProps) { + return ( +
+ {onClick ? ( + + ) : ( +
+
+
+ {name} +
+
+
+ )} + {removable ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/pages/assistant/AssistantAttachmentTextPreviewModal.tsx b/src/renderer/src/pages/assistant/AssistantAttachmentTextPreviewModal.tsx new file mode 100644 index 0000000..fb5f22d --- /dev/null +++ b/src/renderer/src/pages/assistant/AssistantAttachmentTextPreviewModal.tsx @@ -0,0 +1,110 @@ +import { useEffect } from 'react' +import { X } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { ComposerContextFile } from './assistant-composer-types' + +type AttachmentMeta = { + name: string + ext: string + category: 'image' | 'code' | 'doc' +} + +interface AssistantAttachmentTextPreviewModalProps { + file: ComposerContextFile | null + meta: AttachmentMeta | null + contentType: string + sizeLabel: string + showFormattingWarning: boolean + onClose: () => void +} + +export default function AssistantAttachmentTextPreviewModal({ + file, + meta, + contentType, + sizeLabel, + showFormattingWarning, + onClose +}: AssistantAttachmentTextPreviewModalProps) { + useEffect(() => { + if (!file) return + + const onEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + + const originalOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + window.addEventListener('keydown', onEscape) + + return () => { + window.removeEventListener('keydown', onEscape) + document.body.style.overflow = originalOverflow + } + }, [file, onClose]) + + if (!file || !meta) return null + + const previewText = String(file.content || file.previewText || '').trimEnd() + const hasPreviewText = Boolean(previewText.trim()) + + return ( +
event.stopPropagation()} + style={{ animation: 'fadeIn 0.15s ease-out' }} + > +
event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + style={{ animation: 'scaleIn 0.15s ease-out' }} + > +
+
+

{meta.name}

+
+ + {contentType} + + + {meta.ext || 'txt'} + + {sizeLabel && ( + + {sizeLabel} + + )} +
+
+ +
+ +
+
+
+ Pasted text +
+
+                            {hasPreviewText ? previewText : 'No text content available.'}
+                        
+
+ + {showFormattingWarning && ( +
+ Text might have not been properly formatted. +
+ )} +
+
+
+ ) +} diff --git a/src/renderer/src/pages/assistant/AssistantComposerSections.tsx b/src/renderer/src/pages/assistant/AssistantComposerSections.tsx index 30771c2..59cf784 100644 --- a/src/renderer/src/pages/assistant/AssistantComposerSections.tsx +++ b/src/renderer/src/pages/assistant/AssistantComposerSections.tsx @@ -2,77 +2,161 @@ import { memo, type Dispatch, type RefObject, type SetStateAction } from 'react' import { AnimatedHeight } from '@/components/ui/AnimatedHeight' import { VscodeEntryIcon } from '@/components/ui/VscodeEntryIcon' import { cn } from '@/lib/utils' -import { Check, ChevronDown, ChevronUp, FileCode2, FileImage, FileText, GitBranch, ListTodo, Loader2, Lock, LockOpen, MessageSquare, SendHorizontal, X } from 'lucide-react' +import { Check, ChevronDown, ChevronUp, FileCode2, FileText, GitBranch, ListTodo, Loader2, Lock, LockOpen, MessageSquare, Mic, RefreshCw, SendHorizontal, Square, X } from 'lucide-react' +import type { PreviewOpenOptions } from '@/components/ui/file-preview/types' import { formatAssistantModelLabel } from './assistant-model-labels' import { OpenAILogo } from './assistant-composer-inline-mentions' -import { getContentTypeTag, getContextFileMeta } from './assistant-composer-utils' +import { getContentTypeTag, getContextFileMeta, isPastedTextAttachment } from './assistant-composer-utils' import type { ComposerContextFile } from './assistant-composer-types' import type { MentionCandidate } from './assistant-composer-mentions' +import { AssistantAttachmentImageCard } from './AssistantAttachmentImageCard' export const ComposerAttachmentsShelf = memo(({ contextFiles, compact, removingAttachmentIds, + onOpenAttachmentPreview, onPreview, onRemove }: { contextFiles: ComposerContextFile[] compact: boolean removingAttachmentIds: string[] + onOpenAttachmentPreview?: ( + file: { name: string; path: string }, + ext: string, + options?: PreviewOpenOptions + ) => Promise | void onPreview: (file: ComposerContextFile) => void onRemove: (id: string) => void }) => ( 0} duration={220}> -
+
{contextFiles.map((file) => { const meta = getContextFileMeta(file) const contentType = getContentTypeTag(file) const isRemoving = removingAttachmentIds.includes(file.id) const isEntering = Boolean(file.animateIn) + const isImageAttachment = meta.category === 'image' && Boolean(file.previewDataUrl) + const isPastedText = isPastedTextAttachment(file) + const cardWidthClass = isPastedText ? 'w-[92px]' : 'w-[116px]' + const handleOpenImagePreview = () => { + if (onOpenAttachmentPreview) { + void onOpenAttachmentPreview({ name: meta.name, path: file.path }, meta.ext) + return + } + onPreview(file) + } + const handleOpenPastedTextPreview = () => { + onPreview(file) + } return ( -
- + +
+ ) : ( +
- {meta.name} - {contentType} - - - -
+ + +
+
{file.path}
+
+ + ) ) })}
@@ -131,18 +215,102 @@ export const ComposerSendButton = memo(({ isConnected, isThinking, canSend, + label = 'Send', + onStop, onSend }: { disabled: boolean isConnected: boolean isThinking: boolean canSend: boolean + label?: string + onStop?: () => Promise | void onSend: () => void -}) => ( - -)) +}) => { + const canStop = isThinking && Boolean(onStop) && isConnected && !disabled + const isEmptyState = !canStop && !disabled && isConnected && !canSend + const isDisabled = canStop ? false : disabled || !isConnected || !canSend + + return ( + + ) +}) + +export const ComposerVoiceButton = memo(({ + supported, + isRecording, + disabled, + onToggle +}: { + supported: boolean + isRecording: boolean + disabled: boolean + onToggle: () => void +}) => { + if (!supported) return null + + return ( + + ) +}) function syncScrollAffordanceState(element: HTMLDivElement | null, setCanScrollUp: Dispatch>, setCanScrollDown: Dispatch>) { if (!element) { @@ -157,6 +325,7 @@ function syncScrollAffordanceState(element: HTMLDivElement | null, setCanScrollU export const ComposerFooterControls = memo(({ isCompactFooter, + controlsLocked = false, modelDropdownRef, showModelDropdown, setShowModelDropdown, @@ -194,6 +363,7 @@ export const ComposerFooterControls = memo(({ setShowFullAccessConfirm }: { isCompactFooter: boolean + controlsLocked?: boolean modelDropdownRef: RefObject showModelDropdown: boolean setShowModelDropdown: Dispatch> @@ -232,10 +402,10 @@ export const ComposerFooterControls = memo(({ }) => (
-
+
-
Models{modelsLoading ? : null}
+
Models
{ setModelQuery(event.target.value); setActiveModelIndex(0) }} placeholder="Search models..." className="h-8 w-full rounded-lg border border-white/10 bg-white/[0.03] px-2.5 text-[11px] text-sparkle-text outline-none placeholder:text-sparkle-text-muted/60 focus:border-white/20" />
{modelCanScrollUp ?
: null} @@ -245,11 +415,13 @@ export const ComposerFooterControls = memo(({ const isHighlighted = index === activeModelIndex const isLatestModel = model.id === latestModelId return ( - ) })} @@ -260,7 +432,7 @@ export const ComposerFooterControls = memo(({
- +
@@ -276,14 +448,14 @@ export const ComposerFooterControls = memo(({
- +
- + - +
)) @@ -293,6 +465,7 @@ export const ComposerStatusBar = memo(({ modelsLoading, branchesLoading, thinkingLabel, + fastModeEnabled, branchDropdownRef, showBranchDropdown, setShowBranchDropdown, @@ -305,6 +478,7 @@ export const ComposerStatusBar = memo(({ modelsLoading: boolean branchesLoading: boolean thinkingLabel: string + fastModeEnabled: boolean branchDropdownRef: RefObject showBranchDropdown: boolean setShowBranchDropdown: Dispatch> @@ -315,10 +489,10 @@ export const ComposerStatusBar = memo(({
Local - {(isThinking || mentionLoading || modelsLoading || branchesLoading) ? ( + {(isThinking || mentionLoading || branchesLoading) ? ( - {isThinking ? thinkingLabel : mentionLoading ? 'Indexing...' : modelsLoading ? 'Loading models...' : 'Loading...'} + {isThinking ? thinkingLabel : mentionLoading ? 'Indexing...' : 'Loading...'} ) : null}
diff --git a/src/renderer/src/pages/assistant/AssistantComposerView.tsx b/src/renderer/src/pages/assistant/AssistantComposerView.tsx index 57e2f25..e96ec76 100644 --- a/src/renderer/src/pages/assistant/AssistantComposerView.tsx +++ b/src/renderer/src/pages/assistant/AssistantComposerView.tsx @@ -1,3 +1,5 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { useSettings } from '@/lib/settings' import { cn } from '@/lib/utils' import { AnimatedHeight } from '@/components/ui/AnimatedHeight' @@ -21,7 +23,8 @@ import { X } from 'lucide-react' import AssistantAttachmentPreviewModal from './AssistantAttachmentPreviewModal' -import { ComposerAttachmentsShelf, ComposerFooterControls, ComposerMentionMenu, ComposerSendButton } from './AssistantComposerSections' +import AssistantAttachmentTextPreviewModal from './AssistantAttachmentTextPreviewModal' +import { ComposerAttachmentsShelf, ComposerFooterControls, ComposerMentionMenu, ComposerSendButton, ComposerVoiceButton } from './AssistantComposerSections' import { formatAssistantModelLabel } from './assistant-model-labels' import { OpenAILogo, @@ -36,20 +39,86 @@ import { } from './assistant-composer-utils' export function AssistantComposerView({ controller }: { controller: AssistantComposerController }) { + const navigate = useNavigate() const { settings } = useSettings() const iconTheme = settings.theme === 'light' ? 'light' : 'dark' + const transcriptionEnabled = settings.assistantTranscriptionEnabled + const voiceBusy = controller.voiceInput.isRecording || controller.voiceInput.isTranscribing + const [showBrowserSpeechFallbackModal, setShowBrowserSpeechFallbackModal] = useState(false) + const attachmentShelfRef = useRef(null) + + useEffect(() => { + if (settings.assistantTranscriptionEngine !== 'browser') { + setShowBrowserSpeechFallbackModal(false) + return + } + if (controller.voiceInput.speechErrorKind === 'network') { + setShowBrowserSpeechFallbackModal(true) + } + }, [controller.voiceInput.speechErrorKind, settings.assistantTranscriptionEngine]) + + useLayoutEffect(() => { + const host = attachmentShelfRef.current + if (!host) return + + const measure = () => { + const itemRects = Array.from(host.querySelectorAll('[data-composer-attachment-item="true"]')) + .map((element) => element.getBoundingClientRect()) + .filter((rect) => rect.width > 0 && rect.height > 0) + + if (itemRects.length === 0) { + controller.onAttachmentShelfBoundsChange?.(null) + return + } + + const bounds = itemRects.reduce((acc, rect) => ({ + top: Math.min(acc.top, rect.top), + right: Math.max(acc.right, rect.right), + bottom: Math.max(acc.bottom, rect.bottom), + left: Math.min(acc.left, rect.left), + width: 0, + height: 0 + }), { + top: itemRects[0].top, + right: itemRects[0].right, + bottom: itemRects[0].bottom, + left: itemRects[0].left, + width: 0, + height: 0 + }) + + controller.onAttachmentShelfBoundsChange?.({ + ...bounds, + width: Math.max(0, bounds.right - bounds.left), + height: Math.max(0, bounds.bottom - bounds.top) + }) + } + + const frameId = window.requestAnimationFrame(measure) + const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => measure()) : null + observer?.observe(host) + window.addEventListener('resize', measure) + + return () => { + window.cancelAnimationFrame(frameId) + observer?.disconnect() + window.removeEventListener('resize', measure) + } + }, [controller.contextFiles.length, controller.onAttachmentShelfBoundsChange]) return ( <> -
0 ? (controller.compact ? 'gap-1' : 'gap-1.5') : 'gap-0')}> - - +
+
+ +
@@ -147,11 +216,28 @@ export function AssistantComposerView({ controller }: { controller: AssistantCom />
+ {controller.showCancelWhenDirty && controller.isDirty ? ( + + ) : null} + 0)} + canSend={controller.allowEmptySubmit || Boolean(controller.text.trim() || controller.contextFiles.length > 0)} + label={controller.isDirty && controller.dirtySubmitLabel ? controller.dirtySubmitLabel : controller.submitLabel} + onStop={controller.onStop} onSend={() => void controller.handleSend()} />
@@ -159,13 +245,13 @@ export function AssistantComposerView({ controller }: { controller: AssistantCom
-
+
Local - {(controller.isThinking || controller.mentionLoading || controller.modelsLoading || controller.branchesLoading) && ( + {(voiceBusy || controller.voiceInput.speechError || controller.isThinking || controller.mentionLoading || controller.branchesLoading) && ( - - {controller.isThinking ? controller.thinkingLabel : controller.mentionLoading ? 'Indexing...' : controller.modelsLoading ? 'Loading models...' : 'Loading...'} + + {controller.voiceInput.isRecording ? (settings.assistantTranscriptionEngine === 'vosk' ? 'Recording locally...' : 'Listening...') : controller.voiceInput.isTranscribing ? 'Transcribing locally...' : controller.voiceInput.speechError || (controller.isThinking ? controller.thinkingLabel : controller.mentionLoading ? 'Indexing...' : 'Loading...')} )}
@@ -177,24 +263,66 @@ export function AssistantComposerView({ controller }: { controller: AssistantCom -
+
-
+
{!controller.isGitRepo ? ( -
This folder is not a git repository.
+
This folder is not a git repository.
) : controller.branches.length === 0 ? ( -
{controller.branchesLoading && }{controller.branchesLoading ? 'Loading branches...' : 'No branches found.'}
+
{controller.branchesLoading && }{controller.branchesLoading ? 'Loading branches...' : 'No branches found.'}
) : ( <> -
{ controller.setBranchQuery(event.target.value); controller.setActiveBranchIndex(0) }} placeholder="Search branches..." className="block w-full min-w-0 rounded-md border border-white/10 bg-white/[0.03] px-2.5 py-1.5 text-[11px] text-sparkle-text outline-none placeholder:text-sparkle-text-muted/60" />
-
- {controller.filteredBranches.length === 0 ?
No branches found.
: controller.filteredBranches.map((branch, index) => ( -
-
{branch.name}
{branch.current ? 'Current branch' : branch.label}
- {branch.current && Current} -
- ))} +
+ {controller.isSwitchingBranch ? 'Switching branch...' : 'Switch branch'} +
+
{ controller.setBranchQuery(event.target.value); controller.setActiveBranchIndex(0) }} placeholder="Search branches..." className="block h-8 w-full min-w-0 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1 text-[10px] text-sparkle-text outline-none placeholder:text-sparkle-text-muted/60" />
+
+ {controller.filteredBranches.length === 0 ?
No branches found.
: controller.filteredBranches.map((branch, index) => { + const isCurrent = Boolean(branch.current) + const isDefault = controller.defaultBranchName === branch.name + const isHighlighted = index === controller.activeBranchIndex + return ( + + )})}
+ {controller.branchActionError ? ( +
+ {controller.branchActionError} +
+ ) : null} )}
@@ -204,10 +332,18 @@ export function AssistantComposerView({ controller }: { controller: AssistantCom
controller.setPreviewAttachment(null)} + /> + controller.setPreviewAttachment(null)} /> @@ -226,6 +362,19 @@ export function AssistantComposerView({ controller }: { controller: AssistantCom }} onCancel={() => controller.setShowFullAccessConfirm(false)} /> + { + setShowBrowserSpeechFallbackModal(false) + navigate('/settings/account?highlight=transcription') + }} + onCancel={() => setShowBrowserSpeechFallbackModal(false)} + /> ) } diff --git a/src/renderer/src/pages/assistant/AssistantConnectedSessionsRail.tsx b/src/renderer/src/pages/assistant/AssistantConnectedSessionsRail.tsx index 4378076..c7b3cc9 100644 --- a/src/renderer/src/pages/assistant/AssistantConnectedSessionsRail.tsx +++ b/src/renderer/src/pages/assistant/AssistantConnectedSessionsRail.tsx @@ -1,30 +1,44 @@ import { memo } from 'react' import { useAssistantSessionsRailStore } from '@/lib/assistant/store' +import { getAssistantBackgroundActivitySessions, getAssistantSessionsByMode } from '@/lib/assistant/selectors' import { AssistantSessionsRail } from './AssistantSessionsRail' +import type { AssistantRailMode } from './useAssistantPageSidebarState' export const ConnectedAssistantSessionsRail = memo(function ConnectedAssistantSessionsRail(props: { collapsed: boolean width: number + railMode: AssistantRailMode + onRailModeChange: (next: AssistantRailMode) => void onWidthChange: (next: number) => void }) { - const { collapsed, width, onWidthChange } = props + const { collapsed, width, railMode, onRailModeChange, onWidthChange } = props const railController = useAssistantSessionsRailStore() + const otherMode: AssistantRailMode = railMode === 'work' ? 'playground' : 'work' + const visibleSessions = getAssistantSessionsByMode(railController.snapshot, railMode, true) + const backgroundActivitySessions = getAssistantBackgroundActivitySessions(railController.snapshot, otherMode, railController.activeSessionId) return ( railController.createSession(undefined, projectPath)} + onCreateSession={(projectPath) => railController.createSession({ projectPath })} + onCreatePlaygroundSession={(labId) => railController.createSession({ mode: 'playground', playgroundLabId: labId || null })} onSelectSession={railController.selectSession} onRenameSession={railController.renameSession} onArchiveSession={railController.archiveSession} onDeleteSession={railController.deleteSession} onChooseProjectPath={railController.createProjectSession} + onSetPlaygroundRoot={railController.setPlaygroundRoot} + onCreatePlaygroundLab={railController.createPlaygroundLabResult} /> ) }) diff --git a/src/renderer/src/pages/assistant/AssistantConversationComposerPane.tsx b/src/renderer/src/pages/assistant/AssistantConversationComposerPane.tsx index 30073d7..becfe72 100644 --- a/src/renderer/src/pages/assistant/AssistantConversationComposerPane.tsx +++ b/src/renderer/src/pages/assistant/AssistantConversationComposerPane.tsx @@ -1,10 +1,13 @@ import { memo } from 'react' -import type { AssistantPendingUserInput } from '@shared/assistant/contracts' +import type { AssistantPendingUserInput, AssistantPlaygroundPendingLabRequest } from '@shared/assistant/contracts' +import type { PreviewOpenOptions } from '@/components/ui/file-preview/types' import { AssistantComposer } from './AssistantComposer' +import { AssistantPendingPlaygroundLabPanel } from './AssistantPendingPlaygroundLabPanel' import { AssistantPendingUserInputPanel } from './AssistantPendingUserInputPanel' -import type { AssistantComposerSendOptions, ComposerContextFile } from './assistant-composer-types' +import type { AssistantComposerSendOptions, AssistantElementBounds, ComposerContextFile } from './assistant-composer-types' export const AssistantConversationComposerPane = memo(function AssistantConversationComposerPane(props: { + pendingPlaygroundLabRequest: AssistantPlaygroundPendingLabRequest | null pendingUserInputs: AssistantPendingUserInput[] commandPending: boolean thinking: boolean @@ -19,6 +22,13 @@ export const AssistantConversationComposerPane = memo(function AssistantConversa interactionMode: 'default' | 'plan' activeProfile: 'safe-dev' | 'yolo-fast' activeStatusLabel: string + onStop?: () => Promise | void + onOpenAttachmentPreview?: ( + file: { name: string; path: string }, + ext: string, + options?: PreviewOpenOptions + ) => Promise | void + onAttachmentShelfBoundsChange?: (bounds: AssistantElementBounds | null) => void sendPrompt: ( prompt: string, contextFiles: ComposerContextFile[], @@ -26,38 +36,65 @@ export const AssistantConversationComposerPane = memo(function AssistantConversa ) => Promise refreshModels: () => void respondUserInput: (requestId: string, answers: Record) => Promise + approvePendingPlaygroundLabRequest: (input: { title?: string; source: 'empty' | 'git-clone'; repoUrl?: string }) => Promise + declinePendingPlaygroundLabRequest: () => Promise }) { + const hasPendingPlaygroundLabRequest = Boolean(props.pendingPlaygroundLabRequest) const isWaitingForUserInput = props.pendingUserInputs.length > 0 return (
- {isWaitingForUserInput ? ( + {hasPendingPlaygroundLabRequest && props.pendingPlaygroundLabRequest ? ( + + ) : null} + {!hasPendingPlaygroundLabRequest && isWaitingForUserInput ? ( - ) : null} -
- -
+ ) : null} + {!hasPendingPlaygroundLabRequest && !isWaitingForUserInput ? ( +
+ +
+ ) : null}
) }) diff --git a/src/renderer/src/pages/assistant/AssistantConversationPane.tsx b/src/renderer/src/pages/assistant/AssistantConversationPane.tsx index f0d907c..f49c66e 100644 --- a/src/renderer/src/pages/assistant/AssistantConversationPane.tsx +++ b/src/renderer/src/pages/assistant/AssistantConversationPane.tsx @@ -1,19 +1,32 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react' import { ChevronLeft, ChevronRight, ListTodo, MoreHorizontal, PanelLeft, PanelRight, SquarePen } from 'lucide-react' +import type { AssistantMessage, AssistantProposedPlan } from '@shared/assistant/contracts' +import type { PreviewOpenOptions } from '@/components/ui/file-preview/types' import { useSettings } from '@/lib/settings' -import { cn } from '@/lib/utils' +import { useAssistantConversationStore, useAssistantStoreActions } from '@/lib/assistant/store' import { isAssistantThreadActivelyWorking } from '@/lib/assistant/selectors' +import { cn } from '@/lib/utils' import type { AssistantDiffTarget } from './assistant-diff-types' import { buildPromptWithContextFiles } from './assistant-composer-utils' import { AssistantConversationComposerPane } from './AssistantConversationComposerPane' +import { AssistantHeaderOpenWithButton } from './AssistantHeaderOpenWithButton' import { AssistantConversationTimelinePane } from './AssistantConversationTimelinePane' import { AssistantProjectGitChip } from './AssistantProjectGitChip' -import type { AssistantComposerSendOptions, ComposerContextFile } from './assistant-composer-types' +import type { AssistantComposerSendOptions, AssistantElementBounds, ComposerContextFile } from './assistant-composer-types' +import { getAssistantLinkBaseFilePath } from './assistant-file-navigation' +import { getAssistantActivePlanProgress, hasAssistantPlanPanelContent } from './assistant-plan-utils' +import { useAssistantPageTimelineScroll } from './useAssistantPageTimelineScroll' const TIMELINE_SHOW_SCROLL_BUTTON_THRESHOLD_PX = 420 const TIMELINE_HIDE_SCROLL_BUTTON_THRESHOLD_PX = 180 +const IMPLEMENT_MODE_TOAST_MS = 2600 +const SCROLL_BUTTON_ELEVATED_OFFSET_PX = 80 -export function AssistantConversationPane(props: { +function rectsOverlap(a: AssistantElementBounds, b: AssistantElementBounds): boolean { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top +} + +const AssistantConversationHeader = memo(function AssistantConversationHeader(props: { rightPanelOpen: boolean rightPanelMode: 'none' | 'details' | 'plan' | 'diff' planPanelAvailable: boolean @@ -22,22 +35,18 @@ export function AssistantConversationPane(props: { showHeaderMenu: boolean setShowHeaderMenu: (value: boolean) => void headerMenuRef: RefObject - timelineScrollRef: RefObject - deletingMessageId: string | null - latestProjectLabel: string - assistantMessageFilePath?: string | null leftSidebarCollapsed: boolean + latestProjectLabel: string + selectedSessionTitle: string + selectedSessionMode: 'work' | 'playground' + selectedProjectTooltip: string + selectedProjectPath: string | null + preferredShell: 'powershell' | 'cmd' + gitRefreshToken: string onToggleLeftSidebar: () => void - availableModels: Array<{ id: string; label: string; description?: string }> - controller: any - onScrollTimeline: (element: HTMLDivElement) => void - onScrollToBottom: () => void - onRequestDeleteUserMessage: (message: any) => void - onToggleRightSidebar: () => void onTogglePlanPanel: () => void - onOpenAssistantLink?: (href: string) => Promise | void - onOpenEditedFile?: (filePath: string) => Promise | void - onViewDiff?: (target: AssistantDiffTarget) => void + onCreateThread: () => void + onToggleRightSidebar: () => void }) { const { rightPanelOpen, @@ -48,30 +57,156 @@ export function AssistantConversationPane(props: { showHeaderMenu, setShowHeaderMenu, headerMenuRef, - timelineScrollRef, - deletingMessageId, - latestProjectLabel, - assistantMessageFilePath, leftSidebarCollapsed, + latestProjectLabel, + selectedSessionTitle, + selectedSessionMode, + selectedProjectTooltip, + selectedProjectPath, + preferredShell, + gitRefreshToken, onToggleLeftSidebar, - availableModels, - controller, - onScrollTimeline, - onScrollToBottom, - onRequestDeleteUserMessage, - onToggleRightSidebar, onTogglePlanPanel, - onOpenAssistantLink, - onOpenEditedFile, - onViewDiff + onCreateThread, + onToggleRightSidebar } = props + + return ( +
+
+ +

{selectedSessionTitle}

+ + {selectedSessionMode === 'playground' ? 'Playground chat' : 'Work chat'} + + + {latestProjectLabel} + +
+
+ + + + + {showHeaderMenu &&
+ + +
} +
+
+ ) +}) + +export function AssistantConversationPane(props: { + rightPanelOpen: boolean + rightPanelMode: 'none' | 'details' | 'plan' | 'diff' + deletingMessageId: string | null + leftSidebarCollapsed: boolean + onToggleLeftSidebar: () => void + onRequestDeleteUserMessage: (message: AssistantMessage) => void + onToggleRightSidebar: () => void + onTogglePlanPanel: () => void + onOpenAttachmentPreview?: ( + file: { name: string; path: string }, + ext: string, + options?: PreviewOpenOptions + ) => Promise | void + onOpenAssistantLink?: (href: string) => Promise | void + onOpenEditedFile?: (filePath: string) => Promise | void + onViewDiff?: (target: AssistantDiffTarget) => void +}) { + const controller = useAssistantConversationStore() + const actions = useAssistantStoreActions() const { settings } = useSettings() - const isThreadWorking = isAssistantThreadActivelyWorking(controller.activeThread) - const isThreadConnecting = controller.phase.key === 'starting' || (controller.commandPending && !isThreadWorking) - const activeStatusLabel = isThreadConnecting ? 'Connecting...' : 'Working...' + const headerMenuRef = useRef(null) + const [showHeaderMenu, setShowHeaderMenu] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false) + const [elevateScrollToBottom, setElevateScrollToBottom] = useState(false) + const [attachmentShelfBounds, setAttachmentShelfBounds] = useState(null) + const [scrollButtonBounds, setScrollButtonBounds] = useState(null) + const [interactionModeOverride, setInteractionModeOverride] = useState<'default' | null>(null) + const [implementationToastVisible, setImplementationToastVisible] = useState(false) const showScrollToBottomRef = useRef(false) const scrollButtonRafRef = useRef(null) + + const isThreadWorking = isAssistantThreadActivelyWorking(controller.activeThread) + const isThreadConnecting = controller.phase.key === 'starting' + const activeStatusLabel = isThreadConnecting ? 'Connecting...' : 'Working...' + const selectedProjectPath = String(controller.selectedSession?.projectPath || controller.activeThread?.cwd || '').trim() + const selectedSessionMode = controller.selectedSession?.mode || 'work' + const selectedSessionTitle = controller.selectedSession?.title || 'Assistant' + const selectedProjectTooltip = controller.selectedSession?.projectPath || controller.activeThread?.cwd || (selectedSessionMode === 'playground' ? 'No lab attached yet' : 'No project selected') + const latestProjectLabel = selectedProjectPath + ? selectedProjectPath.split(/[\\/]/).filter(Boolean).pop() || selectedProjectPath + : (selectedSessionMode === 'playground' ? 'chat-only' : 'not set') + const assistantMessageFilePath = useMemo( + () => getAssistantLinkBaseFilePath(selectedProjectPath), + [selectedProjectPath] + ) + const availableModels = useMemo(() => { + if (controller.knownModels.length > 0) return controller.knownModels + const activeModel = String(controller.activeThread?.model || '').trim() + return activeModel ? [{ id: activeModel, label: activeModel }] : [] + }, [controller.activeThread?.model, controller.knownModels]) + const planPanelAvailable = hasAssistantPlanPanelContent(controller.activePlan, controller.latestProposedPlan) + const activePlanProgress = getAssistantActivePlanProgress(controller.activePlan, controller.activeThread?.latestTurn || null) + const planProgressLabel = activePlanProgress ? `${activePlanProgress.currentStepNumber}/${activePlanProgress.totalSteps}` : null + const planIsComplete = activePlanProgress?.isComplete === true + const shouldShowWorkingIndicator = isThreadWorking + && !controller.timelineMessages.some((message) => message.role === 'assistant' && message.streaming) + const lastTimelineMessage = controller.timelineMessages[controller.timelineMessages.length - 1] || null + const latestTimelineActivity = controller.activityFeed[0] || null + const { timelineContentRef, timelineScrollRef, onScrollTimeline, onScrollToBottom } = useAssistantPageTimelineScroll({ + sessionId: controller.selectedSession?.id || null, + threadId: controller.activeThread?.id || null, + loading: controller.loading, + timelineMessageCount: controller.timelineMessages.length, + lastTimelineMessageId: lastTimelineMessage?.id || null, + lastTimelineMessageUpdatedAt: lastTimelineMessage?.updatedAt || null, + activityFeedCount: controller.activityFeed.length, + latestTimelineActivityId: latestTimelineActivity?.id || null, + latestTimelineActivityCreatedAt: latestTimelineActivity?.createdAt || null, + shouldShowWorkingIndicator, + latestTurnStartedAt: controller.activeThread?.latestTurn?.startedAt || null, + latestTurnState: controller.activeThread?.latestTurn?.state || null, + threadState: controller.activeThread?.state || null + }) const isLoadingSelectedChat = Boolean( !isThreadConnecting && !controller.loading @@ -82,6 +217,7 @@ export function AssistantConversationPane(props: { && controller.activityFeed.length === 0 && (controller.activeThread.messageCount > 0 || controller.activeThread.latestTurn || controller.activeThread.updatedAt) ) + const gitRefreshToken = `${controller.selectedSession?.id || 'no-session'}:${controller.activeThread?.id || 'no-thread'}:${controller.activeThread?.latestTurn?.completedAt || controller.activeThread?.lastSeenCompletedTurnId || 'idle'}` const getDistanceFromBottom = useCallback((element: HTMLDivElement) => { return Math.max(0, element.scrollHeight - element.scrollTop - element.clientHeight) @@ -110,6 +246,22 @@ export function AssistantConversationPane(props: { }) }, [onScrollTimeline, syncScrollButtonVisibility]) + useEffect(() => { + if (!showHeaderMenu) return + const handlePointerDown = (event: MouseEvent) => { + if (!headerMenuRef.current?.contains(event.target as Node)) setShowHeaderMenu(false) + } + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowHeaderMenu(false) + } + document.addEventListener('mousedown', handlePointerDown) + window.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('mousedown', handlePointerDown) + window.removeEventListener('keydown', handleEscape) + } + }, [showHeaderMenu]) + useLayoutEffect(() => { const element = timelineScrollRef.current if (!element) return @@ -130,6 +282,23 @@ export function AssistantConversationPane(props: { timelineScrollRef ]) + useEffect(() => { + if (!attachmentShelfBounds || !scrollButtonBounds) { + setElevateScrollToBottom(false) + return + } + + const defaultScrollButtonBounds = elevateScrollToBottom + ? { + ...scrollButtonBounds, + top: scrollButtonBounds.top + SCROLL_BUTTON_ELEVATED_OFFSET_PX, + bottom: scrollButtonBounds.bottom + SCROLL_BUTTON_ELEVATED_OFFSET_PX + } + : scrollButtonBounds + + setElevateScrollToBottom(rectsOverlap(attachmentShelfBounds, defaultScrollButtonBounds)) + }, [attachmentShelfBounds, elevateScrollToBottom, scrollButtonBounds]) + useEffect(() => { return () => { if (scrollButtonRafRef.current !== null) { @@ -138,6 +307,18 @@ export function AssistantConversationPane(props: { } }, []) + useEffect(() => { + if (controller.activeThread?.interactionMode === 'default') { + setInteractionModeOverride(null) + } + }, [controller.activeThread?.interactionMode, controller.activeThread?.id]) + + useEffect(() => { + if (!implementationToastVisible) return + const timeoutId = window.setTimeout(() => setImplementationToastVisible(false), IMPLEMENT_MODE_TOAST_MS) + return () => window.clearTimeout(timeoutId) + }, [implementationToastVisible]) + const handleScrollToBottomClick = useCallback(() => { showScrollToBottomRef.current = false setShowScrollToBottom(false) @@ -145,19 +326,43 @@ export function AssistantConversationPane(props: { }, [onScrollToBottom]) const handleRefreshModels = useCallback(() => { - void controller.refreshModels() - }, [controller.refreshModels]) + actions.refreshModels() + }, [actions]) const handleRespondUserInput = useCallback(async (requestId: string, answers: Record) => { - await controller.respondUserInput(requestId, answers) - }, [controller.respondUserInput]) + await actions.respondUserInput(requestId, answers) + }, [actions]) + + const handleApprovePendingPlaygroundLabRequest = useCallback(async (input: { title?: string; source: 'empty' | 'git-clone'; repoUrl?: string }) => { + const sessionId = controller.selectedSession?.id + if (!sessionId) return + await actions.approvePendingPlaygroundLabRequest({ + sessionId, + source: input.source, + title: input.title, + repoUrl: input.repoUrl + }) + }, [actions, controller.selectedSession?.id]) + + const handleDeclinePendingPlaygroundLabRequest = useCallback(async () => { + const sessionId = controller.selectedSession?.id + if (!sessionId) return + await actions.declinePendingPlaygroundLabRequest({ sessionId }) + }, [actions, controller.selectedSession?.id]) + + const handleStopTurn = useCallback(async () => { + await actions.interruptTurn( + controller.activeThread?.latestTurn?.id, + controller.selectedSession?.id || undefined + ) + }, [actions, controller.activeThread?.latestTurn?.id, controller.selectedSession?.id]) const handleSendPrompt = useCallback(async ( prompt: string, contextFiles: ComposerContextFile[], options: AssistantComposerSendOptions ) => { - const result = await controller.sendPromptResult(buildPromptWithContextFiles(prompt, contextFiles), { + const result = await actions.sendPromptResult(buildPromptWithContextFiles(prompt, contextFiles), { sessionId: controller.selectedSession?.id || undefined, model: options.model, runtimeMode: options.runtimeMode, @@ -166,67 +371,79 @@ export function AssistantConversationPane(props: { serviceTier: options.serviceTier }) return result.success - }, [controller.selectedSession?.id, controller.sendPromptResult]) - const selectedProjectPath = controller.selectedSession?.projectPath || controller.activeThread?.cwd || null - const gitRefreshToken = `${controller.selectedSession?.id || 'no-session'}:${controller.activeThread?.id || 'no-thread'}:${controller.activeThread?.latestTurn?.id || 'no-turn'}:${controller.activeThread?.latestTurn?.state || 'idle'}:${controller.commandPending ? 'busy' : 'idle'}` + }, [actions, controller.selectedSession?.id]) + + const handleImplementProposedPlan = useCallback(async (plan: AssistantProposedPlan) => { + const planMarkdown = String(plan.planMarkdown || '').trim() + if (!planMarkdown) return + + setInteractionModeOverride('default') + setImplementationToastVisible(true) + await actions.sendPromptResult( + `Implement the approved plan below. Do not re-plan unless you hit a real blocking contradiction. Start executing now.\n\n\n${planMarkdown}\n`, + { + sessionId: controller.selectedSession?.id || undefined, + model: controller.activeThread?.model || undefined, + runtimeMode: controller.activeThread?.runtimeMode || 'approval-required', + interactionMode: 'default', + effort: controller.activeThread?.latestTurn?.effort || undefined, + serviceTier: controller.activeThread?.latestTurn?.serviceTier === 'fast' ? 'fast' : undefined + } + ) + }, [ + actions, + controller.activeThread?.latestTurn?.effort, + controller.activeThread?.latestTurn?.serviceTier, + controller.activeThread?.model, + controller.activeThread?.runtimeMode, + controller.selectedSession?.id + ]) + + const handleCreateThread = useCallback(() => { + void actions.newThread(controller.selectedSession?.id || undefined) + setShowHeaderMenu(false) + }, [actions, controller.selectedSession?.id]) + + const handleToggleDetailsPanel = useCallback(() => { + props.onToggleRightSidebar() + setShowHeaderMenu(false) + }, [props.onToggleRightSidebar]) + + const effectiveInteractionMode = interactionModeOverride || controller.activeThread?.interactionMode || 'default' return ( -
-
-
- -

{controller.selectedSession?.title || 'Assistant'}

- - {latestProjectLabel} - -
-
- - {planPanelAvailable ? ( - - ) : null} - - {showHeaderMenu &&
- - -
} -
-
+
+ +
+
+ + Moving to implementation. Switching from Plan to Chat. +
+
) } diff --git a/src/renderer/src/pages/assistant/AssistantConversationTimelinePane.tsx b/src/renderer/src/pages/assistant/AssistantConversationTimelinePane.tsx index 82d9ba2..63d6c36 100644 --- a/src/renderer/src/pages/assistant/AssistantConversationTimelinePane.tsx +++ b/src/renderer/src/pages/assistant/AssistantConversationTimelinePane.tsx @@ -1,17 +1,21 @@ -import { memo, type RefObject } from 'react' +import { memo, useLayoutEffect, useRef, type RefObject } from 'react' import { ArrowDown } from 'lucide-react' -import type { AssistantActivity, AssistantMessage } from '@shared/assistant/contracts' +import type { AssistantActivity, AssistantMessage, AssistantProposedPlan } from '@shared/assistant/contracts' +import type { PreviewOpenOptions } from '@/components/ui/file-preview/types' import type { AssistantTextStreamingMode } from '@/lib/settings' import { LoadingSpinner } from '@/components/ui/LoadingState' import { cn } from '@/lib/utils' import { AssistantTimeline } from './AssistantTimeline' import type { AssistantDiffTarget } from './assistant-diff-types' +import type { AssistantElementBounds } from './assistant-composer-types' export const AssistantConversationTimelinePane = memo(function AssistantConversationTimelinePane(props: { loading: boolean timelineScrollRef: RefObject + timelineContentRef: RefObject messages: AssistantMessage[] activities: AssistantActivity[] + proposedPlans?: AssistantProposedPlan[] latestProjectLabel: string projectTitle: string | null assistantMessageFilePath?: string | null @@ -26,14 +30,56 @@ export const AssistantConversationTimelinePane = memo(function AssistantConversa loadingChats: boolean assistantTextStreamingMode: AssistantTextStreamingMode showScrollToBottom: boolean + elevateScrollToBottom?: boolean + onScrollButtonBoundsChange?: (bounds: AssistantElementBounds | null) => void onScrollTimeline: (element: HTMLDivElement) => void onScrollToBottom: () => void onRequestDeleteUserMessage: (message: AssistantMessage) => void + onImplementProposedPlan?: (plan: AssistantProposedPlan) => Promise | void + onShowPlanPanel?: () => void + onOpenAttachmentPreview?: ( + file: { name: string; path: string }, + ext: string, + options?: PreviewOpenOptions + ) => Promise | void onOpenAssistantLink?: (href: string) => Promise | void onOpenEditedFile?: (filePath: string) => Promise | void onViewDiff?: (target: AssistantDiffTarget) => void }) { const projectRootPath = props.projectTitle + const floatingPlanOverlayRef = useRef(null) + const scrollButtonRef = useRef(null) + + useLayoutEffect(() => { + const element = scrollButtonRef.current + if (!props.showScrollToBottom || !element) { + props.onScrollButtonBoundsChange?.(null) + return + } + + const measure = () => { + const rect = element.getBoundingClientRect() + props.onScrollButtonBoundsChange?.({ + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height + }) + } + + const frameId = window.requestAnimationFrame(measure) + const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => measure()) : null + observer?.observe(element) + window.addEventListener('resize', measure) + + return () => { + window.cancelAnimationFrame(frameId) + observer?.disconnect() + window.removeEventListener('resize', measure) + } + }, [props.elevateScrollToBottom, props.onScrollButtonBoundsChange, props.showScrollToBottom]) return (
@@ -48,18 +94,20 @@ export const AssistantConversationTimelinePane = memo(function AssistantConversa
props.onScrollTimeline(event.currentTarget)} - className="custom-scrollbar h-full overflow-y-auto px-4 py-4" + className="custom-scrollbar relative h-full overflow-y-auto px-4 py-4" > -
+
-
+
+
+ +
+ +
+ +
+ {loadingIdes && !idesLoaded ? ( +
+ + Checking apps... +
+ ) : null} + + {availableIdes.map((ide) => { + const opening = openingTargetId === ide.id + return ( + + ) + })} + + {idesLoaded && !loadingIdes && availableIdes.length === 0 ? ( +
+ Cursor, VS Code, and Android Studio were not detected. +
+ ) : null} + +
+ + + + + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+ +
+
+ ) +} diff --git a/src/renderer/src/pages/assistant/AssistantPage.tsx b/src/renderer/src/pages/assistant/AssistantPage.tsx index f8d7916..d061d52 100644 --- a/src/renderer/src/pages/assistant/AssistantPage.tsx +++ b/src/renderer/src/pages/assistant/AssistantPage.tsx @@ -1,48 +1,43 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' -import type { AssistantActivity, AssistantMessage, AssistantTurnUsage } from '@shared/assistant/contracts' +import type { AssistantMessage } from '@shared/assistant/contracts' import { FilePreviewModal } from '@/components/ui/FilePreviewModal' +import type { PreviewOpenOptions } from '@/components/ui/file-preview/types' import { useFilePreview } from '@/components/ui/file-preview/useFilePreview' -import { useAssistantStore } from '@/lib/assistant/store' -import { isAssistantThreadActivelyWorking } from '@/lib/assistant/selectors' import { ASSISTANT_MAIN_SIDEBAR_COLLAPSED_STORAGE_KEY, useSidebar } from '@/components/layout/Sidebar' +import { useAssistantStoreActions, useAssistantStoreSelector } from '@/lib/assistant/store' +import { getActiveAssistantThread, getSelectedAssistantSession } from '@/lib/assistant/selectors' import { ConnectedAssistantSessionsRail } from './AssistantConnectedSessionsRail' import { AssistantConversationPane } from './AssistantConversationPane' import { AssistantDiffPanel } from './AssistantDiffPanel' -import { AssistantPlanPanel } from './AssistantPlanPanel' -import { - DeleteHistoryConfirm, - formatCompactMetric, - formatContextMetric, - getIssueActivities, - buildIssueLogEntry, - copyTextToClipboard, - IssueLogDetailsModal, - resolveUsageMetricTone, - type LogDetailsTab, - type UsageMetricTone -} from './AssistantPageHelpers' -import { AssistantThreadDetailsPanel } from './AssistantThreadDetailsPanel' +import { ConnectedAssistantPlanPanel } from './ConnectedAssistantPlanPanel' +import { ConnectedAssistantThreadDetailsPanel } from './ConnectedAssistantThreadDetailsPanel' +import { DeleteHistoryConfirm } from './AssistantPageHelpers' import type { AssistantDiffTarget } from './assistant-diff-types' -import { getAssistantActivePlanProgress, hasAssistantPlanPanelContent } from './assistant-plan-utils' -import { - subscribeAssistantComposerSessionState, - readAssistantComposerSessionState, - type AssistantComposerSessionState -} from './assistant-composer-session-state' -import { formatAssistantModelLabel } from './assistant-model-labels' +import { openAssistantFileTarget } from './assistant-file-navigation' import { - SIDEBAR_EFFORT_LABELS, useAssistantPageSidebarState, type AssistantRightPanelMode } from './useAssistantPageSidebarState' -import { getAssistantLinkBaseFilePath, openAssistantFileTarget } from './assistant-file-navigation' -import { useAssistantPageTimelineScroll } from './useAssistantPageTimelineScroll' -type IssueActivityGroup = { - activity: AssistantActivity - activities: AssistantActivity[] - count: number +type AssistantPageShellSelection = { + bootstrapped: boolean + assistantAvailable: boolean + assistantConnected: boolean + commandPending: boolean + selectedSessionId: string | null + activeThreadId: string | null + selectedProjectPath: string +} + +function areAssistantPageShellSelectionsEqual(left: AssistantPageShellSelection, right: AssistantPageShellSelection): boolean { + return left.bootstrapped === right.bootstrapped + && left.assistantAvailable === right.assistantAvailable + && left.assistantConnected === right.assistantConnected + && left.commandPending === right.commandPending + && left.selectedSessionId === right.selectedSessionId + && left.activeThreadId === right.activeThreadId + && left.selectedProjectPath === right.selectedProjectPath } function readAssistantMainSidebarCollapsedPreference(): boolean { @@ -56,12 +51,24 @@ function readAssistantMainSidebarCollapsedPreference(): boolean { export default function AssistantPage() { const navigate = useNavigate() - const controller = useAssistantStore() + const actions = useAssistantStoreActions() + const shell = useAssistantStoreSelector((state) => { + const selectedSession = getSelectedAssistantSession(state.snapshot) + const activeThread = getActiveAssistantThread(selectedSession) + + return { + bootstrapped: state.hydrated, + assistantAvailable: state.status.available, + assistantConnected: state.status.connected, + commandPending: state.commandPending, + selectedSessionId: selectedSession?.id || null, + activeThreadId: activeThread?.id || null, + selectedProjectPath: String(selectedSession?.projectPath || activeThread?.cwd || '').trim() + } + }, areAssistantPageShellSelectionsEqual) const preview = useFilePreview() const { isCollapsed: mainSidebarCollapsed, setIsCollapsed: setMainSidebarCollapsed } = useSidebar() - const headerMenuRef = useRef(null) const autoConnectAttemptedSessionRef = useRef(null) - const lastUsageByThreadRef = useRef>(new Map()) const mainSidebarBeforeAssistantRef = useRef(null) const previousMainSidebarCollapsedRef = useRef(mainSidebarCollapsed) const autoCollapsedInnerSidebarRef = useRef(false) @@ -71,40 +78,14 @@ export default function AssistantPage() { setLeftSidebarCollapsed, leftSidebarWidth, setLeftSidebarWidth, + railMode, + setRailMode, rightPanelMode, setRightPanelMode } = useAssistantPageSidebarState() - const [showHeaderMenu, setShowHeaderMenu] = useState(false) - const [selectedLogActivity, setSelectedLogActivity] = useState(null) const [selectedDiffTarget, setSelectedDiffTarget] = useState(null) const [pendingMessageDelete, setPendingMessageDelete] = useState(null) const [deletingMessageId, setDeletingMessageId] = useState(null) - const [logDetailsTab, setLogDetailsTab] = useState('rendered') - const [copiedLogId, setCopiedLogId] = useState(null) - const [copyErrorByLogId, setCopyErrorByLogId] = useState>({}) - const [projectPathCopied, setProjectPathCopied] = useState(false) - const [showFullProjectPath, setShowFullProjectPath] = useState(false) - const [allLogsCopied, setAllLogsCopied] = useState(false) - const [clearingLogs, setClearingLogs] = useState(false) - const [logsExpanded, setLogsExpanded] = useState(false) - const [composerSessionState, setComposerSessionState] = useState({}) - - useEffect(() => { - const selectedSessionId = controller.selectedSession?.id || null - setComposerSessionState(readAssistantComposerSessionState(selectedSessionId)) - }, [controller.selectedSession?.id]) - - useEffect(() => subscribeAssistantComposerSessionState((updatedSessionId, nextState) => { - if (!controller.selectedSession?.id || updatedSessionId !== controller.selectedSession.id) return - setComposerSessionState(nextState) - }), [controller.selectedSession?.id]) - - useEffect(() => { - const threadId = controller.activeThread?.id - const usage = controller.activeThread?.latestTurn?.usage - if (!threadId || !usage) return - lastUsageByThreadRef.current.set(threadId, usage) - }, [controller.activeThread?.id, controller.activeThread?.latestTurn?.usage]) useEffect(() => { mainSidebarBeforeAssistantRef.current = mainSidebarCollapsed @@ -154,200 +135,28 @@ export default function AssistantPage() { }, [leftSidebarCollapsed, mainSidebarCollapsed, rightPanelMode, setLeftSidebarCollapsed]) useEffect(() => { - if (!showHeaderMenu) return - const handlePointerDown = (event: MouseEvent) => { - if (!headerMenuRef.current?.contains(event.target as Node)) setShowHeaderMenu(false) - } - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') setShowHeaderMenu(false) - } - document.addEventListener('mousedown', handlePointerDown) - window.addEventListener('keydown', handleEscape) - return () => { - document.removeEventListener('mousedown', handlePointerDown) - window.removeEventListener('keydown', handleEscape) - } - }, [showHeaderMenu]) - - const selectedProjectPath = String(controller.selectedSession?.projectPath || controller.activeThread?.cwd || '').trim() - const assistantMessageFilePath = useMemo( - () => getAssistantLinkBaseFilePath(selectedProjectPath), - [selectedProjectPath] - ) - const planPanelAvailable = hasAssistantPlanPanelContent(controller.activePlan, controller.latestProposedPlan) - const activePlanProgress = getAssistantActivePlanProgress(controller.activePlan, controller.activeThread?.latestTurn || null) - const planProgressLabel = activePlanProgress ? `${activePlanProgress.currentStepNumber}/${activePlanProgress.totalSteps}` : null - const planIsComplete = activePlanProgress?.isComplete === true - const shouldShowWorkingIndicator = isAssistantThreadActivelyWorking(controller.activeThread) - && !controller.timelineMessages.some((message) => message.role === 'assistant' && message.streaming) - const selectedProjectLabel = selectedProjectPath - ? selectedProjectPath.split(/[\\/]/).filter(Boolean).pop() || selectedProjectPath - : 'not set' - const selectedProjectPathWithTilde = selectedProjectPath - ? selectedProjectPath.replace(/^[A-Z]:\\Users\\[^\\]+/, '~').replace(/\\/g, '/') - : '' - const displayProjectPath = showFullProjectPath ? selectedProjectPathWithTilde : selectedProjectLabel - const availableModels = useMemo(() => { - if (controller.knownModels.length > 0) return controller.knownModels - const activeModel = String(controller.activeThread?.model || '').trim() - return activeModel ? [{ id: activeModel, label: activeModel }] : [] - }, [controller.activeThread?.model, controller.knownModels]) - const sidebarSelectedModel = formatAssistantModelLabel(composerSessionState.model || controller.activeThread?.model || availableModels[0]?.id || '') - const latestTurnUsage = useMemo(() => { - const threadId = controller.activeThread?.id - if (!threadId) return null - return controller.activeThread?.latestTurn?.usage - || lastUsageByThreadRef.current.get(threadId) - || null - }, [controller.activeThread?.id, controller.activeThread?.latestTurn?.usage]) - const contextUsedTokens = latestTurnUsage?.totalTokens ?? null - const contextWindowTokens = latestTurnUsage?.modelContextWindow ?? null - const sessionSidebarWidth = leftSidebarCollapsed ? 0 : Math.max(180, Math.min(520, Math.round(leftSidebarWidth))) - const sidebarMetricChips = [ - { - label: 'Input tokens', - value: latestTurnUsage?.inputTokens != null ? formatCompactMetric(latestTurnUsage.inputTokens) : null, - tone: resolveUsageMetricTone(latestTurnUsage?.inputTokens, contextWindowTokens, { normal: 12_000, high: 40_000 }) - }, - { - label: 'Output tokens', - value: latestTurnUsage?.outputTokens != null ? formatCompactMetric(latestTurnUsage.outputTokens) : null, - tone: resolveUsageMetricTone(latestTurnUsage?.outputTokens, contextWindowTokens, { normal: 4_000, high: 16_000 }) - }, - { - label: 'Reasoning tokens', - value: latestTurnUsage?.reasoningOutputTokens != null ? formatCompactMetric(latestTurnUsage.reasoningOutputTokens) : null, - tone: resolveUsageMetricTone(latestTurnUsage?.reasoningOutputTokens, contextWindowTokens, { normal: 4_000, high: 16_000 }) - }, - { - label: 'Cached input', - value: latestTurnUsage?.cachedInputTokens != null ? formatCompactMetric(latestTurnUsage.cachedInputTokens) : null, - tone: resolveUsageMetricTone(latestTurnUsage?.cachedInputTokens, contextWindowTokens, { normal: 8_000, high: 24_000 }) - }, - { - label: 'Total tokens', - value: latestTurnUsage?.totalTokens != null ? formatCompactMetric(latestTurnUsage.totalTokens) : null, - tone: resolveUsageMetricTone(latestTurnUsage?.totalTokens, contextWindowTokens, { normal: 16_000, high: 48_000 }) - }, - { - label: 'Context usage', - value: contextWindowTokens ? formatContextMetric(contextUsedTokens, contextWindowTokens) : null, - tone: resolveUsageMetricTone(contextUsedTokens, contextWindowTokens, { normal: 0, high: 0 }) - } - ].filter((entry): entry is { label: string; value: string; tone: UsageMetricTone } => Boolean(entry.value)) - const selectedThinkingLabel = SIDEBAR_EFFORT_LABELS[composerSessionState.effort || 'high'] - const selectedSpeedLabel = composerSessionState.fastModeEnabled ? 'Fast' : 'Standard' - const selectedRuntimeMode = composerSessionState.runtimeMode || controller.activeThread?.runtimeMode || 'approval-required' - const selectedRuntimeLabel = selectedRuntimeMode === 'full-access' ? 'Full access' : 'Supervised' - const contextUsedDisplay = contextUsedTokens != null ? formatCompactMetric(contextUsedTokens) : controller.activeThread?.latestTurn ? 'Not reported' : 'No turn yet' - const contextAvailableDisplay = contextWindowTokens != null ? formatCompactMetric(contextWindowTokens) : controller.activeThread?.latestTurn ? 'Not reported' : 'No turn yet' - const contextPercentage = contextUsedTokens != null && contextWindowTokens != null && contextWindowTokens > 0 - ? Math.round((contextUsedTokens / contextWindowTokens) * 100) - : null - const contextColor = contextPercentage != null - ? contextPercentage >= 90 ? 'text-red-300' : contextPercentage >= 70 ? 'text-amber-300' : 'text-emerald-300' - : 'text-sparkle-text' - const lastTimelineMessage = controller.timelineMessages[controller.timelineMessages.length - 1] || null - const latestTimelineActivity = controller.activityFeed[0] || null - const shouldComputeIssueActivities = rightPanelMode === 'details' || Boolean(selectedLogActivity) - const issueActivities = useMemo(() => { - if (!shouldComputeIssueActivities) return [] - - const nextActivities = [...getIssueActivities(controller.activityFeed)] - if (controller.commandError) { - nextActivities.unshift({ - id: `assistant-local-error-${controller.commandError}`, - kind: 'ui.command-error', - tone: 'error', - summary: 'Assistant command failed', - detail: controller.commandError, - turnId: controller.activeThread?.latestTurn?.id || null, - createdAt: latestTimelineActivity?.createdAt || controller.activeThread?.updatedAt || controller.selectedSession?.updatedAt || new Date(0).toISOString() - }) - } - return nextActivities - }, [ - controller.activityFeed, - controller.commandError, - controller.activeThread?.latestTurn?.id, - controller.activeThread?.updatedAt, - controller.selectedSession?.updatedAt, - latestTimelineActivity?.createdAt, - shouldComputeIssueActivities - ]) - const groupedIssueActivities = useMemo(() => { - const groups: IssueActivityGroup[] = [] - for (const activity of issueActivities) { - const lastGroup = groups[groups.length - 1] - if (lastGroup && lastGroup.activity.summary === activity.summary && lastGroup.activity.tone === activity.tone) { - lastGroup.count += 1 - lastGroup.activities.push(activity) - } else { - groups.push({ activity, activities: [activity], count: 1 }) - } - } - return groups - }, [issueActivities]) - const latestIssueGroup = groupedIssueActivities[0] || null - const olderIssueGroups = groupedIssueActivities.slice(1) - const { timelineScrollRef, onScrollTimeline, onScrollToBottom } = useAssistantPageTimelineScroll({ - sessionId: controller.selectedSession?.id || null, - threadId: controller.activeThread?.id || null, - loading: controller.loading, - timelineMessageCount: controller.timelineMessages.length, - lastTimelineMessageId: lastTimelineMessage?.id || null, - lastTimelineMessageUpdatedAt: lastTimelineMessage?.updatedAt || null, - activityFeedCount: controller.activityFeed.length, - latestTimelineActivityId: latestTimelineActivity?.id || null, - latestTimelineActivityCreatedAt: latestTimelineActivity?.createdAt || null, - shouldShowWorkingIndicator, - latestTurnStartedAt: controller.activeThread?.latestTurn?.startedAt || null, - latestTurnState: controller.activeThread?.latestTurn?.state || null, - threadState: controller.activeThread?.state || null - }) - - useEffect(() => { - if (olderIssueGroups.length === 0 && logsExpanded) setLogsExpanded(false) - }, [logsExpanded, olderIssueGroups.length]) - - useEffect(() => { - if (rightPanelMode === 'plan' && !planPanelAvailable) setRightPanelMode('none') - }, [planPanelAvailable, rightPanelMode]) - - useEffect(() => { - if (rightPanelMode === 'diff' && !selectedDiffTarget) setRightPanelMode('none') - }, [rightPanelMode, selectedDiffTarget]) + if (!shell.bootstrapped || !shell.assistantAvailable || shell.assistantConnected || shell.commandPending) return + const sessionId = shell.selectedSessionId + if (!sessionId || autoConnectAttemptedSessionRef.current === sessionId) return + autoConnectAttemptedSessionRef.current = sessionId + void actions.connect(sessionId) + }, [actions, shell.assistantAvailable, shell.assistantConnected, shell.bootstrapped, shell.commandPending, shell.selectedSessionId]) useEffect(() => { setSelectedDiffTarget(null) if (rightPanelMode === 'diff') setRightPanelMode('none') - }, [controller.selectedSession?.id, controller.activeThread?.id]) - - useEffect(() => { - if (!controller.bootstrapped || !controller.status?.available || controller.status?.connected || controller.commandPending) return - const sessionId = controller.selectedSession?.id || null - if (!sessionId || autoConnectAttemptedSessionRef.current === sessionId) return - autoConnectAttemptedSessionRef.current = sessionId - void controller.connect(sessionId) - }, [ - controller.bootstrapped, - controller.commandPending, - controller.selectedSession?.id, - controller.status?.available, - controller.status?.connected, - controller.connect - ]) + }, [setRightPanelMode, shell.activeThreadId, shell.selectedSessionId]) const openAssistantTarget = useCallback(async (target: string, startInEditMode = false) => { const opened = await openAssistantFileTarget({ target, - projectPath: selectedProjectPath, + projectPath: shell.selectedProjectPath, navigate, openPreview: preview.openPreview, previewOptions: startInEditMode ? { startInEditMode: true } : undefined }) return opened - }, [navigate, preview.openPreview, selectedProjectPath]) + }, [navigate, preview.openPreview, shell.selectedProjectPath]) const handleOpenAssistantInternalLink = useCallback(async (href: string) => { await openAssistantTarget(href) @@ -357,67 +166,29 @@ export default function AssistantPage() { await openAssistantTarget(filePath, true) }, [openAssistantTarget]) + const handleOpenAttachmentPreview = useCallback(async ( + file: { name: string; path: string }, + ext: string, + options?: PreviewOpenOptions + ) => { + await preview.openPreview(file, ext, options) + }, [preview.openPreview]) + const handleViewActivityDiff = useCallback((target: AssistantDiffTarget) => { setSelectedDiffTarget(target) setRightPanelMode('diff') - }, []) + }, [setRightPanelMode]) - const handleDeleteUserMessage = async () => { + const handleDeleteUserMessage = useCallback(async () => { if (!pendingMessageDelete) return try { setDeletingMessageId(pendingMessageDelete.id) - const result = await controller.deleteMessageResult(pendingMessageDelete.id, controller.selectedSession?.id) + const result = await actions.deleteMessageResult(pendingMessageDelete.id, shell.selectedSessionId || undefined) if (result.success) setPendingMessageDelete(null) } finally { setDeletingMessageId(null) } - } - const handleCopyProjectPath = async () => { - if (!selectedProjectPath) return - try { - await copyTextToClipboard(selectedProjectPath) - setProjectPathCopied(true) - window.setTimeout(() => setProjectPathCopied(false), 1600) - } catch {} - } - const handleCopyLog = async (activity: AssistantActivity) => { - try { - await copyTextToClipboard(JSON.stringify(buildIssueLogEntry(activity), null, 2)) - setCopiedLogId(activity.id) - setCopyErrorByLogId((current) => ({ ...current, [activity.id]: null })) - window.setTimeout(() => setCopiedLogId((current) => current === activity.id ? null : current), 1600) - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to copy to clipboard' - setCopyErrorByLogId((current) => ({ ...current, [activity.id]: message })) - window.setTimeout(() => { - setCopyErrorByLogId((current) => { - const next = { ...current } - if (next[activity.id] === message) delete next[activity.id] - return next - }) - }, 2400) - } - } - const handleCopyAllLogs = async () => { - if (issueActivities.length === 0) return - try { - const allLogs = issueActivities.map((activity) => JSON.stringify(buildIssueLogEntry(activity), null, 2)).join('\n\n---\n\n') - await copyTextToClipboard(allLogs) - setAllLogsCopied(true) - window.setTimeout(() => setAllLogsCopied(false), 1600) - } catch {} - } - const handleClearLogs = async () => { - if (!controller.selectedSession?.id || !latestIssueGroup || clearingLogs) return - try { - setClearingLogs(true) - setLogsExpanded(false) - const result = await controller.clearLogsResult(controller.selectedSession.id) - if (result.success) controller.clearCommandError() - } finally { - setClearingLogs(false) - } - } + }, [actions, pendingMessageDelete, shell.selectedSessionId]) const handleToggleAssistantLeftSidebar = useCallback(() => { autoCollapsedInnerSidebarRef.current = false @@ -430,6 +201,39 @@ export default function AssistantPage() { }) }, [mainSidebarCollapsed, rightPanelMode, setLeftSidebarCollapsed, setMainSidebarCollapsed]) + const handleToggleRightSidebar = useCallback(() => { + setRightPanelMode((current) => current === 'details' ? 'none' : 'details') + }, [setRightPanelMode]) + + const handleTogglePlanPanel = useCallback(() => { + setRightPanelMode((current) => current === 'plan' ? 'none' : 'plan') + }, [setRightPanelMode]) + + const handleCloseRightPanel = useCallback(() => { + setRightPanelMode('none') + }, [setRightPanelMode]) + + const handleCloseDiffPanel = useCallback(() => { + setRightPanelMode('none') + setSelectedDiffTarget(null) + }, [setRightPanelMode]) + + const handleShowThreadDetailsPanel = useCallback(() => { + setRightPanelMode('details') + }, [setRightPanelMode]) + + const handleShowPlanPanel = useCallback(() => { + setRightPanelMode('plan') + }, [setRightPanelMode]) + + const handleCancelPendingMessageDelete = useCallback(() => { + if (deletingMessageId) return + setPendingMessageDelete(null) + }, [deletingMessageId]) + + const sessionSidebarWidth = leftSidebarCollapsed ? 0 : Math.max(180, Math.min(520, Math.round(leftSidebarWidth))) + const compactRightPanel = !leftSidebarCollapsed && rightPanelMode !== 'none' + return (
@@ -437,118 +241,52 @@ export default function AssistantPage() {
setRightPanelMode((current) => current === 'details' ? 'none' : 'details')} - onTogglePlanPanel={() => setRightPanelMode((current) => current === 'plan' ? 'none' : 'plan')} + onToggleRightSidebar={handleToggleRightSidebar} + onTogglePlanPanel={handleTogglePlanPanel} onOpenAssistantLink={handleOpenAssistantInternalLink} + onOpenAttachmentPreview={handleOpenAttachmentPreview} onOpenEditedFile={handleOpenEditedFile} onViewDiff={handleViewActivityDiff} /> { - setRightPanelMode('none') - setSelectedDiffTarget(null) - }} + onClose={handleCloseDiffPanel} /> - setRightPanelMode('none')} + compact={compactRightPanel} + onClose={handleCloseRightPanel} + onShowThreadDetails={handleShowThreadDetailsPanel} onOpenInternalLink={handleOpenAssistantInternalLink} /> - setRightPanelMode('none')} - onToggleProjectPath={() => setShowFullProjectPath((current) => !current)} - onCopyProjectPath={() => void handleCopyProjectPath()} - onToggleLogsExpanded={() => setLogsExpanded((current) => !current)} - onCopyAllLogs={() => void handleCopyAllLogs()} - onClearLogs={() => void handleClearLogs()} - onCopyLog={(activity) => void handleCopyLog(activity)} - onShowLogDetails={(activity) => { - setSelectedLogActivity(activity) - setLogDetailsTab('rendered') - }} - onToggleAssistantConnection={() => { - if (controller.status?.connected) { - void controller.disconnect(controller.selectedSession?.id || undefined) - } else { - void controller.connect(controller.selectedSession?.id || undefined) - } - }} + compact={compactRightPanel} + onClose={handleCloseRightPanel} + onShowPlan={handleShowPlanPanel} />
- setSelectedLogActivity(null)} - /> void handleDeleteUserMessage()} - onCancel={() => { - if (deletingMessageId) return - setPendingMessageDelete(null) - }} + onCancel={handleCancelPendingMessageDelete} /> {preview.previewFile ? ( Promise | void + onDecline: () => Promise | void +}) { + const { request, responding, onApprove, onDecline } = props + const [title, setTitle] = useState(request.suggestedLabName || '') + const [repoUrl, setRepoUrl] = useState(request.repoUrl || '') + + useEffect(() => { + setTitle(request.suggestedLabName || '') + setRepoUrl(request.repoUrl || '') + }, [request.createdAt, request.id, request.kind, request.repoUrl, request.suggestedLabName]) + + const isClone = request.kind === 'clone-repo' + const canApprove = isClone ? repoUrl.trim().length > 0 : true + + return ( +
+
+
Playground Approval
+

+ {isClone ? 'Assistant wants to clone a repo into a new Lab' : 'Assistant wants to create a new Lab'} +

+

+ Approve this only if you want this Playground chat to start filesystem work in an isolated lab. +

+
+
+ setTitle(event.target.value)} + className="w-full rounded-xl border border-white/10 bg-sparkle-bg px-4 py-3 text-sm text-sparkle-text outline-none transition-all focus:border-[var(--accent-primary)]/40 focus:ring-4 focus:ring-[var(--accent-primary)]/10" + placeholder="Lab name" + maxLength={120} + /> + {isClone ? ( + setRepoUrl(event.target.value)} + className="w-full rounded-xl border border-white/10 bg-sparkle-bg px-4 py-3 text-sm text-sparkle-text outline-none transition-all focus:border-[var(--accent-primary)]/40 focus:ring-4 focus:ring-[var(--accent-primary)]/10" + placeholder="Repository URL" + /> + ) : null} +
+ {request.prompt} +
+
+
+ + +
+
+ ) +}) diff --git a/src/renderer/src/pages/assistant/AssistantPendingUserInputPanel.tsx b/src/renderer/src/pages/assistant/AssistantPendingUserInputPanel.tsx index 7b51005..df2c77b 100644 --- a/src/renderer/src/pages/assistant/AssistantPendingUserInputPanel.tsx +++ b/src/renderer/src/pages/assistant/AssistantPendingUserInputPanel.tsx @@ -1,7 +1,11 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowLeft, Check, Loader2 } from 'lucide-react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' +import { ArrowLeft, Check, CircleHelp, GitBranch, Plus, SquarePen } from 'lucide-react' import type { AssistantPendingUserInput } from '@shared/assistant/contracts' +import { AnimatedHeight } from '@/components/ui/AnimatedHeight' import { cn } from '@/lib/utils' +import { ComposerFooterControls, ComposerSendButton } from './AssistantComposerSections' +import { useAssistantComposerController } from './useAssistantComposerController' +import type { AssistantComposerSendOptions, ComposerContextFile } from './assistant-composer-types' import { buildAssistantPendingUserInputAnswers, deriveAssistantPendingUserInputProgress, @@ -9,16 +13,54 @@ import { type AssistantPendingUserInputDraftAnswers } from './assistant-pending-user-input' +const CUSTOM_ANSWER_LABEL = 'Write your own answer' + export const AssistantPendingUserInputPanel = memo(function AssistantPendingUserInputPanel(props: { pendingUserInputs: AssistantPendingUserInput[] responding: boolean onRespond: (requestId: string, answers: Record) => Promise | void + sessionId: string | null + assistantAvailable: boolean + assistantConnected: boolean + selectedProjectPath: string | null + availableModels: Array<{ id: string; label: string; description?: string }> + activeModel: string | undefined + modelsLoading: boolean + runtimeMode: 'approval-required' | 'full-access' + interactionMode: 'default' | 'plan' + activeProfile: 'safe-dev' | 'yolo-fast' + activeStatusLabel: string }) { const { pendingUserInputs, responding, onRespond } = props const activePrompt = pendingUserInputs[0] || null const [draftAnswersByRequestId, setDraftAnswersByRequestId] = useState>({}) const [questionIndex, setQuestionIndex] = useState(0) - const autoAdvanceTimerRef = useRef(null) + const [customQuestionIdByRequestId, setCustomQuestionIdByRequestId] = useState>({}) + const [questionShellOpen, setQuestionShellOpen] = useState(false) + const [returnToReview, setReturnToReview] = useState(false) + const [expandedOptionKey, setExpandedOptionKey] = useState(null) + const customTextareaRef = useRef(null) + const animatedStepRef = useRef(null) + + const composerController = useAssistantComposerController({ + sessionId: props.sessionId, + onSend: async (_prompt: string, _contextFiles: ComposerContextFile[], _options: AssistantComposerSendOptions) => false, + disabled: !props.sessionId || !props.assistantAvailable, + allowEmptySubmit: true, + isSending: responding, + isThinking: false, + thinkingLabel: props.activeStatusLabel, + isConnected: props.assistantConnected, + activeModel: props.activeModel, + modelOptions: props.availableModels, + modelsLoading: props.modelsLoading, + modelsError: null, + activeProfile: props.activeProfile, + runtimeMode: props.runtimeMode, + interactionMode: props.interactionMode, + projectPath: props.selectedProjectPath, + submitLabel: 'Continue' + }) const activeDraftAnswers = useMemo( () => activePrompt ? draftAnswersByRequestId[activePrompt.requestId] || {} : {}, @@ -29,28 +71,40 @@ export const AssistantPendingUserInputPanel = memo(function AssistantPendingUser [activeDraftAnswers, activePrompt, questionIndex] ) - useEffect(() => { - return () => { - if (autoAdvanceTimerRef.current !== null) window.clearTimeout(autoAdvanceTimerRef.current) - } - }, []) - useEffect(() => { const pendingRequestIds = new Set(pendingUserInputs.map((entry) => entry.requestId)) setDraftAnswersByRequestId((current) => { const nextEntries = Object.entries(current).filter(([requestId]) => pendingRequestIds.has(requestId)) return nextEntries.length === Object.keys(current).length ? current : Object.fromEntries(nextEntries) }) + setCustomQuestionIdByRequestId((current) => { + const nextEntries = Object.entries(current).filter(([requestId]) => pendingRequestIds.has(requestId)) + return nextEntries.length === Object.keys(current).length ? current : Object.fromEntries(nextEntries) + }) }, [pendingUserInputs]) useEffect(() => { if (!activePrompt) { setQuestionIndex(0) + setReturnToReview(false) + setExpandedOptionKey(null) return } - const nextQuestionIndex = findFirstUnansweredAssistantPendingUserInputQuestionIndex(activePrompt.questions, activeDraftAnswers) - setQuestionIndex(nextQuestionIndex) - }, [activeDraftAnswers, activePrompt?.requestId]) + setQuestionShellOpen(false) + setReturnToReview(false) + setExpandedOptionKey(null) + setQuestionIndex(findFirstUnansweredAssistantPendingUserInputQuestionIndex(activePrompt.questions, activeDraftAnswers)) + }, [activePrompt?.requestId]) + + useEffect(() => { + setExpandedOptionKey(null) + }, [activePrompt?.requestId, questionIndex]) + + useEffect(() => { + if (!activePrompt) return + const frame = window.requestAnimationFrame(() => setQuestionShellOpen(true)) + return () => window.cancelAnimationFrame(frame) + }, [activePrompt?.requestId]) const handleSelectOption = useCallback((questionId: string, optionLabel: string) => { if (!activePrompt) return @@ -61,30 +115,78 @@ export const AssistantPendingUserInputPanel = memo(function AssistantPendingUser [questionId]: optionLabel } })) + setCustomQuestionIdByRequestId((current) => ({ + ...current, + [activePrompt.requestId]: current[activePrompt.requestId] === questionId ? null : current[activePrompt.requestId] ?? null + })) + }, [activePrompt]) + + const handleSelectCustom = useCallback((questionId: string) => { + if (!activePrompt) return + const activeQuestion = activePrompt.questions.find((question) => question.id === questionId) || null + setCustomQuestionIdByRequestId((current) => ({ + ...current, + [activePrompt.requestId]: questionId + })) + setDraftAnswersByRequestId((current) => { + const currentAnswers = current[activePrompt.requestId] || {} + const currentAnswer = String(currentAnswers[questionId] || '') + const nextAnswer = activeQuestion?.options.some((option) => option.label === currentAnswer) ? '' : currentAnswer + return { + ...current, + [activePrompt.requestId]: { + ...currentAnswers, + [questionId]: nextAnswer + } + } + }) + window.requestAnimationFrame(() => { + const textarea = customTextareaRef.current + if (!textarea) return + textarea.focus() + const cursor = textarea.value.length + textarea.setSelectionRange(cursor, cursor) + }) + }, [activePrompt]) + + const handleCustomAnswerChange = useCallback((questionId: string, value: string) => { + if (!activePrompt) return + setCustomQuestionIdByRequestId((current) => ({ + ...current, + [activePrompt.requestId]: questionId + })) + setDraftAnswersByRequestId((current) => ({ + ...current, + [activePrompt.requestId]: { + ...(current[activePrompt.requestId] || {}), + [questionId]: value + } + })) }, [activePrompt]) const handleAdvance = useCallback(async () => { if (!activePrompt || !progress) return const resolvedAnswers = buildAssistantPendingUserInputAnswers(activePrompt.questions, activeDraftAnswers) - if (!resolvedAnswers) return - if (!progress.isLastQuestion) { - setQuestionIndex(Math.min(progress.questionIndex + 1, activePrompt.questions.length - 1)) + if (progress.isReviewStep) { + if (!resolvedAnswers) return + await onRespond(activePrompt.requestId, resolvedAnswers) return } - await onRespond(activePrompt.requestId, resolvedAnswers) + if (!progress.hasAnswer) return + if (returnToReview) { + setQuestionIndex(activePrompt.questions.length) + setReturnToReview(false) + return + } + if (progress.questionIndex < activePrompt.questions.length - 1) { + setQuestionIndex(progress.questionIndex + 1) + return + } + setQuestionIndex(activePrompt.questions.length) }, [activeDraftAnswers, activePrompt, onRespond, progress]) - const handleSelectOptionAndAdvance = useCallback((questionId: string, optionLabel: string) => { - handleSelectOption(questionId, optionLabel) - if (autoAdvanceTimerRef.current !== null) window.clearTimeout(autoAdvanceTimerRef.current) - autoAdvanceTimerRef.current = window.setTimeout(() => { - autoAdvanceTimerRef.current = null - void handleAdvance() - }, 180) - }, [handleAdvance, handleSelectOption]) - useEffect(() => { const activeQuestion = progress?.activeQuestion if (!activePrompt || !activeQuestion || responding) return @@ -92,120 +194,473 @@ export const AssistantPendingUserInputPanel = memo(function AssistantPendingUser const handleKeyDown = (event: KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return const target = event.target - if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return + if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement || target instanceof HTMLSelectElement) return + const digit = Number.parseInt(event.key, 10) - if (Number.isNaN(digit) || digit < 1 || digit > 9) return - const option = activeQuestion.options[digit - 1] - if (!option) return - event.preventDefault() - handleSelectOptionAndAdvance(activeQuestion.id, option.label) + if (!Number.isNaN(digit) && digit >= 1 && digit <= 9) { + const option = activeQuestion.options[digit - 1] + if (!option) return + event.preventDefault() + handleSelectOption(activeQuestion.id, option.label) + return + } + + if ((event.key === 'Enter' || event.key === 'NumpadEnter') && progress.hasAnswer) { + event.preventDefault() + void handleAdvance() + } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [activePrompt, handleSelectOptionAndAdvance, progress?.activeQuestion, responding]) + }, [activePrompt, handleAdvance, handleSelectOption, progress, responding]) + + useLayoutEffect(() => { + if (!progress?.activeQuestion || !activePrompt) return + const activeCustomQuestionId = customQuestionIdByRequestId[activePrompt.requestId] || null + const shouldFocusCustomComposer = activeCustomQuestionId === progress.activeQuestion.id || progress.isCustomAnswer + if (!shouldFocusCustomComposer) return + const textarea = customTextareaRef.current + if (!textarea) return + textarea.focus() + const cursor = textarea.value.length + textarea.setSelectionRange(cursor, cursor) + }, [activePrompt, customQuestionIdByRequestId, progress?.activeQuestion, progress?.isCustomAnswer]) - if (!activePrompt || !progress?.activeQuestion) return null + if (!activePrompt || !progress) return null const activeQuestion = progress.activeQuestion + const isReviewStep = progress.isReviewStep + const activeCustomQuestionId = customQuestionIdByRequestId[activePrompt.requestId] || null + const showCustomComposer = Boolean( + activeQuestion + && (activeCustomQuestionId === activeQuestion.id || progress.isCustomAnswer) + ) + const animatedStageKey = isReviewStep + ? `${activePrompt.requestId}:review` + : `${activePrompt.requestId}:${activeQuestion?.id || questionIndex}` + const customOptionKey = activeQuestion ? `${activeQuestion.id}:__custom__` : null + const answeredAllQuestions = progress.answeredQuestionCount >= activePrompt.questions.length + const actionLabel = responding ? 'Finish' : isReviewStep ? 'Finish' : returnToReview ? 'Back to review' : 'Continue' + const canAdvance = isReviewStep ? answeredAllQuestions : progress.hasAnswer + const reviewAnswers = activePrompt.questions.map((question, index) => ({ + question, + index, + answer: String(activeDraftAnswers[question.id] || '') + })) + + const handleCustomTextareaKeyDown = useCallback((event: ReactKeyboardEvent) => { + event.stopPropagation() + if ('nativeEvent' in event && 'stopImmediatePropagation' in event.nativeEvent) { + event.nativeEvent.stopImmediatePropagation() + } + if (!activeQuestion || isReviewStep || responding) return + if (event.ctrlKey || event.metaKey || event.altKey) return + if (event.key !== 'Enter' && event.key !== 'NumpadEnter') return + if (event.shiftKey) return + if (!progress.hasAnswer) return + event.preventDefault() + void handleAdvance() + }, [activeQuestion, handleAdvance, isReviewStep, progress.hasAnswer, responding]) + + useEffect(() => { + const container = animatedStepRef.current + if (!container) return + + const animatedNodes = Array.from(container.querySelectorAll('[data-guided-animate]')) + animatedNodes.forEach((node, index) => { + node.animate( + [ + { + opacity: 0, + transform: 'translateY(14px) scale(0.982)', + filter: 'blur(3px)' + }, + { + opacity: 1, + transform: 'translateY(0) scale(1)', + filter: 'blur(0px)' + } + ], + { + duration: 280 + index * 48, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)', + fill: 'both' + } + ) + }) + }, [animatedStageKey]) return ( -
-
-
-
- - Input Needed - - {activePrompt.questions.length > 1 ? ( - - {progress.questionIndex + 1}/{activePrompt.questions.length} - - ) : null} - {pendingUserInputs.length > 1 ? ( - - request 1/{pendingUserInputs.length} - - ) : null} -
-

- {activeQuestion.header} -

-

- {activeQuestion.question} -

-
+
+
+
+
+ +
+
+
+
+

+ {isReviewStep ? 'Review Decisions' : activeQuestion?.header} +

+
+
+ + Guided Input + + + {isReviewStep ? 'Review' : `${progress.questionIndex + 1}/${activePrompt.questions.length}`} + + {pendingUserInputs.length > 1 ? ( + + 1/{pendingUserInputs.length} + + ) : null} +
+
+

+ {isReviewStep ? 'Review every choice before sending it back.' : activeQuestion?.question} +

+
-
- {activeQuestion.options.map((option, index) => { - const selected = progress.selectedOptionLabel === option.label - return ( - - ) - })} -
+ {isReviewStep ? ( +
+ {reviewAnswers.map(({ question, index, answer }) => ( + + ))} +
+ ) : activeQuestion ? ( +
+ {activeQuestion.options.map((option, index) => { + const selected = progress.selectedOptionLabel === option.label + const optionKey = `${activeQuestion.id}:${option.label}` + const hasDetails = Boolean(option.description && option.description !== option.label) + const detailsOpen = expandedOptionKey === optionKey && hasDetails + return ( +
event.preventDefault()} + onClick={() => handleSelectOption(activeQuestion.id, option.label)} + onKeyDown={(event) => { + if (responding) return + if (event.key === 'Enter' || event.key === 'NumpadEnter') { + event.preventDefault() + handleSelectOption(activeQuestion.id, option.label) + } + }} + role="button" + tabIndex={responding ? -1 : 0} + aria-pressed={selected} + aria-disabled={responding} + className={cn( + 'group/option w-full rounded-2xl px-3 py-2 text-left transition-colors', + selected + ? 'bg-emerald-500/[0.08] text-sparkle-text' + : 'bg-white/[0.02] text-sparkle-text-secondary hover:bg-white/[0.04] hover:text-sparkle-text', + responding && 'cursor-not-allowed opacity-60' + )} + > +
+ + + + {index + 1} + + {option.label} + + + + {hasDetails ? ( + + ) : null} + + + + +
+ +
+

+ {option.description} +

+
+
+
+ ) + })} -
-
- {progress.answeredQuestionCount}/{activePrompt.questions.length} answered -
-
- {progress.questionIndex > 0 ? ( +
event.preventDefault()} + onClick={() => handleSelectCustom(activeQuestion.id)} + onKeyDown={(event) => { + if (responding) return + if (event.key === 'Enter' || event.key === 'NumpadEnter') { + event.preventDefault() + handleSelectCustom(activeQuestion.id) + } + }} + role="button" + tabIndex={responding || showCustomComposer ? -1 : 0} + aria-pressed={showCustomComposer} + aria-disabled={responding} + className={cn( + 'group/custom w-full rounded-2xl px-3 py-2 text-left transition-colors', + showCustomComposer + ? 'bg-sky-500/[0.08] text-sparkle-text' + : 'bg-white/[0.02] text-sparkle-text-secondary hover:bg-white/[0.04] hover:text-sparkle-text', + responding && 'cursor-not-allowed opacity-60' + )} + > +
+ + + + + + {CUSTOM_ANSWER_LABEL} + + + + {customOptionKey ? ( + + ) : null} + + + + +
+ +
+

+ Use the composer area below when none of the predefined answers fit. +

+
+
+
+
+ ) : null} +
+ + +
- ) : null} - +
+