From 6a8b9cd1b5404383f246114602db65ac5c64ad9f Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 12:56:32 +0200 Subject: [PATCH] fix: enhance CLI installer and session management - Updated the postinstall script in package.json to handle rebuild failures gracefully. - Added clearContext option in team launch requests to allow starting fresh sessions without resuming previous context. - Improved CLI installer logging by integrating raw output chunks for better terminal rendering. - Refactored components to utilize TerminalLogPanel for displaying installation logs, enhancing user experience during CLI installation. - Updated various services and hooks to support the new clearContext feature and raw logging. --- package.json | 2 +- src/main/ipc/teams.ts | 1 + .../infrastructure/CliInstallerService.ts | 27 ++++-- .../services/team/TeamProvisioningService.ts | 60 ++++++++------ .../components/dashboard/CliStatusBanner.tsx | 39 +-------- .../components/dashboard/DashboardView.tsx | 2 +- .../team/dialogs/LaunchTeamDialog.tsx | 34 +++++++- .../team/review/ScopeWarningBanner.tsx | 10 +-- .../components/terminal/TerminalLogPanel.tsx | 83 +++++++++++++++++++ src/renderer/hooks/useCliInstaller.ts | 6 +- src/renderer/store/index.ts | 5 +- .../store/slices/cliInstallerSlice.ts | 3 + src/shared/types/cliInstaller.ts | 2 + src/shared/types/team.ts | 2 + 14 files changed, 195 insertions(+), 81 deletions(-) create mode 100644 src/renderer/components/terminal/TerminalLogPanel.tsx diff --git a/package.json b/package.json index 2529b301..75a51af3 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts", "standalone:start": "node dist-standalone/index.cjs", "prepare": "husky", - "postinstall": "electron-rebuild -f -o node-pty" + "postinstall": "electron-rebuild -f -o node-pty || echo 'node-pty rebuild failed (terminal will be disabled)'" }, "lint-staged": { "src/**/*.{ts,tsx,js,jsx}": [ diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index df419c15..1c6a45a1 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -639,6 +639,7 @@ async function handleLaunchTeam( cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, + clearContext: payload.clearContext === true ? true : undefined, }, (progress) => { try { diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index fc211670..35772582 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -331,7 +331,11 @@ export class CliInstallerService { await fsp.chmod(tmpFilePath, 0o755); } - this.sendProgress({ type: 'installing', detail: 'Starting shell integration...' }); + this.sendProgress({ + type: 'installing', + detail: 'Starting shell integration...', + rawChunk: 'Starting shell integration...\r\n', + }); logger.info('Running claude install...'); try { @@ -446,14 +450,19 @@ export class CliInstallerService { const outputLines: string[] = []; const handleOutput = (chunk: Buffer): void => { - const text = chunk.toString('utf-8').trim(); - if (!text) return; - for (const line of text.split('\n')) { - const trimmed = line.trim(); - if (trimmed) { - outputLines.push(trimmed); - logger.info(`[claude install] ${trimmed}`); - this.sendProgress({ type: 'installing', detail: trimmed }); + const raw = chunk.toString('utf-8'); + if (!raw.trim()) return; + + // Send raw chunk for xterm.js rendering in UI + this.sendProgress({ type: 'installing', rawChunk: raw }); + + // Extract clean text for logger and error context + for (const line of raw.split('\n')) { + // eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- ANSI escape sequences stripped for clean logs + const clean = line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').trim(); + if (clean) { + outputLines.push(clean); + logger.info(`[claude install] ${clean}`); } } }; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 705c2c0b..8c1922ba 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1092,34 +1092,44 @@ export class TeamProvisioningService { // Extract leadSessionId for session resume on reconnect. // If a valid JSONL file exists for the previous session, we can resume it // so the lead retains full context of prior work. + // When clearContext is true, skip resume entirely to start a fresh session. let previousSessionId: string | undefined; - try { - const configParsed = JSON.parse(configRaw) as Record; - if ( - typeof configParsed.leadSessionId === 'string' && - configParsed.leadSessionId.trim().length > 0 - ) { - const candidateId = configParsed.leadSessionId.trim(); - const projectPath = - typeof configParsed.projectPath === 'string' && configParsed.projectPath.trim().length > 0 - ? configParsed.projectPath.trim() - : request.cwd; - const projectId = encodePath(projectPath); - const baseDir = extractBaseDir(projectId); - const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`); - if (await this.pathExists(jsonlPath)) { - previousSessionId = candidateId; - logger.info( - `[${request.teamName}] Found previous session JSONL for resume: ${candidateId}` - ); - } else { - logger.info( - `[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh` - ); + if (request.clearContext) { + logger.info( + `[${request.teamName}] clearContext requested — skipping session resume, starting fresh` + ); + } else { + try { + const configParsed = JSON.parse(configRaw) as Record; + if ( + typeof configParsed.leadSessionId === 'string' && + configParsed.leadSessionId.trim().length > 0 + ) { + const candidateId = configParsed.leadSessionId.trim(); + const projectPath = + typeof configParsed.projectPath === 'string' && + configParsed.projectPath.trim().length > 0 + ? configParsed.projectPath.trim() + : request.cwd; + const projectId = encodePath(projectPath); + const baseDir = extractBaseDir(projectId); + const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`); + if (await this.pathExists(jsonlPath)) { + previousSessionId = candidateId; + logger.info( + `[${request.teamName}] Found previous session JSONL for resume: ${candidateId}` + ); + } else { + logger.info( + `[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh` + ); + } } + } catch { + logger.debug( + `[${request.teamName}] Failed to extract leadSessionId from config for resume` + ); } - } catch { - logger.debug(`[${request.teamName}] Failed to extract leadSessionId from config for resume`); } // IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json. diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 96f2b22e..d9aef8e4 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -7,9 +7,10 @@ * Only rendered in Electron mode. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { api, isElectronMode } from '@renderer/api'; +import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { formatBytes } from '@renderer/utils/formatters'; @@ -51,38 +52,6 @@ const DetailLine = ({ text }: { text: string | null }): React.JSX.Element | null ); }; -/** Mini log panel shown during the installing phase */ -const LogPanel = ({ logs }: { logs: string[] }): React.JSX.Element | null => { - const scrollRef = useRef(null); - - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [logs]); - - if (logs.length === 0) return null; - - return ( -
- {logs.map((line, i) => ( -
- {line} -
- ))} -
- ); -}; - /** Error display with multi-line support */ const ErrorDisplay = ({ error, @@ -151,7 +120,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { downloadTotal, installerError, installerDetail, - installerLogs, + installerRawChunks, completedVersion, fetchCliStatus, installCli, @@ -321,7 +290,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { Installing Claude CLI... - + ); } diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 93c412d0..6efb75bf 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -146,7 +146,7 @@ const RepositoryCard = ({ (e: React.MouseEvent) => { e.stopPropagation(); if (projectPath) { - void api.openPath(projectPath); + void api.openPath(projectPath, projectPath); } }, [projectPath] diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 3eb65fdb..dc28aaa8 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; +import { Checkbox } from '@renderer/components/ui/checkbox'; import { Dialog, DialogContent, @@ -24,7 +25,7 @@ import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { normalizePath } from '@renderer/utils/pathNormalize'; -import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Loader2, RotateCcw } from 'lucide-react'; import { ProjectPathSelector } from './ProjectPathSelector'; @@ -71,6 +72,7 @@ export const LaunchTeamDialog = ({ const [prepareWarnings, setPrepareWarnings] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [selectedModel, setSelectedModel] = useState(''); + const [clearContext, setClearContext] = useState(false); const resetFormState = (): void => { setLocalError(null); @@ -82,6 +84,7 @@ export const LaunchTeamDialog = ({ setSelectedProjectPath(''); setCustomCwd(''); setSelectedModel(''); + setClearContext(false); }; // Warm up CLI on open @@ -244,6 +247,7 @@ export const LaunchTeamDialog = ({ cwd: effectiveCwd, prompt: promptDraft.value.trim() || undefined, model: selectedModel && selectedModel !== '__default__' ? selectedModel : undefined, + clearContext: clearContext || undefined, }); resetFormState(); onClose(); @@ -369,6 +373,34 @@ export const LaunchTeamDialog = ({ + +
+
+ setClearContext(checked === true)} + /> + +
+ {clearContext && ( +
+
+ +

+ The team lead will start a new session without resuming previous context. All + accumulated session memory and conversation history will not be available. +

+
+
+ )} +
{activeError ? ( diff --git a/src/renderer/components/team/review/ScopeWarningBanner.tsx b/src/renderer/components/team/review/ScopeWarningBanner.tsx index 502310eb..1eb44427 100644 --- a/src/renderer/components/team/review/ScopeWarningBanner.tsx +++ b/src/renderer/components/team/review/ScopeWarningBanner.tsx @@ -29,9 +29,9 @@ const TIER_CONFIGS: Record = { border: 'border-emerald-500/15', bg: 'bg-emerald-500/5', accentColor: 'text-emerald-400', - title: 'Scope determined precisely', + title: 'Task scope determined precisely', detail: - 'Both start (TaskUpdate → in_progress) and completion (TaskUpdate → completed) markers found in the session log. The diff includes only file modifications (Edit, Write) between these two boundaries.', + 'Both start and completion markers found in the session log. The diff includes only changes made during this specific task — other tasks that modified the same files are excluded.', }, 2: { Icon: Info, @@ -40,7 +40,7 @@ const TIER_CONFIGS: Record = { accentColor: 'text-blue-400', title: 'End boundary estimated', detail: - 'Only the start marker (TaskUpdate → in_progress) was found — the task has no completion marker yet. Changes shown from start marker to end of session log.', + 'Only the start marker was found — the task has no completion marker yet. Changes shown from task start to end of session. If other tasks ran after this one in the same session, their changes may also be included.', }, 3: { Icon: AlertTriangle, @@ -49,7 +49,7 @@ const TIER_CONFIGS: Record = { accentColor: 'text-orange-400', title: 'Start boundary estimated', detail: - 'Only the completion marker (TaskUpdate → completed) was found. The start of work was not captured — this can happen if the task was already in progress when the session began. Changes shown from session start to completion marker.', + 'Only the completion marker was found — the start of work was not captured. If other tasks ran before this one in the same session, their changes to the same files may also be included.', }, 4: { Icon: AlertTriangle, @@ -58,7 +58,7 @@ const TIER_CONFIGS: Record = { accentColor: 'text-red-400', title: 'Showing all session changes', detail: - 'No TaskUpdate markers found in the session log. Cannot determine task-specific boundaries — this can happen with older CLI versions or non-standard workflows. All file modifications from the session are included.', + 'No task markers found in the session log. Cannot isolate this task — all file changes from the entire session are shown, including changes from other tasks. This can happen with older CLI versions or non-standard workflows.', }, }; diff --git a/src/renderer/components/terminal/TerminalLogPanel.tsx b/src/renderer/components/terminal/TerminalLogPanel.tsx new file mode 100644 index 00000000..ea9d41c4 --- /dev/null +++ b/src/renderer/components/terminal/TerminalLogPanel.tsx @@ -0,0 +1,83 @@ +import '@xterm/xterm/css/xterm.css'; + +import { useEffect, useRef } from 'react'; + +import { FitAddon } from '@xterm/addon-fit'; +import { Terminal } from '@xterm/xterm'; + +interface TerminalLogPanelProps { + /** Raw output chunks (with ANSI codes) to render */ + chunks: string[]; + /** CSS class for container */ + className?: string; +} + +export const TerminalLogPanel = ({ + chunks, + className, +}: TerminalLogPanelProps): React.JSX.Element => { + const containerRef = useRef(null); + const termRef = useRef(null); + const writtenRef = useRef(0); + + // Create xterm instance once + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const term = new Terminal({ + cursorBlink: false, + disableStdin: true, + fontSize: 12, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + scrollback: 200, + theme: { + background: '#141416', + foreground: '#fafafa', + cursor: 'transparent', + }, + }); + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(container); + + const rafId = requestAnimationFrame(() => fitAddon.fit()); + + const observer = new ResizeObserver(() => fitAddon.fit()); + observer.observe(container); + + termRef.current = term; + writtenRef.current = 0; + + return () => { + cancelAnimationFrame(rafId); + observer.disconnect(); + term.dispose(); + termRef.current = null; + writtenRef.current = 0; + }; + }, []); + + // Write new chunks incrementally + useEffect(() => { + const term = termRef.current; + if (!term) return; + + for (let i = writtenRef.current; i < chunks.length; i++) { + term.write(chunks[i]); + } + writtenRef.current = chunks.length; + }, [chunks]); + + return ( +
+ ); +}; diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index c3a0dd5a..679d5e23 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -26,7 +26,7 @@ export function useCliInstaller(): { downloadTotal: number; installerError: string | null; installerDetail: string | null; - installerLogs: string[]; + installerRawChunks: string[]; completedVersion: string | null; fetchCliStatus: () => Promise; installCli: () => void; @@ -41,7 +41,7 @@ export function useCliInstaller(): { const downloadTotal = useStore((s) => s.cliDownloadTotal); const installerError = useStore((s) => s.cliInstallerError); const installerDetail = useStore((s) => s.cliInstallerDetail); - const installerLogs = useStore((s) => s.cliInstallerLogs); + const installerRawChunks = useStore((s) => s.cliInstallerRawChunks); const completedVersion = useStore((s) => s.cliCompletedVersion); const fetchCliStatus = useStore((s) => s.fetchCliStatus); const installCli = useStore((s) => s.installCli); @@ -58,7 +58,7 @@ export function useCliInstaller(): { downloadTotal, installerError, installerDetail, - installerLogs, + installerRawChunks, completedVersion, fetchCliStatus, installCli, diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index a97e396d..2c2ccc9b 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -399,13 +399,16 @@ export function initializeNotificationListeners(): () => void { useStore.setState({ cliInstallerState: 'verifying', cliInstallerDetail: detail }); break; case 'installing': { - // Accumulate log lines for the mini-terminal + // Accumulate log lines and raw chunks for xterm.js rendering const prevLogs = useStore.getState().cliInstallerLogs; + const prevRaw = useStore.getState().cliInstallerRawChunks; const newLogs = detail ? [...prevLogs, detail].slice(-50) : prevLogs; + const newRaw = progress.rawChunk ? [...prevRaw, progress.rawChunk].slice(-200) : prevRaw; useStore.setState({ cliInstallerState: 'installing', cliInstallerDetail: detail, cliInstallerLogs: newLogs, + cliInstallerRawChunks: newRaw, }); break; } diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 0c7073ba..e66a8294 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -37,6 +37,7 @@ export interface CliInstallerSlice { cliInstallerError: string | null; cliInstallerDetail: string | null; cliInstallerLogs: string[]; + cliInstallerRawChunks: string[]; cliCompletedVersion: string | null; // Actions @@ -62,6 +63,7 @@ export const createCliInstallerSlice: StateCreator { @@ -84,6 +86,7 @@ export const createCliInstallerSlice: StateCreator