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/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/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..c513246 100644 --- a/docs/current/CURRENT_CAPABILITIES_MATRIX.md +++ b/docs/current/CURRENT_CAPABILITIES_MATRIX.md @@ -12,6 +12,7 @@ Last validated against code on March 20, 2026. ## Desktop Shell and System - Window controls: `Implemented` +- Windows File Explorer shell integration for `Open with DevScope Air` file/folder entry points: `Implemented` - System overview and detailed system stats: `Implemented` - Readiness and developer-tooling detection: `Implemented` - Shared metrics bootstrap, subscribe, and read flows: `Implemented` @@ -45,15 +46,22 @@ 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 markdown file links and edited-file entries opening in-app preview, including exact-line opens for file references such as `path/to/file.ts:42` or `#L42`: `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 composer image attachments open the file preview renderer directly from the shelf while non-image attachments keep the local attachment preview flow: `Implemented` +- Assistant composer pasted text attachments render as compact paper-card previews and open a dedicated text preview modal: `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..46e0a9d 100644 --- a/docs/current/CURRENT_CODEBASE_ARCHITECTURE.md +++ b/docs/current/CURRENT_CODEBASE_ARCHITECTURE.md @@ -30,6 +30,10 @@ From `src/renderer/src/App.tsx`, the desktop app currently exposes: Legacy helper routes still redirect into the live assistant/settings surface instead of serving separate deprecated pages. +The renderer also includes a dedicated `/quick-open` preview route for file-association and shell file launches. That route bypasses the main app shell and now renders inside its own frameless preview window with renderer-owned chrome instead of native Electron frame chrome. + +Windows shell folder launches route into `/explorer/:folderPath` with a transient session-level allowance so explicit File Explorer context-menu opens still work even if the optional Explorer sidebar tab is currently disabled in settings. + ## Main Process Domain Areas `src/main/ipc/handlers.ts` registers handlers for: @@ -41,6 +45,7 @@ Legacy helper routes still redirect into the live assistant/settings surface ins - project details and running-process/session views - file tree, file reads, and file writes - external terminal launch plus preview-terminal and Python preview flows +- Windows shell launch routing for file previews and folder opens - Git read/write operations - desktop update state and install actions @@ -55,6 +60,10 @@ The assistant is part of the active app, not a removed feature. Current assistant capabilities include session lifecycle, model listing, connect/disconnect, prompt send, interrupt, approval responses, user-input responses, project-path association, and event subscription. +Assistant timeline tool-call cards also support path-aware file navigation: edited-file rows and plain file-path lines in tool results can open directly into the shared file-preview renderer. + +Assistant conversation status labels are phase-driven from the active thread state; generic pending UI actions should not be treated as thread connection state. + ## Shared Contract Model Two contract groups matter most: @@ -72,7 +81,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, splits renderer subscriptions so the assistant page shell, conversation pane, and right-side panels do not all rerender on live timeline churn, relies on a sliding tail history window plus per-row deferred rendering to keep long conversations responsive while still allowing explicit older-history expansion, and now persists per-turn usage in a dedicated `assistant_turns` ledger that the thread-details panel fetches on demand instead of inflating the hot assistant snapshot. ## 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 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/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/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/inspectors/SensingEngine.ts b/src/main/inspectors/SensingEngine.ts index 9cf6f96..844082b 100644 --- a/src/main/inspectors/SensingEngine.ts +++ b/src/main/inspectors/SensingEngine.ts @@ -1,27 +1,17 @@ -import { spawn } from 'child_process' import log from 'electron-log' -import { persistentCache, ToolCacheEntry } from './persistent-cache' +import { persistentCache } from './persistent-cache' import { TOOL_REGISTRY, ToolDefinition, DetectionConfig, ToolCategory } from '../../shared/tool-registry' -import { DetectedTool, ToolStatus } from './types' +import { DetectedTool } from './types' import { commandExists, getCommandVersion, safeExec } from './safe-exec' -import { unifiedBatchCheck, invalidateUnifiedBatchCache, BatchResults } from './unified-batch-scanner' -/** - * SensingEngine - * Unified orchestrator for tool detection and monitoring - */ class SensingEngine { - private isScanning = false private progressCallback?: (category: string, current: number, total: number, tool: string) => void - /** - * Map a ToolDefinition to a DetectedTool state (not installed by default) - */ private createInitialState(tool: ToolDefinition): DetectedTool { return { id: tool.id, displayName: tool.displayName, - category: tool.category as any, // Cast to match types.ts ToolCategory + category: tool.category as any, installed: false, status: 'not_installed', usedFor: tool.usedFor, @@ -33,15 +23,11 @@ class SensingEngine { } } - /** - * CLI Detection Strategy - */ private async detectViaCli(tool: ToolDefinition, config: DetectionConfig): Promise> { const cmd = config.command || tool.command const exists = await commandExists(cmd) if (!exists) { - // Check alternate commands if primary fails if (tool.alternateCommands) { for (const alt of tool.alternateCommands) { if (await commandExists(alt)) { @@ -55,34 +41,29 @@ class SensingEngine { } } } + return { installed: false, status: 'not_installed' } } const versionResult = await getCommandVersion(cmd, config.versionArgs?.[0] || tool.versionArg) - const version = versionResult || undefined return { installed: true, status: 'healthy', - version, + version: versionResult || undefined, metadata: { command: cmd } } } - /** - * Process Detection Strategy (for services like Docker, Ollama) - */ private async detectViaProcess(tool: ToolDefinition, config: DetectionConfig): Promise> { const processName = config.processName || tool.id try { - // Cross-platform process check let isRunning = false if (process.platform === 'win32') { const cmd = `tasklist /NH /FI "IMAGENAME eq ${processName}.exe"` const result = await safeExec('cmd', ['/c', cmd]) isRunning = result.stdout.toLowerCase().includes(processName.toLowerCase()) } else { - // macOS/Linux: use pgrep try { const result = await safeExec('pgrep', ['-x', processName], { timeout: 3000 }) isRunning = result.stdout.trim().length > 0 @@ -91,7 +72,6 @@ class SensingEngine { } } - // Also check if CLI exists if it's a dual-type tool const cliExists = await commandExists(tool.command) let version: string | undefined if (cliExists) { @@ -102,94 +82,16 @@ class SensingEngine { return { installed: cliExists || isRunning, status: isRunning ? 'healthy' : (cliExists ? 'warning' : 'not_installed'), - version: version, + version, metadata: { running: isRunning } } - } catch (err) { + } catch { return { installed: false, status: 'unknown' } } } - /** - * Custom Detection Strategy (for complex tools) - */ - private async detectViaCustom(tool: ToolDefinition, config: DetectionConfig): Promise> { - const detectorName = config.customDetector - if (!detectorName) return { status: 'error', description: 'Custom detector name not specified' } - - switch (detectorName) { - case 'cuda': - return this.detectCUDA(tool) - case 'pytorch': - return this.detectAiFramework(tool, 'torch') - case 'tensorflow': - return this.detectAiFramework(tool, 'tensorflow') - default: - return { status: 'error', description: `Unknown custom detector: ${detectorName}` } - } - } - - /** - * Specialized: CUDA Detection - */ - private async detectCUDA(tool: ToolDefinition): Promise> { - try { - // Check nvidia-smi - if (await commandExists('nvidia-smi')) { - const result = await safeExec('nvidia-smi', [ - '--query-gpu=driver_version,name,memory.total', - '--format=csv,noheader' - ]) - if (result.stdout) { - const [driver, name, vram] = result.stdout.trim().split(',').map(s => s.trim()) - return { - installed: true, - status: 'healthy', - version: driver, - description: `${name} • ${vram} VRAM • Driver ${driver}` - } - } - } - // Fallback to nvcc - if (await commandExists('nvcc')) { - const versionResult = await getCommandVersion('nvcc', '--version') - return { installed: true, status: 'healthy', version: versionResult || undefined, description: `CUDA Toolkit ${versionResult || 'Installed'}` } - } - } catch (err) { - log.debug('CUDA detection failed:', err) - } - return { installed: false, status: 'not_installed' } - } - - /** - * Specialized: AI Framework Detection (via pip) - */ - private async detectAiFramework(tool: ToolDefinition, packageName: string): Promise> { - try { - if (await commandExists('pip')) { - const result = await safeExec('pip', ['show', packageName]) - if (result.stdout && result.stdout.includes(`Name: ${packageName}`)) { - const versionMatch = result.stdout.match(/Version: ([^\s]+)/) - const version = versionMatch ? versionMatch[1] : 'Unknown' - return { - installed: true, - status: 'healthy', - version, - description: `${tool.displayName} v${version} (via pip)` - } - } - } - } catch (err) { - log.debug(`${packageName} detection failed:`, err) - } - return { installed: false, status: 'not_installed' } - } - - /** - * Scan a single tool based on its configuration - */ async scanTool(id: string): Promise { - const tool = TOOL_REGISTRY.find((t: ToolDefinition) => t.id === id) + const tool = TOOL_REGISTRY.find((item: ToolDefinition) => item.id === id) if (!tool) throw new Error(`Tool ${id} not found in registry`) let result: Partial = { installed: false, status: 'not_installed' } @@ -203,9 +105,6 @@ class SensingEngine { case 'process': result = await this.detectViaProcess(tool, config) break - case 'custom': - result = await this.detectViaCustom(tool, config) - break default: result = await this.detectViaCli(tool, config) } @@ -220,151 +119,33 @@ class SensingEngine { lastChecked: Date.now() } - // Update cache persistentCache.setTool(detected as any) return detected } - /** - * Scan an entire category of tools - */ async scanCategory(category: ToolCategory): Promise { - const tools = TOOL_REGISTRY.filter((t: ToolDefinition) => t.category === category) + const tools = TOOL_REGISTRY.filter((tool: ToolDefinition) => tool.category === category) log.info(`SensingEngine: Scanning category ${category} (${tools.length} tools)`) const results: DetectedTool[] = [] - - // Scan in small batches to avoid overloading const batchSize = 5 - for (let i = 0; i < tools.length; i += batchSize) { - const batch = tools.slice(i, i + batchSize) - const batchResults = await Promise.all(batch.map((t: ToolDefinition) => this.scanTool(t.id))) + + for (let index = 0; index < tools.length; index += batchSize) { + const batch = tools.slice(index, index + batchSize) + const batchResults = await Promise.all(batch.map((tool: ToolDefinition) => this.scanTool(tool.id))) results.push(...batchResults) - if (this.progressCallback) { - this.progressCallback(category, Math.min(i + batchSize, tools.length), tools.length, batch[0].displayName) + if (this.progressCallback && batch.length > 0) { + this.progressCallback(category, Math.min(index + batchSize, tools.length), tools.length, batch[0].displayName) } } return results } - /** - * Set progress callback - */ onProgress(callback: (category: string, current: number, total: number, tool: string) => void) { this.progressCallback = callback } - - /** - * Get aggregated AI runtime report - */ - async getAIRuntimeReport() { - const [llmRuntimes, gpuAcceleration, aiFrameworks] = await Promise.all([ - this.scanCategory('ai_runtime'), - this.scanCategory('gpu_acceleration'), - this.scanCategory('ai_framework') - ]) - - return { - llmRuntimes: llmRuntimes as any[], - gpuAcceleration, - aiFrameworks, - timestamp: Date.now() - } - } - - /** - * Scan ALL categories in parallel (fast full scan) - * Uses unified batch scanner for CLI tools, then runs - * custom detectors for special tools (CUDA, AI frameworks). - */ - async scanAllParallel( - onCategoryComplete?: (category: string, results: DetectedTool[]) => void - ): Promise> { - log.info('SensingEngine: Starting parallel full scan...') - const startTime = Date.now() - - // Collect ALL CLI tool commands for unified batch check - const cliTools = TOOL_REGISTRY.filter( - (t: ToolDefinition) => !t.detection || t.detection.strategy === 'cli' - ) - const allCommands = new Set() - for (const tool of cliTools) { - allCommands.add(tool.command) - if (tool.alternateCommands) { - tool.alternateCommands.forEach((c: string) => allCommands.add(c)) - } - } - - // Run unified batch check for all CLI tools at once - const batchResults = await unifiedBatchCheck(Array.from(allCommands)) - - // Now build DetectedTool results using batch data - const allResults = new Map() - const categories = [...new Set(TOOL_REGISTRY.map((t: ToolDefinition) => t.category))] as ToolCategory[] - - // Process each category (using batch results, no new spawns for CLI tools) - const categoryPromises = categories.map(async (category) => { - const tools = TOOL_REGISTRY.filter((t: ToolDefinition) => t.category === category) - const results: DetectedTool[] = [] - - for (const tool of tools) { - const config = tool.detection || { strategy: 'cli' } - - if (config.strategy === 'cli' || !config.strategy) { - // Use batch results (instant lookup, no spawn) - const batchResult = batchResults[tool.command] - let found = batchResult?.exists || false - let version = batchResult?.version - let path = batchResult?.path - - // Check alternate commands if primary not found - if (!found && tool.alternateCommands) { - for (const alt of tool.alternateCommands) { - const altResult = batchResults[alt] - if (altResult?.exists) { - found = true - version = altResult.version - path = altResult.path - break - } - } - } - - const detected: DetectedTool = { - ...this.createInitialState(tool), - installed: found, - status: found ? 'healthy' : 'not_installed', - version: version || undefined, - path, - lastChecked: Date.now() - } - results.push(detected) - persistentCache.setTool(detected as any) - } else { - // Process/custom detection still uses individual checks - const detected = await this.scanTool(tool.id) - results.push(detected) - } - } - - allResults.set(category, results) - if (onCategoryComplete) { - onCategoryComplete(category, results) - } - }) - - await Promise.all(categoryPromises) - - persistentCache.markScanned() - persistentCache.save() - - const duration = Date.now() - startTime - log.info(`SensingEngine: Parallel full scan completed in ${duration}ms`) - - return allResults - } } export const sensingEngine = new SensingEngine() diff --git a/src/main/inspectors/types.ts b/src/main/inspectors/types.ts index a03e778..1cb3864 100644 --- a/src/main/inspectors/types.ts +++ b/src/main/inspectors/types.ts @@ -67,12 +67,8 @@ export type ToolCategory = | 'build_tool' | 'container' | 'version_control' - | 'ai_runtime' - | 'ai_agent' | 'browser' | 'database' - | 'gpu_acceleration' - | 'ai_framework' | 'unknown' export interface DetectedTool { @@ -100,24 +96,6 @@ export interface ToolingReport { timestamp: number } -// ============================================================================ -// AI Runtime Types -// ============================================================================ - -export interface AIRuntimeInfo extends DetectedTool { - running?: boolean - port?: number - models?: string[] - endpoint?: string -} - -export interface AIRuntimeReport { - llmRuntimes: AIRuntimeInfo[] - gpuAcceleration: DetectedTool[] - aiFrameworks: DetectedTool[] - timestamp: number -} - // ============================================================================ // Readiness Types // ============================================================================ @@ -162,19 +140,6 @@ export interface ReadinessReport { export interface FullReport { system: SystemHealth tooling: ToolingReport - aiRuntime: AIRuntimeReport readiness: ReadinessReport timestamp: number } - -// ============================================================================ -// IPC API Types -// ============================================================================ - -export interface DevScopeAPI { - getSystemOverview: () => Promise - getDeveloperTooling: () => Promise - getAIRuntimeStatus: () => Promise - getReadinessReport: () => Promise - refreshAll: () => Promise -} diff --git a/src/main/inspectors/unified-batch-scanner.ts b/src/main/inspectors/unified-batch-scanner.ts deleted file mode 100644 index 51b36d7..0000000 --- a/src/main/inspectors/unified-batch-scanner.ts +++ /dev/null @@ -1,321 +0,0 @@ -/** - * DevScope - Unified Cross-Platform Batch Scanner - * - * Replaces the Windows-only PowerShell batch-detector with a cross-platform - * approach that uses a single shell invocation to check tool existence and - * versions. Works on Windows (cmd), macOS (sh/zsh), and Linux (sh/bash). - * - * Performance: ~200ms for 30+ tools vs ~3-5s with individual spawns. - */ - -import { spawn } from 'child_process' -import log from 'electron-log' -import { cacheManager } from './cache-manager' - -// ============================================================================ -// Types -// ============================================================================ - -export interface BatchToolResult { - exists: boolean - path?: string - version?: string - error?: string -} - -export interface BatchResults { - [tool: string]: BatchToolResult -} - -// ============================================================================ -// Constants -// ============================================================================ - -const BATCH_CACHE_KEY = 'unified-batch-detection' -const BATCH_CACHE_TTL = 5 * 60 * 1000 // 5 minutes -const isWindows = process.platform === 'win32' - -// Delimiter used to separate tool results in shell output -const TOOL_DELIMITER = '___DEVSCOPE_TOOL___' - -// ============================================================================ -// Core: Cross-Platform Shell Execution -// ============================================================================ - -/** - * Execute a shell command and return stdout/stderr. - * Uses cmd on Windows, sh on Unix. - */ -function shellExec(command: string, timeout = 10000): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve) => { - const shell = isWindows ? 'cmd' : 'sh' - const args = isWindows ? ['/c', command] : ['-c', command] - - const child = spawn(shell, args, { - windowsHide: true, - timeout, - env: { ...process.env } - }) - - let stdout = '' - let stderr = '' - - child.stdout?.on('data', (data) => { - stdout += data.toString() - }) - - child.stderr?.on('data', (data) => { - stderr += data.toString() - }) - - const timer = setTimeout(() => { - child.kill() - resolve({ stdout, stderr: stderr || 'Timeout' }) - }, timeout) - - child.on('close', () => { - clearTimeout(timer) - resolve({ stdout, stderr }) - }) - - child.on('error', (err) => { - clearTimeout(timer) - resolve({ stdout: '', stderr: err.message }) - }) - }) -} - -// ============================================================================ -// Phase 1: Batch Existence Check (single shell call) -// ============================================================================ - -/** - * Build a shell command that checks existence of ALL tools at once. - * Each tool result is separated by a delimiter for easy parsing. - * - * Windows: `(where node 2>nul || echo NOT_FOUND) & echo ___DELIM___ & ...` - * Unix: `(which node 2>/dev/null || echo NOT_FOUND); echo ___DELIM___; ...` - */ -function buildExistenceCommand(tools: string[]): string { - const whereCmd = isWindows ? 'where' : 'which' - const nullRedirect = isWindows ? '2>nul' : '2>/dev/null' - const separator = isWindows ? ' & ' : '; ' - const delimiter = `echo ${TOOL_DELIMITER}` - - const parts = tools.map(tool => { - return `(${whereCmd} ${tool} ${nullRedirect} || echo NOT_FOUND)${separator}${delimiter}` - }) - - return parts.join(separator) -} - -/** - * Parse the output of the batch existence command. - * Returns a map of tool -> { exists, path }. - */ -function parseExistenceOutput(output: string, tools: string[]): Map { - const results = new Map() - const sections = output.split(TOOL_DELIMITER) - - for (let i = 0; i < tools.length; i++) { - const section = sections[i]?.trim() || '' - - if (!section || section.includes('NOT_FOUND') || section === '') { - results.set(tools[i], { exists: false }) - } else { - // The first non-empty line is the path - const lines = section.split('\n').map(l => l.trim()).filter(l => l && l !== '') - const firstPath = lines[0] || undefined - results.set(tools[i], { exists: true, path: firstPath }) - } - } - - return results -} - -// ============================================================================ -// Phase 2: Batch Version Check (single shell call, only for existing tools) -// ============================================================================ - -/** - * Build a shell command that fetches versions for all existing tools. - * Uses `tool --version` with output separation by delimiter. - */ -function buildVersionCommand(tools: string[], versionArgs?: Map): string { - const separator = isWindows ? ' & ' : '; ' - const delimiter = `echo ${TOOL_DELIMITER}` - - const parts = tools.map(tool => { - const vArg = versionArgs?.get(tool) || '--version' - // Capture both stdout and stderr (some tools output version to stderr) - return `(${tool} ${vArg} 2>&1 || echo VERSION_FAILED)${separator}${delimiter}` - }) - - return parts.join(separator) -} - -/** - * Parse version output from batch command. - * Extracts semver-like patterns from each tool's output. - */ -function parseVersionOutput(output: string, tools: string[]): Map { - const results = new Map() - const sections = output.split(TOOL_DELIMITER) - - const versionPatterns = [ - /v?(\d+\.\d+\.\d+[-.\w]*)/i, - /version\s*[:\s]?\s*v?(\d+\.\d+\.?\d*[-.\w]*)/i, - /(\d+\.\d+\.?\d*[-.\w]*)/, - ] - - for (let i = 0; i < tools.length; i++) { - const section = sections[i]?.trim() || '' - - if (!section || section.includes('VERSION_FAILED')) { - results.set(tools[i], undefined) - continue - } - - const firstLine = section.split('\n')[0]?.trim() || '' - let version: string | undefined - - for (const pattern of versionPatterns) { - const match = firstLine.match(pattern) - if (match?.[1]) { - version = match[1] - break - } - } - - // Fallback: use first 30 chars of first line - if (!version && firstLine) { - version = firstLine.substring(0, 30) - } - - results.set(tools[i], version) - } - - return results -} - -// ============================================================================ -// Public API -// ============================================================================ - -/** - * Check multiple tools at once using cross-platform batch scanning. - * - * This is the main entry point — replaces both the old PowerShell - * batch-detector and the native-detector with a single, fast implementation. - * - * @param tools - Array of command names to check (e.g. ['node', 'git', 'docker']) - * @param versionArgs - Optional map of tool -> version argument (default: '--version') - * @returns Map of tool -> { exists, path, version } - */ -export async function unifiedBatchCheck( - tools: string[], - versionArgs?: Map -): Promise { - // Check cache first - const cached = cacheManager.get(BATCH_CACHE_KEY) - if (cached) { - log.debug(`[UnifiedBatch] Cache hit for ${tools.length} tools`) - return cached - } - - log.info(`[UnifiedBatch] Scanning ${tools.length} tools (${isWindows ? 'Windows' : process.platform})...`) - const startTime = Date.now() - - const results: BatchResults = {} - - try { - // ── Phase 1: Check existence of all tools in one shell call ── - // Split into batches of 20 to avoid command line length limits - const existenceMap = new Map() - const existBatchSize = 20 - - for (let i = 0; i < tools.length; i += existBatchSize) { - const batch = tools.slice(i, i + existBatchSize) - const existenceCmd = buildExistenceCommand(batch) - const existenceOutput = await shellExec(existenceCmd, 8000) - const batchMap = parseExistenceOutput(existenceOutput.stdout, batch) - for (const [k, v] of batchMap) { - existenceMap.set(k, v) - } - } - - // Collect tools that exist for version checking - const existingTools: string[] = [] - for (const [tool, result] of existenceMap) { - if (result.exists) { - existingTools.push(tool) - } else { - results[tool] = { exists: false } - } - } - - const existenceDuration = Date.now() - startTime - log.info(`[UnifiedBatch] Existence check: ${existingTools.length}/${tools.length} found in ${existenceDuration}ms`) - - // ── Phase 2: Get versions for existing tools in batched shell calls ── - if (existingTools.length > 0) { - const versionBatchSize = 10 - for (let i = 0; i < existingTools.length; i += versionBatchSize) { - const batch = existingTools.slice(i, i + versionBatchSize) - const versionCmd = buildVersionCommand(batch, versionArgs) - const versionOutput = await shellExec(versionCmd, 10000) - const versionMap = parseVersionOutput(versionOutput.stdout, batch) - - for (const tool of batch) { - const existence = existenceMap.get(tool)! - const version = versionMap.get(tool) - results[tool] = { - exists: true, - path: existence.path, - version: version || 'Installed' - } - } - } - } - } catch (error) { - log.error('[UnifiedBatch] Scan failed:', error) - // Fallback: mark unchecked tools - for (const tool of tools) { - if (!results[tool]) { - results[tool] = { exists: false, error: 'Scan failed' } - } - } - } - - const totalDuration = Date.now() - startTime - const installedCount = Object.values(results).filter(r => r.exists).length - log.info(`[UnifiedBatch] Complete: ${installedCount}/${tools.length} tools in ${totalDuration}ms`) - - // Cache the results - cacheManager.set(BATCH_CACHE_KEY, results, BATCH_CACHE_TTL) - - return results -} - -/** - * Invalidate the unified batch cache (call on manual refresh) - */ -export function invalidateUnifiedBatchCache(): void { - cacheManager.invalidate(BATCH_CACHE_KEY) - log.info('[UnifiedBatch] Cache invalidated') -} - -/** - * Convenience: check if a tool exists from batch results - */ -export function toolExists(results: BatchResults, tool: string): boolean { - return results[tool]?.exists || false -} - -/** - * Convenience: get version from batch results - */ -export function getVersion(results: BatchResults, tool: string): string | null { - const result = results[tool] - return result?.exists && result.version ? result.version : null -} 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/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/main/ipc/handlers/file-tree-handlers.ts b/src/main/ipc/handlers/file-tree-handlers.ts index df7069b..b05dd72 100644 --- a/src/main/ipc/handlers/file-tree-handlers.ts +++ b/src/main/ipc/handlers/file-tree-handlers.ts @@ -2,7 +2,7 @@ import { shell } from 'electron' import { access, cp, lstat, mkdir, open as fsOpen, readFile, readdir, rename, rm, stat, writeFile } from 'fs/promises' import { basename, dirname, join, parse, relative, resolve, sep } from 'path' import log from 'electron-log' -import { getGitStatus, type GitFileStatus } from '../../inspectors/git' +import { checkIsGitRepo, getGitStatus, type GitFileStatus } from '../../inspectors/git' import { invalidateScanProjectsCache } from '../../services/project-discovery-service' import { handleCreateFileSystemItem, @@ -90,7 +90,9 @@ export async function handleGetFileTree( let gitStatusMap: Record = {} try { - gitStatusMap = await getGitStatus(resolvedProjectPath) + if (await checkIsGitRepo(resolvedProjectPath)) { + gitStatusMap = await getGitStatus(resolvedProjectPath) + } } catch { // Ignore git errors. } 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/ipc/handlers/system-handlers.ts b/src/main/ipc/handlers/system-handlers.ts index 1c8bedb..0d998da 100644 --- a/src/main/ipc/handlers/system-handlers.ts +++ b/src/main/ipc/handlers/system-handlers.ts @@ -4,7 +4,6 @@ import { getSystemInfo, sensingEngine } from '../../inspectors' -import { invalidateUnifiedBatchCache } from '../../inspectors/unified-batch-scanner' import type { FullReport, ReadinessReport, @@ -68,15 +67,13 @@ export async function handleGetDeveloperTooling(): Promise { export async function handleGetReadinessReport(): Promise { log.info('IPC: getReadinessReport') const tooling = await handleGetDeveloperTooling() - const aiRuntime = { llmRuntimes: [], gpuAcceleration: [], aiFrameworks: [], timestamp: Date.now() } - return calculateReadiness(tooling, aiRuntime as any) + return calculateReadiness(tooling) } export async function handleRefreshAll(): Promise { log.info('IPC: refreshAll') clearCommandCache() - invalidateUnifiedBatchCache() systemMetricsBridge.invalidateStaticSnapshot() const [system, tooling] = await Promise.all([ @@ -84,13 +81,11 @@ export async function handleRefreshAll(): Promise { handleGetDeveloperTooling() ]) - const aiRuntime = { llmRuntimes: [], gpuAcceleration: [], aiFrameworks: [], timestamp: Date.now() } - const readiness = calculateReadiness(tooling, aiRuntime as any) + const readiness = calculateReadiness(tooling) return { system, tooling, - aiRuntime, readiness, timestamp: Date.now() } diff --git a/src/main/readiness/scorer.ts b/src/main/readiness/scorer.ts index c89820e..27c77e8 100644 --- a/src/main/readiness/scorer.ts +++ b/src/main/readiness/scorer.ts @@ -7,7 +7,6 @@ import log from 'electron-log' import type { DetectedTool, ToolingReport, - AIRuntimeReport, ReadinessReport, ReadinessLevel, Warning, @@ -60,17 +59,14 @@ const RECOMMENDATIONS: Record t.installed).length @@ -101,8 +97,7 @@ function getReadinessLevel(score: number, warnings: Warning[]): ReadinessLevel { * Generate warnings for issues */ function generateWarnings( - tooling: ToolingReport, - aiRuntime: AIRuntimeReport + tooling: ToolingReport ): Warning[] { const warnings: Warning[] = [] let id = 1 @@ -165,8 +160,7 @@ function generateWarnings( * Generate recommendations for missing tools */ function generateRecommendations( - tooling: ToolingReport, - aiRuntime: AIRuntimeReport + tooling: ToolingReport ): Recommendation[] { const recommendations: Recommendation[] = [] let priority = 1 @@ -175,8 +169,7 @@ function generateRecommendations( ...tooling.languages, ...tooling.packageManagers, ...tooling.containers, - ...tooling.versionControl, - ...aiRuntime.llmRuntimes + ...tooling.versionControl ] for (const [toolId, rec] of Object.entries(RECOMMENDATIONS)) { @@ -201,14 +194,13 @@ function generateRecommendations( * Calculate readiness report */ export function calculateReadiness( - tooling: ToolingReport, - aiRuntime: AIRuntimeReport + tooling: ToolingReport ): ReadinessReport { log.info('Calculating readiness score...') - const warnings = generateWarnings(tooling, aiRuntime) - const recommendations = generateRecommendations(tooling, aiRuntime) - const score = calculateScore(tooling, aiRuntime) + const warnings = generateWarnings(tooling) + const recommendations = generateRecommendations(tooling) + const score = calculateScore(tooling) const level = getReadinessLevel(score, warnings) const allTools: DetectedTool[] = [ 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/main/utils.ts b/src/main/utils.ts index 8d690a2..f59a814 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -4,7 +4,6 @@ */ import { app } from 'electron' -import path from 'path' export const electronApp = { setAppUserModelId: (id: string) => { @@ -14,10 +13,6 @@ export const electronApp = { } } -export const optimizer = { - // Placeholder for optimizer utilities -} - export const is = { dev: !app.isPackaged, prod: app.isPackaged 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/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 ? : } /> void - group: string -} +import { CommandPaletteResults } from './CommandPaletteResults' +import type { CommandPaletteDomain as Domain, CommandPaletteResult as Result } from './command-palette-types' function getParentPath(filePath: string): string { const normalized = String(filePath || '').trim() @@ -382,102 +372,14 @@ export function CommandPalette() { )} - {results.length > 0 && ( -
- {results.map((result, index) => { - const isSelected = index === selectedIndex - const showGroupLabel = index === 0 || results[index - 1]?.group !== result.group - - return ( -
- {showGroupLabel && ( -
- - {result.group} - -
- )} - - -
- ) - })} -
- )} - - {results.length === 0 && query.trim() !== '' && !loadingFiles && ( -
-
- -
-
No results found
-
- No match for "{query}". Try a broader term, or switch modes with `/` and `//`. -
-
- )} - - {loadingFiles && ( -
-
-
Scanning folders...
-
- )} +
diff --git a/src/renderer/src/components/CommandPaletteResults.tsx b/src/renderer/src/components/CommandPaletteResults.tsx new file mode 100644 index 0000000..bd3f55a --- /dev/null +++ b/src/renderer/src/components/CommandPaletteResults.tsx @@ -0,0 +1,120 @@ +import { ArrowRight, Search } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { CommandPaletteResult } from './command-palette-types' + +export function CommandPaletteResults({ + query, + results, + selectedIndex, + setSelectedIndex, + selectResult, + loadingFiles +}: { + query: string + results: CommandPaletteResult[] + selectedIndex: number + setSelectedIndex: (value: number | ((current: number) => number)) => void + selectResult: (result?: CommandPaletteResult) => void + loadingFiles: boolean +}) { + return ( + <> + {results.length > 0 ? ( +
+ {results.map((result, index) => { + const isSelected = index === selectedIndex + const showGroupLabel = index === 0 || results[index - 1]?.group !== result.group + + return ( +
+ {showGroupLabel ? ( +
+ + {result.group} + +
+ ) : null} + + +
+ ) + })} +
+ ) : null} + + {results.length === 0 && query.trim() !== '' && !loadingFiles ? ( +
+
+ +
+
No results found
+
+ No match for "{query}". Try a broader term, or switch modes with `/` and `//`. +
+
+ ) : null} + + {loadingFiles ? ( +
+
+
Scanning folders...
+
+ ) : null} + + ) +} diff --git a/src/renderer/src/components/command-palette-types.tsx b/src/renderer/src/components/command-palette-types.tsx new file mode 100644 index 0000000..d38707e --- /dev/null +++ b/src/renderer/src/components/command-palette-types.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' + +export type CommandPaletteDomain = 'projects' | 'files' | 'mixed' + +export type CommandPaletteResult = { + id: string + title: string + subtitle?: string + badge?: string + icon?: ReactNode + action: () => void + group: string +} diff --git a/src/renderer/src/components/ui/FilePreviewModal.tsx b/src/renderer/src/components/ui/FilePreviewModal.tsx index eb84378..dd898b3 100644 --- a/src/renderer/src/components/ui/FilePreviewModal.tsx +++ b/src/renderer/src/components/ui/FilePreviewModal.tsx @@ -21,6 +21,7 @@ interface FilePreviewModalProps extends PreviewMeta { content: string loading?: boolean projectPath?: string + shellMode?: 'modal' | 'window' onOpenLinkedPreview?: (file: { name: string; path: string }, ext: string, options?: PreviewOpenOptions) => 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/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/refreshCache.ts b/src/renderer/src/lib/refreshCache.ts deleted file mode 100644 index 99cd36a..0000000 --- a/src/renderer/src/lib/refreshCache.ts +++ /dev/null @@ -1,116 +0,0 @@ -type CacheKey = 'system' | 'tooling' | 'aiRuntime' | 'aiAgents' | 'readiness' | 'detailedSystem' | 'timestamp' - -interface CacheEntry { - data: T - timestamp: number -} - -type CacheStore = Partial>> - -const cache: CacheStore = {} - -// Cache TTL in milliseconds (how long before data is considered stale) -const CACHE_TTL: Record = { - system: 60000, // 1 minute - tooling: 120000, // 2 minutes (tool detection is slow) - aiRuntime: 60000, // 1 minute - aiAgents: 120000, // 2 minutes (agent detection is slow) - readiness: 60000, // 1 minute - detailedSystem: 10000, // 10 seconds (for live monitoring) - timestamp: 60000 -} - -export const updateCache = (data: Partial>): void => { - const now = Date.now() - Object.entries(data).forEach(([key, value]) => { - if (value !== undefined) { - cache[key as CacheKey] = { data: value, timestamp: now } - } - }) -} - -export const getCache = (key: CacheKey): T | null => { - const entry = cache[key] - if (!entry) return null - return entry.data as T -} - -/** - * Get cache only if it's fresh (within TTL) - */ -export const getFreshCache = (key: CacheKey): T | null => { - const entry = cache[key] - if (!entry) return null - - const ttl = CACHE_TTL[key] || 60000 - const age = Date.now() - entry.timestamp - - if (age > ttl) return null - return entry.data as T -} - -/** - * Check if cache entry is stale - */ -export const isCacheStale = (key: CacheKey): boolean => { - const entry = cache[key] - if (!entry) return true - - const ttl = CACHE_TTL[key] || 60000 - return Date.now() - entry.timestamp > ttl -} - -export const clearCache = (): void => { - Object.keys(cache).forEach(key => delete cache[key as CacheKey]) -} - -/** - * Prefetch essential data on app start (lightweight version) - * Only fetches system overview and readiness - tooling loads on demand - */ -export const prefetchEssentialData = async (): Promise => { - try { - // Fetch lightweight data first for fast initial render - const [system, readiness] = await Promise.all([ - window.devscope.getSystemOverview(), - window.devscope.getReadinessReport() - ]) - - updateCache({ system, readiness }) - - // Dispatch event for fast initial render - window.dispatchEvent(new CustomEvent('devscope:prefetch-complete', { - detail: { system, readiness } - })) - - // Then fetch heavier data in background (non-blocking) - Promise.all([ - window.devscope.getDeveloperTooling(), - window.devscope.getAIRuntimeStatus() - ]).then(([tooling, aiRuntime]) => { - updateCache({ tooling, aiRuntime }) - window.dispatchEvent(new CustomEvent('devscope:background-load', { - detail: { tooling, aiRuntime } - })) - }).catch(err => console.error('Background load failed:', err)) - - } catch (err) { - console.error('Prefetch failed:', err) - } -} - -/** - * Check if essential cache has data - */ -export const isCacheReady = (): boolean => { - return !!(cache.system && cache.readiness) -} - -/** - * Get cache age in milliseconds - */ -export const getCacheAge = (key: CacheKey): number => { - const entry = cache[key] - return entry ? Date.now() - entry.timestamp : Infinity -} - 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/Home.tsx b/src/renderer/src/pages/Home.tsx index 6bb4c63..3f1ad66 100644 --- a/src/renderer/src/pages/Home.tsx +++ b/src/renderer/src/pages/Home.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight, Blocks, Code2, Rocket, Users } from 'lucide-react' +import { ArrowUpRight, Blocks, Code2, MessageSquare, Rocket, Users } from 'lucide-react' import { useState } from 'react' import { Link } from 'react-router-dom' import { cn } from '@/lib/utils' @@ -52,6 +52,17 @@ export default function Home() { novelty: settings.tasksPageEnabled ? 'Novel feature: dedicated tasks surface' : 'Novel feature: project workflow fallback' + }, + { + title: 'Assistant Workspace', + description: 'Open the assistant surface directly to plan, inspect, and execute work without leaving the desktop flow.', + icon: MessageSquare, + tone: 'from-violet-400/15 via-violet-400/0 to-transparent', + accent: 'text-violet-300', + border: 'group-hover:border-violet-400/50', + glow: 'bg-violet-400/20', + path: '/assistant', + novelty: 'Novel feature: in-app coding assistant' } ] diff --git a/src/renderer/src/pages/Projects.tsx b/src/renderer/src/pages/Projects.tsx deleted file mode 100644 index 62d6fc4..0000000 --- a/src/renderer/src/pages/Projects.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Link, Navigate } from 'react-router-dom' -import { Folder, FolderTree, Settings } from 'lucide-react' -import { useSettings } from '@/lib/settings' - -export default function Projects() { - const { settings } = useSettings() - const projectsRoot = String(settings.projectsFolder || '').trim() - - if (!projectsRoot) { - return ( -
-
-
-
- -
-

Projects

-
-

Your coding projects in one place

-
- -
- -

No Projects Folder Configured

-

- Set up a projects folder in settings to browse your projects. -

- - - Configure Projects Folder - -
-
- ) - } - - return -} 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 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} - +
+