feat: add download badges with direct links per platform

This commit is contained in:
iliya 2026-02-27 20:55:38 +02:00
parent 697f5bb896
commit 1737662354
8 changed files with 217 additions and 80 deletions

View file

@ -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>&nbsp;&nbsp;
<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>&nbsp;&nbsp;
<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>&nbsp;
<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>&nbsp;
<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>

View file

@ -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();

View file

@ -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));

View file

@ -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);

View file

@ -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,

View file

@ -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 &mdash;, &#39;, 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 (&nbsp;, &mdash;, &#39;, 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>
)}

View file

@ -405,6 +405,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onExit={() => {
void fetchCliStatus();
}}
autoCloseOnSuccessMs={4000}
successMessage="Login complete"
failureMessage="Login failed"
/>
)}
</>

View file

@ -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>