feat: add download badges with direct links per platform
This commit is contained in:
parent
697f5bb896
commit
1737662354
8 changed files with 217 additions and 80 deletions
40
README.md
40
README.md
|
|
@ -15,17 +15,39 @@
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<p align="center">
|
<table align="center">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg">
|
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg">
|
||||||
<img src="https://img.shields.io/badge/macOS-Download-black?logo=apple&logoColor=white&style=flat" alt="Download for macOS" height="30" />
|
<img src="https://img.shields.io/badge/macOS_(Apple_Silicon)-Download_.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
|
||||||
</a>
|
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage">
|
|
||||||
<img src="https://img.shields.io/badge/Linux-Download-FCC624?logo=linux&logoColor=black&style=flat" alt="Download for Linux" height="30" />
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe">
|
|
||||||
<img src="https://img.shields.io/badge/Windows-Download-0078D4?logo=windows&logoColor=white&style=flat" alt="Download for Windows" height="30" />
|
|
||||||
</a>
|
</a>
|
||||||
</p>
|
<br />
|
||||||
|
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x64.dmg">
|
||||||
|
<img src="https://img.shields.io/badge/macOS_(Intel)-Download_.dmg-434343?style=flat-square&logo=apple&logoColor=white" alt="macOS Intel" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe">
|
||||||
|
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage">
|
||||||
|
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
|
||||||
|
</a>
|
||||||
|
<br />
|
||||||
|
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-amd64.deb">
|
||||||
|
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x86_64.rpm">
|
||||||
|
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.pacman">
|
||||||
|
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>100% free, open source. No API keys. No configuration.</sub>
|
<sub>100% free, open source. No API keys. No configuration.</sub>
|
||||||
|
|
|
||||||
|
|
@ -572,6 +572,13 @@ function shutdownServices(): void {
|
||||||
sshConnectionManager.dispose();
|
sshConnectionManager.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop all running team provisioning processes
|
||||||
|
if (teamProvisioningService) {
|
||||||
|
for (const teamName of teamProvisioningService.getAliveTeams()) {
|
||||||
|
teamProvisioningService.stopTeam(teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Kill all PTY processes
|
// Kill all PTY processes
|
||||||
if (ptyTerminalService) {
|
if (ptyTerminalService) {
|
||||||
ptyTerminalService.killAll();
|
ptyTerminalService.killAll();
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,18 @@ export class PtyTerminalService {
|
||||||
? (process.env.COMSPEC ?? 'powershell.exe')
|
? (process.env.COMSPEC ?? 'powershell.exe')
|
||||||
: (process.env.SHELL ?? '/bin/bash'));
|
: (process.env.SHELL ?? '/bin/bash'));
|
||||||
|
|
||||||
|
const home = getHomeDir();
|
||||||
const pty = nodePty.spawn(shell, options?.args ?? [], {
|
const pty = nodePty.spawn(shell, options?.args ?? [], {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: options?.cols ?? 80,
|
cols: options?.cols ?? 80,
|
||||||
rows: options?.rows ?? 24,
|
rows: options?.rows ?? 24,
|
||||||
cwd: options?.cwd ?? getHomeDir(),
|
cwd: options?.cwd ?? home,
|
||||||
env: { ...process.env, ...options?.env } as Record<string, string>,
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: home,
|
||||||
|
USERPROFILE: home,
|
||||||
|
...options?.env,
|
||||||
|
} as Record<string, string>,
|
||||||
});
|
});
|
||||||
|
|
||||||
pty.onData((data) => this.send(TERMINAL_DATA, id, data));
|
pty.onData((data) => this.send(TERMINAL_DATA, id, data));
|
||||||
|
|
|
||||||
|
|
@ -176,9 +176,7 @@ export class ClaudeBinaryResolver {
|
||||||
path.join(getHomeDir(), '.npm-global', 'bin'),
|
path.join(getHomeDir(), '.npm-global', 'bin'),
|
||||||
path.join(getHomeDir(), '.npm', 'bin'),
|
path.join(getHomeDir(), '.npm', 'bin'),
|
||||||
process.platform === 'win32'
|
process.platform === 'win32'
|
||||||
? process.env.APPDATA
|
? path.join(getHomeDir(), 'AppData', 'Roaming', 'npm')
|
||||||
? path.join(process.env.APPDATA, 'npm')
|
|
||||||
: ''
|
|
||||||
: '/usr/local/bin',
|
: '/usr/local/bin',
|
||||||
process.platform === 'win32' ? '' : '/opt/homebrew/bin',
|
process.platform === 'win32' ? '' : '/opt/homebrew/bin',
|
||||||
].filter((candidate) => candidate.length > 0);
|
].filter((candidate) => candidate.length > 0);
|
||||||
|
|
|
||||||
|
|
@ -196,10 +196,22 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
});
|
});
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
|
let settled = false;
|
||||||
let timeoutHandle: NodeJS.Timeout | null = setTimeout(() => {
|
let timeoutHandle: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
timeoutHandle = null;
|
timeoutHandle = null;
|
||||||
child.kill();
|
child.kill();
|
||||||
reject(new Error('shell env resolve timeout'));
|
// SIGKILL fallback if SIGTERM is ignored (e.g., shell stuck on .zshrc)
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
} catch {
|
||||||
|
/* already dead */
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(new Error('shell env resolve timeout'));
|
||||||
|
}
|
||||||
}, SHELL_ENV_TIMEOUT_MS);
|
}, SHELL_ENV_TIMEOUT_MS);
|
||||||
|
|
||||||
child.stdout?.on('data', (chunk: Buffer) => {
|
child.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
|
@ -210,13 +222,19 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
|
||||||
clearTimeout(timeoutHandle);
|
clearTimeout(timeoutHandle);
|
||||||
timeoutHandle = null;
|
timeoutHandle = null;
|
||||||
}
|
}
|
||||||
reject(error);
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
child.once('close', () => {
|
child.once('close', () => {
|
||||||
if (timeoutHandle) {
|
if (timeoutHandle) {
|
||||||
clearTimeout(timeoutHandle);
|
clearTimeout(timeoutHandle);
|
||||||
}
|
}
|
||||||
resolve(Buffer.concat(chunks).toString('utf8'));
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return parseNullSeparatedEnv(envDump);
|
return parseNullSeparatedEnv(envDump);
|
||||||
|
|
@ -965,7 +983,7 @@ export class TeamProvisioningService {
|
||||||
run.child = child;
|
run.child = child;
|
||||||
|
|
||||||
// Send provisioning prompt as first stream-json message (SDKUserMessage format)
|
// Send provisioning prompt as first stream-json message (SDKUserMessage format)
|
||||||
if (child.stdin) {
|
if (child.stdin?.writable) {
|
||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
type: 'user',
|
type: 'user',
|
||||||
message: {
|
message: {
|
||||||
|
|
@ -1275,7 +1293,7 @@ export class TeamProvisioningService {
|
||||||
run.child = child;
|
run.child = child;
|
||||||
|
|
||||||
// Send launch prompt
|
// Send launch prompt
|
||||||
if (child.stdin) {
|
if (child.stdin?.writable) {
|
||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
type: 'user',
|
type: 'user',
|
||||||
message: {
|
message: {
|
||||||
|
|
@ -1982,7 +2000,9 @@ export class TeamProvisioningService {
|
||||||
* Process stays alive for subsequent tasks.
|
* Process stays alive for subsequent tasks.
|
||||||
*/
|
*/
|
||||||
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
||||||
if (run.cancelRequested) return;
|
// Guard: must be set synchronously BEFORE any await to prevent
|
||||||
|
// double-invocation from filesystem monitor + stream-json racing.
|
||||||
|
if (run.provisioningComplete || run.cancelRequested) return;
|
||||||
run.provisioningComplete = true;
|
run.provisioningComplete = true;
|
||||||
this.setLeadActivity(run, 'idle');
|
this.setLeadActivity(run, 'idle');
|
||||||
|
|
||||||
|
|
@ -2058,6 +2078,11 @@ export class TeamProvisioningService {
|
||||||
run.timeoutHandle = null;
|
run.timeoutHandle = null;
|
||||||
}
|
}
|
||||||
this.stopFilesystemMonitor(run);
|
this.stopFilesystemMonitor(run);
|
||||||
|
// Remove stream listeners to prevent data handlers firing on a cleaned-up run
|
||||||
|
if (run.child) {
|
||||||
|
run.child.stdout?.removeAllListeners('data');
|
||||||
|
run.child.stderr?.removeAllListeners('data');
|
||||||
|
}
|
||||||
this.activeByTeam.delete(run.teamName);
|
this.activeByTeam.delete(run.teamName);
|
||||||
this.leadInboxRelayInFlight.delete(run.teamName);
|
this.leadInboxRelayInFlight.delete(run.teamName);
|
||||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||||
|
|
@ -2441,7 +2466,10 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
private async buildProvisioningEnv(): Promise<ProvisioningEnvResolution> {
|
private async buildProvisioningEnv(): Promise<ProvisioningEnvResolution> {
|
||||||
const shellEnv = await resolveInteractiveShellEnv();
|
const shellEnv = await resolveInteractiveShellEnv();
|
||||||
const home = shellEnv.HOME?.trim() || process.env.HOME?.trim() || getHomeDir();
|
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
||||||
|
// correctly on Windows. Prefer it over process.env which may be garbled.
|
||||||
|
const electronHome = getHomeDir();
|
||||||
|
const home = shellEnv.HOME?.trim() || electronHome;
|
||||||
const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username;
|
const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username;
|
||||||
const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh';
|
const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh';
|
||||||
const xdgConfigHome =
|
const xdgConfigHome =
|
||||||
|
|
@ -2455,6 +2483,7 @@ export class TeamProvisioningService {
|
||||||
...process.env,
|
...process.env,
|
||||||
...shellEnv,
|
...shellEnv,
|
||||||
HOME: home,
|
HOME: home,
|
||||||
|
USERPROFILE: home,
|
||||||
USER: user,
|
USER: user,
|
||||||
LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
|
LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
|
||||||
SHELL: shell,
|
SHELL: shell,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* UpdateDialog - Modal dialog shown when a new version is available.
|
* UpdateDialog - Modal dialog shown when a new version is available.
|
||||||
*
|
*
|
||||||
* Prompts the user to download the update or dismiss it.
|
* Prompts the user to download the update or dismiss it.
|
||||||
* Release notes may be HTML from the updater; we normalize to text and render as markdown.
|
* Release notes (markdown from GitHub) are rendered with ReactMarkdown.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
@ -14,31 +14,6 @@ import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize release notes: strip HTML tags and convert block elements to newlines.
|
|
||||||
* Uses DOMParser for proper HTML entity decoding (handles all entities like —, ', etc.)
|
|
||||||
*/
|
|
||||||
function normalizeReleaseNotes(html: string): string {
|
|
||||||
if (!html?.trim()) return '';
|
|
||||||
|
|
||||||
// Convert block elements to newlines for better formatting
|
|
||||||
const processed = html
|
|
||||||
.replace(/<\/p>\s*/gi, '\n\n')
|
|
||||||
.replace(/<br\s*\/?>\s*/gi, '\n')
|
|
||||||
.replace(/<\/div>\s*/gi, '\n')
|
|
||||||
.replace(/<\/li>\s*/gi, '\n')
|
|
||||||
.replace(/<\/h[1-6]>\s*/gi, '\n\n');
|
|
||||||
|
|
||||||
// Use DOMParser to decode HTML entities and strip remaining tags
|
|
||||||
// This properly handles all HTML entities ( , —, ', etc.)
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(processed, 'text/html');
|
|
||||||
const text = doc.body.textContent || '';
|
|
||||||
|
|
||||||
// Normalize multiple newlines
|
|
||||||
return text.replace(/\n{3,}/g, '\n\n').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UpdateDialog = (): React.JSX.Element | null => {
|
export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
|
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
|
||||||
const availableVersion = useStore((s) => s.availableVersion);
|
const availableVersion = useStore((s) => s.availableVersion);
|
||||||
|
|
@ -141,14 +116,14 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Release notes — normalize HTML then render as markdown */}
|
{/* Release notes */}
|
||||||
{releaseNotes && (
|
{releaseNotes && (
|
||||||
<div
|
<div
|
||||||
className="prose prose-sm mb-4 max-h-48 overflow-y-auto rounded border p-2 text-xs"
|
className="prose prose-sm prose-invert mb-4 max-h-60 overflow-y-auto rounded border p-3 text-xs"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface)',
|
backgroundColor: 'var(--color-surface)',
|
||||||
borderColor: 'var(--color-border)',
|
borderColor: 'var(--color-border)',
|
||||||
color: 'var(--color-text-muted)',
|
color: 'var(--color-text-secondary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
|
|
@ -156,7 +131,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
rehypePlugins={REHYPE_PLUGINS}
|
rehypePlugins={REHYPE_PLUGINS}
|
||||||
components={markdownComponents}
|
components={markdownComponents}
|
||||||
>
|
>
|
||||||
{normalizeReleaseNotes(releaseNotes)}
|
{releaseNotes}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
onExit={() => {
|
onExit={() => {
|
||||||
void fetchCliStatus();
|
void fetchCliStatus();
|
||||||
}}
|
}}
|
||||||
|
autoCloseOnSuccessMs={4000}
|
||||||
|
successMessage="Login complete"
|
||||||
|
failureMessage="Login failed"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
import { Terminal, X } from 'lucide-react';
|
import { CheckCircle, Terminal, X, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { EmbeddedTerminal } from './EmbeddedTerminal';
|
import { EmbeddedTerminal } from './EmbeddedTerminal';
|
||||||
|
|
||||||
|
|
@ -18,6 +18,12 @@ interface TerminalModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Called when the PTY process exits */
|
/** Called when the PTY process exits */
|
||||||
onExit?: (exitCode: number) => void;
|
onExit?: (exitCode: number) => void;
|
||||||
|
/** Auto-close the modal after this many ms on success (exit code 0). 0 = disabled. */
|
||||||
|
autoCloseOnSuccessMs?: number;
|
||||||
|
/** Custom message shown on exit code 0. Default: "Completed successfully" */
|
||||||
|
successMessage?: string;
|
||||||
|
/** Custom message prefix for non-zero exit. Default: "Process failed" */
|
||||||
|
failureMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalModal({
|
export function TerminalModal({
|
||||||
|
|
@ -27,28 +33,72 @@ export function TerminalModal({
|
||||||
cwd,
|
cwd,
|
||||||
onClose,
|
onClose,
|
||||||
onExit,
|
onExit,
|
||||||
|
autoCloseOnSuccessMs = 0,
|
||||||
|
successMessage = 'Completed successfully',
|
||||||
|
failureMessage = 'Process failed',
|
||||||
}: TerminalModalProps): React.JSX.Element {
|
}: TerminalModalProps): React.JSX.Element {
|
||||||
const [exited, setExited] = useState<number | null>(null);
|
const [exited, setExited] = useState<number | null>(null);
|
||||||
|
const [countdown, setCountdown] = useState<number>(0);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleExit = (exitCode: number): void => {
|
const handleExit = useCallback(
|
||||||
setExited(exitCode);
|
(exitCode: number): void => {
|
||||||
onExit?.(exitCode);
|
setExited(exitCode);
|
||||||
};
|
onExit?.(exitCode);
|
||||||
|
if (exitCode === 0 && autoCloseOnSuccessMs > 0) {
|
||||||
|
setCountdown(Math.ceil(autoCloseOnSuccessMs / 1000));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onExit, autoCloseOnSuccessMs]
|
||||||
|
);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
const handleKeyDown = useCallback(
|
||||||
if (e.key === 'Escape') {
|
(e: React.KeyboardEvent): void => {
|
||||||
e.stopPropagation();
|
if (e.key === 'Escape') {
|
||||||
onClose();
|
e.stopPropagation();
|
||||||
}
|
onClose();
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Focus trap — focus dialog on mount
|
||||||
|
useEffect(() => {
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Countdown timer for auto-close
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown <= 0) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
onClose();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [countdown, onClose]);
|
||||||
|
|
||||||
|
const totalSeconds = autoCloseOnSuccessMs > 0 ? Math.ceil(autoCloseOnSuccessMs / 1000) : 0;
|
||||||
|
const progressPercent = totalSeconds > 0 ? (countdown / totalSeconds) * 100 : 0;
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- modal backdrop
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- modal backdrop handles Escape
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<div className="flex h-[60vh] w-full max-w-3xl flex-col overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl">
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={title}
|
||||||
|
aria-modal="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex h-[60vh] w-full max-w-3xl flex-col overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl outline-none"
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-text">
|
<div className="flex items-center gap-2 text-sm font-medium text-text">
|
||||||
|
|
@ -57,28 +107,75 @@ export function TerminalModal({
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terminal area */}
|
{/* Terminal area — always visible, status bar overlaid at bottom */}
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col p-2">
|
<div className="relative flex min-h-0 flex-1 flex-col p-2">
|
||||||
{exited === null ? (
|
<EmbeddedTerminal command={command} args={args} cwd={cwd} onExit={handleExit} />
|
||||||
<EmbeddedTerminal command={command} args={args} cwd={cwd} onExit={handleExit} />
|
|
||||||
) : (
|
{exited !== null && (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-text-secondary">
|
<div
|
||||||
<p className="text-sm">
|
role="status"
|
||||||
Process exited with code{' '}
|
aria-live="polite"
|
||||||
<span className="font-mono font-medium text-text">{exited}</span>
|
aria-atomic="true"
|
||||||
</p>
|
className="absolute inset-x-0 bottom-0 border-t px-4 py-3"
|
||||||
<button
|
style={{
|
||||||
onClick={onClose}
|
backgroundColor: 'rgba(20, 20, 22, 0.98)',
|
||||||
className="rounded-md bg-surface-raised px-4 py-1.5 text-sm text-text transition-colors hover:bg-border-emphasis"
|
borderColor:
|
||||||
>
|
exited === 0 ? 'rgba(74, 222, 128, 0.25)' : 'rgba(248, 113, 113, 0.25)',
|
||||||
Close
|
backdropFilter: 'blur(12px)',
|
||||||
</button>
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{exited === 0 ? (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<CheckCircle size={18} className="shrink-0 text-green-400" aria-hidden="true" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-green-400">{successMessage}</span>
|
||||||
|
{countdown > 0 && (
|
||||||
|
<span className="text-xs text-text-muted">Closing in {countdown}s...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<XCircle size={18} className="shrink-0 text-red-400" aria-hidden="true" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-red-400">
|
||||||
|
{failureMessage}{' '}
|
||||||
|
<span className="font-mono opacity-75">(exit code {exited})</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
Check terminal output above for details
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="shrink-0 rounded-md bg-surface-raised px-4 py-1.5 text-sm text-text transition-colors hover:bg-border-emphasis"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar for auto-close countdown */}
|
||||||
|
{countdown > 0 && (
|
||||||
|
<div
|
||||||
|
className="mt-2.5 h-0.5 w-full overflow-hidden rounded-full"
|
||||||
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.06)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-green-400/50 transition-all duration-1000 ease-linear"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue