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.
This commit is contained in:
parent
d3e234c2d4
commit
6a8b9cd1b5
14 changed files with 195 additions and 81 deletions
|
|
@ -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}": [
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
if (logs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="mt-2 max-h-24 overflow-y-auto rounded border font-mono text-xs leading-relaxed"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
padding: '6px 8px',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{logs.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
<span style={{ color: 'var(--color-text-muted)', opacity: 0.5 }}>›</span> {line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 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...
|
||||
</span>
|
||||
</div>
|
||||
<LogPanel logs={installerLogs} />
|
||||
<TerminalLogPanel chunks={installerRawChunks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ const RepositoryCard = ({
|
|||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (projectPath) {
|
||||
void api.openPath(projectPath);
|
||||
void api.openPath(projectPath, projectPath);
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
|
|
|
|||
|
|
@ -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<string[]>([]);
|
||||
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 = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="clear-context"
|
||||
checked={clearContext}
|
||||
onCheckedChange={(checked) => setClearContext(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="clear-context"
|
||||
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-text-secondary"
|
||||
>
|
||||
<RotateCcw className="size-3 shrink-0" />
|
||||
Clear context (fresh session)
|
||||
</Label>
|
||||
</div>
|
||||
{clearContext && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" />
|
||||
<p className="text-amber-300/90">
|
||||
The team lead will start a new session without resuming previous context. All
|
||||
accumulated session memory and conversation history will not be available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeError ? (
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ const TIER_CONFIGS: Record<number, TierConfig> = {
|
|||
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<number, TierConfig> = {
|
|||
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<number, TierConfig> = {
|
|||
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<number, TierConfig> = {
|
|||
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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
83
src/renderer/components/terminal/TerminalLogPanel.tsx
Normal file
83
src/renderer/components/terminal/TerminalLogPanel.tsx
Normal file
|
|
@ -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<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`mt-2 overflow-hidden rounded border ${className ?? ''}`}
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
height: '120px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -26,7 +26,7 @@ export function useCliInstaller(): {
|
|||
downloadTotal: number;
|
||||
installerError: string | null;
|
||||
installerDetail: string | null;
|
||||
installerLogs: string[];
|
||||
installerRawChunks: string[];
|
||||
completedVersion: string | null;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppState, [], [], CliInstalle
|
|||
cliInstallerError: null,
|
||||
cliInstallerDetail: null,
|
||||
cliInstallerLogs: [],
|
||||
cliInstallerRawChunks: [],
|
||||
cliCompletedVersion: null,
|
||||
|
||||
fetchCliStatus: async () => {
|
||||
|
|
@ -84,6 +86,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
cliInstallerError: null,
|
||||
cliInstallerDetail: null,
|
||||
cliInstallerLogs: [],
|
||||
cliInstallerRawChunks: [],
|
||||
cliDownloadProgress: 0,
|
||||
cliDownloadTransferred: 0,
|
||||
cliDownloadTotal: 0,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ export interface CliInstallerProgress {
|
|||
error?: string;
|
||||
/** Status detail text (e.g. stdout lines from `claude install`) */
|
||||
detail?: string;
|
||||
/** Raw terminal output chunk (with ANSI codes), only for 'installing' */
|
||||
rawChunk?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -212,6 +212,8 @@ export interface TeamLaunchRequest {
|
|||
cwd: string;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
/** When true, skip --resume and start a fresh session (clears context memory). */
|
||||
clearContext?: boolean;
|
||||
}
|
||||
|
||||
export interface TeamLaunchResponse {
|
||||
|
|
|
|||
Loading…
Reference in a new issue