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 />
|
||||
|
||||
<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">
|
||||
<img src="https://img.shields.io/badge/macOS-Download-black?logo=apple&logoColor=white&style=flat" alt="Download for macOS" height="30" />
|
||||
</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" />
|
||||
<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>
|
||||
</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">
|
||||
<sub>100% free, open source. No API keys. No configuration.</sub>
|
||||
|
|
|
|||
|
|
@ -572,6 +572,13 @@ function shutdownServices(): void {
|
|||
sshConnectionManager.dispose();
|
||||
}
|
||||
|
||||
// Stop all running team provisioning processes
|
||||
if (teamProvisioningService) {
|
||||
for (const teamName of teamProvisioningService.getAliveTeams()) {
|
||||
teamProvisioningService.stopTeam(teamName);
|
||||
}
|
||||
}
|
||||
|
||||
// Kill all PTY processes
|
||||
if (ptyTerminalService) {
|
||||
ptyTerminalService.killAll();
|
||||
|
|
|
|||
|
|
@ -58,12 +58,18 @@ export class PtyTerminalService {
|
|||
? (process.env.COMSPEC ?? 'powershell.exe')
|
||||
: (process.env.SHELL ?? '/bin/bash'));
|
||||
|
||||
const home = getHomeDir();
|
||||
const pty = nodePty.spawn(shell, options?.args ?? [], {
|
||||
name: 'xterm-256color',
|
||||
cols: options?.cols ?? 80,
|
||||
rows: options?.rows ?? 24,
|
||||
cwd: options?.cwd ?? getHomeDir(),
|
||||
env: { ...process.env, ...options?.env } as Record<string, string>,
|
||||
cwd: options?.cwd ?? home,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
...options?.env,
|
||||
} as Record<string, string>,
|
||||
});
|
||||
|
||||
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', 'bin'),
|
||||
process.platform === 'win32'
|
||||
? process.env.APPDATA
|
||||
? path.join(process.env.APPDATA, 'npm')
|
||||
: ''
|
||||
? path.join(getHomeDir(), 'AppData', 'Roaming', 'npm')
|
||||
: '/usr/local/bin',
|
||||
process.platform === 'win32' ? '' : '/opt/homebrew/bin',
|
||||
].filter((candidate) => candidate.length > 0);
|
||||
|
|
|
|||
|
|
@ -196,10 +196,22 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
|
|||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
let settled = false;
|
||||
let timeoutHandle: NodeJS.Timeout | null = setTimeout(() => {
|
||||
timeoutHandle = null;
|
||||
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);
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
|
|
@ -210,13 +222,19 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
|
|||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
reject(error);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once('close', () => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
}
|
||||
});
|
||||
});
|
||||
return parseNullSeparatedEnv(envDump);
|
||||
|
|
@ -965,7 +983,7 @@ export class TeamProvisioningService {
|
|||
run.child = child;
|
||||
|
||||
// Send provisioning prompt as first stream-json message (SDKUserMessage format)
|
||||
if (child.stdin) {
|
||||
if (child.stdin?.writable) {
|
||||
const message = JSON.stringify({
|
||||
type: 'user',
|
||||
message: {
|
||||
|
|
@ -1275,7 +1293,7 @@ export class TeamProvisioningService {
|
|||
run.child = child;
|
||||
|
||||
// Send launch prompt
|
||||
if (child.stdin) {
|
||||
if (child.stdin?.writable) {
|
||||
const message = JSON.stringify({
|
||||
type: 'user',
|
||||
message: {
|
||||
|
|
@ -1982,7 +2000,9 @@ export class TeamProvisioningService {
|
|||
* Process stays alive for subsequent tasks.
|
||||
*/
|
||||
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;
|
||||
this.setLeadActivity(run, 'idle');
|
||||
|
||||
|
|
@ -2058,6 +2078,11 @@ export class TeamProvisioningService {
|
|||
run.timeoutHandle = null;
|
||||
}
|
||||
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.leadInboxRelayInFlight.delete(run.teamName);
|
||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||
|
|
@ -2441,7 +2466,10 @@ export class TeamProvisioningService {
|
|||
|
||||
private async buildProvisioningEnv(): Promise<ProvisioningEnvResolution> {
|
||||
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 shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh';
|
||||
const xdgConfigHome =
|
||||
|
|
@ -2455,6 +2483,7 @@ export class TeamProvisioningService {
|
|||
...process.env,
|
||||
...shellEnv,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
USER: user,
|
||||
LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
|
||||
SHELL: shell,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* UpdateDialog - Modal dialog shown when a new version is available.
|
||||
*
|
||||
* 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';
|
||||
|
|
@ -14,31 +14,6 @@ import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
|||
import { X } from 'lucide-react';
|
||||
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 => {
|
||||
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
|
||||
const availableVersion = useStore((s) => s.availableVersion);
|
||||
|
|
@ -141,14 +116,14 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Release notes — normalize HTML then render as markdown */}
|
||||
{/* Release notes */}
|
||||
{releaseNotes && (
|
||||
<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={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
|
|
@ -156,7 +131,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
|||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{normalizeReleaseNotes(releaseNotes)}
|
||||
{releaseNotes}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -405,6 +405,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
onExit={() => {
|
||||
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 { Terminal, X } from 'lucide-react';
|
||||
import { CheckCircle, Terminal, X, XCircle } from 'lucide-react';
|
||||
|
||||
import { EmbeddedTerminal } from './EmbeddedTerminal';
|
||||
|
||||
|
|
@ -18,6 +18,12 @@ interface TerminalModalProps {
|
|||
onClose: () => void;
|
||||
/** Called when the PTY process exits */
|
||||
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({
|
||||
|
|
@ -27,28 +33,72 @@ export function TerminalModal({
|
|||
cwd,
|
||||
onClose,
|
||||
onExit,
|
||||
autoCloseOnSuccessMs = 0,
|
||||
successMessage = 'Completed successfully',
|
||||
failureMessage = 'Process failed',
|
||||
}: TerminalModalProps): React.JSX.Element {
|
||||
const [exited, setExited] = useState<number | null>(null);
|
||||
const [countdown, setCountdown] = useState<number>(0);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleExit = (exitCode: number): void => {
|
||||
setExited(exitCode);
|
||||
onExit?.(exitCode);
|
||||
};
|
||||
const handleExit = useCallback(
|
||||
(exitCode: number): void => {
|
||||
setExited(exitCode);
|
||||
onExit?.(exitCode);
|
||||
if (exitCode === 0 && autoCloseOnSuccessMs > 0) {
|
||||
setCountdown(Math.ceil(autoCloseOnSuccessMs / 1000));
|
||||
}
|
||||
},
|
||||
[onExit, autoCloseOnSuccessMs]
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
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(
|
||||
// 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
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
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 */}
|
||||
<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">
|
||||
|
|
@ -57,28 +107,75 @@ export function TerminalModal({
|
|||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</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">
|
||||
{exited === null ? (
|
||||
<EmbeddedTerminal command={command} args={args} cwd={cwd} onExit={handleExit} />
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-text-secondary">
|
||||
<p className="text-sm">
|
||||
Process exited with code{' '}
|
||||
<span className="font-mono font-medium text-text">{exited}</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md bg-surface-raised px-4 py-1.5 text-sm text-text transition-colors hover:bg-border-emphasis"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<EmbeddedTerminal command={command} args={args} cwd={cwd} onExit={handleExit} />
|
||||
|
||||
{exited !== null && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="absolute inset-x-0 bottom-0 border-t px-4 py-3"
|
||||
style={{
|
||||
backgroundColor: 'rgba(20, 20, 22, 0.98)',
|
||||
borderColor:
|
||||
exited === 0 ? 'rgba(74, 222, 128, 0.25)' : 'rgba(248, 113, 113, 0.25)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue