Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1de05a7f1 | ||
|
|
05c57be98f | ||
|
|
222c80e544 | ||
|
|
8dd6dd09d6 | ||
|
|
eda0e26adb |
No files matched your search
@@ -60,3 +60,4 @@ CLAUDE.md
|
|||||||
build/release/AgentCompany-0.4.61-arm64.dmg
|
build/release/AgentCompany-0.4.61-arm64.dmg
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
build/release/AgentCompany-0.4.90-arm64.dmg
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Launch autonomous AI companies from your desktop.</strong><br/>
|
<strong>Launch autonomous AI companies from your desktop.</strong><br/>
|
||||||
Set a goal. Watch AI agents self-organize, delegate, and deliver.
|
Set a goal. Watch AI agents self-organize, delegate, and deliver.<br/>
|
||||||
|
<strong>PLEASE USE A CODING PLAN, IT COMSUMES A LOT OF TOKENS</strong><br/>
|
||||||
|
I will add support for Ollama and Github Copilot in the near future.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
|
|
||||||
## What is AgentCompany?
|
## What is AgentCompany?
|
||||||
|
|
||||||
AgentCompany is a cross-platform desktop app that turns a single goal into a fully autonomous AI company. You provide a company name, description, and mission. A CEO agent self-organizes departments, hires specialists, delegates tasks, and delivers real results — code, content, research, business plans, and more — while you observe through a Slack-style command hub.
|
AgentCompany is a cross-platform desktop app that turns a single goal into a fully autonomous AI company. You provide a company name, description, and mission. A CEO agent self-organizes departments, hires specialists, delegates tasks, and delivers real results — code, content, research, business plans, and more — while you observe through a Slack-style command hub. Inspired by **[Paperclip](https://github.com/paperclipai/paperclip)**
|
||||||
|
|
||||||
### Supported AI Runtimes
|
### Supported AI Runtimes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
|
||||||
|
<!-- Apple HIG: full-bleed square, no circle, OS applies mask -->
|
||||||
|
<rect width="1024" height="1024" fill="#191919"/>
|
||||||
|
<g transform="translate(146 134) scale(6)">
|
||||||
|
<path fill="#FBFAF5" d="M 76.604813 111.661926 C 65.070183 111.662621 53.987053 111.662621 42.757744 111.662621 C 45.2239 101.998177 45.297546 101.949265 53.830105 102.036659 C 57.279823 102.071999 60.730202 102.04248 64.259201 102.04248 C 62.159458 93.316338 61.799103 93.009819 51.340881 91.341682 C 59.312901 73.847687 67.193756 56.553749 75.587578 38.134155 C 86.933441 62.965954 97.804642 86.758881 108.711754 110.630394 C 100.707413 111.871689 92.355965 107.246559 88.107536 98.852119 C 84.207748 91.146538 80.800591 83.191696 77.172813 75.348343 C 76.340195 73.54818 75.492958 71.754791 74.614624 69.877441 C 69.107384 75.691788 68.008286 81.475128 71.005882 88.088753 C 74.413452 95.606903 77.813919 103.128281 81.452705 111.167984 C 79.55114 111.381325 78.303734 111.521271 76.604813 111.661926 Z"/>
|
||||||
|
<path fill="#FBFAF5" d="M 26.805992 87.743256 C 38.616821 61.830872 50.261688 36.260269 62.470131 9.452141 C 65.732315 17.818909 71.145126 23.524567 66.258224 33.086182 C 55.518253 54.099815 46.339973 75.911873 36.533691 97.402336 C 32.699284 105.805443 26.495394 110.823158 16.012421 111.410957 C 19.72117 103.270836 23.180601 95.67794 26.805992 87.743256 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 738 B |
|
Before Width: | Height: | Size: 559 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 164 B After Width: | Height: | Size: 320 B |
|
Before Width: | Height: | Size: 243 B After Width: | Height: | Size: 676 B |
@@ -4,7 +4,7 @@
|
|||||||
"output": "/Users/zacharyzhang/Documents/Github/AgentCompany/build/release"
|
"output": "/Users/zacharyzhang/Documents/Github/AgentCompany/build/release"
|
||||||
},
|
},
|
||||||
"extraMetadata": {
|
"extraMetadata": {
|
||||||
"version": "0.4.80"
|
"version": "0.4.95"
|
||||||
},
|
},
|
||||||
"mac": {
|
"mac": {
|
||||||
"hardenedRuntime": false,
|
"hardenedRuntime": false,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ arm64:
|
|||||||
- '!build/release{,/**/*}'
|
- '!build/release{,/**/*}'
|
||||||
- out/**
|
- out/**
|
||||||
- package.json
|
- package.json
|
||||||
|
- assets/icon.png
|
||||||
|
- assets/tray-icon*.png
|
||||||
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
|
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
|
||||||
- '!**/._*'
|
- '!**/._*'
|
||||||
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
|
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
|
||||||
@@ -16,3 +18,5 @@ arm64:
|
|||||||
- '**/*'
|
- '**/*'
|
||||||
- out/**
|
- out/**
|
||||||
- package.json
|
- package.json
|
||||||
|
- assets/icon.png
|
||||||
|
- assets/tray-icon*.png
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
version: 0.4.80
|
version: 0.4.95
|
||||||
files:
|
files:
|
||||||
- url: AgentCompany-0.4.80-arm64-mac.zip
|
- url: AgentCompany-0.4.95-arm64-mac.zip
|
||||||
sha512: 5gKuP0v6BeMrgiEre/WXXERVAyjh7imWgleoV18ZU5fly+/wq8witKgg8o+E5kid7VUSqTYlvePRtkT8NrYYhg==
|
sha512: /Ew0ls0nISOD/65MHxVbc23TRmgP/bbCWR89j/FhoAwKkE0Vmexjsp6IxY22X8tXnwuMgP+A/DJgqenNlKslzQ==
|
||||||
size: 113614651
|
size: 115753870
|
||||||
path: AgentCompany-0.4.80-arm64-mac.zip
|
path: AgentCompany-0.4.95-arm64-mac.zip
|
||||||
sha512: 5gKuP0v6BeMrgiEre/WXXERVAyjh7imWgleoV18ZU5fly+/wq8witKgg8o+E5kid7VUSqTYlvePRtkT8NrYYhg==
|
sha512: /Ew0ls0nISOD/65MHxVbc23TRmgP/bbCWR89j/FhoAwKkE0Vmexjsp6IxY22X8tXnwuMgP+A/DJgqenNlKslzQ==
|
||||||
releaseDate: '2026-03-23T20:47:41.499Z'
|
releaseDate: '2026-03-24T20:37:33.571Z'
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-company",
|
"name": "agent-company",
|
||||||
"version": "0.4.92",
|
"version": "0.4.97",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
||||||
@@ -35,7 +35,9 @@
|
|||||||
"@agentcompany/adapter-codex-local": "workspace:*",
|
"@agentcompany/adapter-codex-local": "workspace:*",
|
||||||
"@agentcompany/adapter-gemini-local": "workspace:*",
|
"@agentcompany/adapter-gemini-local": "workspace:*",
|
||||||
"@agentcompany/adapter-utils": "workspace:*",
|
"@agentcompany/adapter-utils": "workspace:*",
|
||||||
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource-variable/jetbrains-mono": "5.2.8",
|
"@fontsource-variable/jetbrains-mono": "5.2.8",
|
||||||
|
"@fontsource/instrument-serif": "^5.2.8",
|
||||||
"@phosphor-icons/react": "2.1.10",
|
"@phosphor-icons/react": "2.1.10",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@tanstack/react-virtual": "^3.13.23",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
|||||||
@@ -20,9 +20,15 @@ importers:
|
|||||||
'@agentcompany/adapter-utils':
|
'@agentcompany/adapter-utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:packages/adapter-utils
|
version: link:packages/adapter-utils
|
||||||
|
'@fontsource-variable/inter':
|
||||||
|
specifier: ^5.2.8
|
||||||
|
version: 5.2.8
|
||||||
'@fontsource-variable/jetbrains-mono':
|
'@fontsource-variable/jetbrains-mono':
|
||||||
specifier: 5.2.8
|
specifier: 5.2.8
|
||||||
version: 5.2.8
|
version: 5.2.8
|
||||||
|
'@fontsource/instrument-serif':
|
||||||
|
specifier: ^5.2.8
|
||||||
|
version: 5.2.8
|
||||||
'@phosphor-icons/react':
|
'@phosphor-icons/react':
|
||||||
specifier: 2.1.10
|
specifier: 2.1.10
|
||||||
version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -699,9 +705,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@fontsource-variable/inter@5.2.8':
|
||||||
|
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
|
||||||
|
|
||||||
'@fontsource-variable/jetbrains-mono@5.2.8':
|
'@fontsource-variable/jetbrains-mono@5.2.8':
|
||||||
resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==}
|
resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==}
|
||||||
|
|
||||||
|
'@fontsource/instrument-serif@5.2.8':
|
||||||
|
resolution: {integrity: sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw==}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -3387,8 +3399,12 @@ snapshots:
|
|||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@fontsource-variable/inter@5.2.8': {}
|
||||||
|
|
||||||
'@fontsource-variable/jetbrains-mono@5.2.8': {}
|
'@fontsource-variable/jetbrains-mono@5.2.8': {}
|
||||||
|
|
||||||
|
'@fontsource/instrument-serif@5.2.8': {}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.7':
|
'@humanfs/node@0.16.7':
|
||||||
|
|||||||
@@ -1,222 +1,195 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Generate AgentCompany app icons (PNG, ICO, ICNS) and tray icons.
|
"""Generate AgentCompany icons from SVG sources using cairosvg.
|
||||||
|
|
||||||
Design v4: Circle background #191919 with #FBFAF5 "A" logo.
|
Two icon families:
|
||||||
Uses the actual logo paths from logo.svg rendered via cairosvg/Pillow.
|
- Web (circle bg): icon.svg → landing favicon + website logo
|
||||||
|
- App (square bg): icon-app.svg → Electron app icon (Apple HIG, OS applies mask)
|
||||||
|
- Tray: rendered from logo.svg for menu bar / notification area
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
# ── Ensure cairo library is discoverable on macOS (Homebrew) ──────────
|
||||||
|
for _lib_dir in ("/opt/homebrew/opt/cairo/lib", "/usr/local/opt/cairo/lib"):
|
||||||
|
if Path(_lib_dir).exists():
|
||||||
|
os.environ["DYLD_LIBRARY_PATH"] = (
|
||||||
|
_lib_dir + ":" + os.environ.get("DYLD_LIBRARY_PATH", "")
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
import cairosvg # noqa: E402 — must come after DYLD_LIBRARY_PATH setup
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
ASSETS = ROOT / "assets"
|
ASSETS = ROOT / "assets"
|
||||||
|
LANDING = ROOT / "landing" / "public"
|
||||||
ICONSET = ASSETS / "icon.iconset"
|
ICONSET = ASSETS / "icon.iconset"
|
||||||
PNG_PATH = ASSETS / "icon.png"
|
LOGO_SVG = ROOT / "logo.svg"
|
||||||
ICO_PATH = ASSETS / "icon.ico"
|
|
||||||
ICNS_PATH = ASSETS / "icon.icns"
|
# SVG sources
|
||||||
SVG_PATH = ASSETS / "icon.svg"
|
WEB_SVG = ASSETS / "icon.svg" # circle bg — web favicon
|
||||||
|
APP_SVG = ASSETS / "icon-app.svg" # square bg — Electron (Apple HIG)
|
||||||
|
|
||||||
SIZE = 1024
|
SIZE = 1024
|
||||||
|
|
||||||
# ── Brand palette ──────────────────────────────────────────────────────
|
|
||||||
BG_COLOR = (25, 25, 25) # #191919
|
|
||||||
LOGO_COLOR = (251, 250, 245) # #FBFAF5
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def build_icon() -> Image.Image:
|
def svg_to_image(svg_path: Path, width: int, height: int | None = None) -> Image.Image:
|
||||||
"""Build the app icon by rendering icon.svg via cairosvg, or fallback to Pillow."""
|
"""Render an SVG to a Pillow RGBA Image at the given size."""
|
||||||
# Try cairosvg first (best quality)
|
h = height or width
|
||||||
try:
|
png_data = cairosvg.svg2png(
|
||||||
import cairosvg
|
url=str(svg_path), output_width=width, output_height=h,
|
||||||
svg_data = SVG_PATH.read_bytes()
|
|
||||||
png_data = cairosvg.svg2png(bytestring=svg_data, output_width=SIZE, output_height=SIZE)
|
|
||||||
import io
|
|
||||||
return Image.open(io.BytesIO(png_data)).convert("RGBA")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback: draw programmatically with Pillow
|
|
||||||
icon = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(icon)
|
|
||||||
|
|
||||||
# Circle background
|
|
||||||
margin = SIZE // 2 - 480
|
|
||||||
draw.ellipse(
|
|
||||||
(margin, margin, SIZE - margin, SIZE - margin),
|
|
||||||
fill=(*BG_COLOR, 255),
|
|
||||||
)
|
)
|
||||||
|
return Image.open(io.BytesIO(png_data)).convert("RGBA")
|
||||||
|
|
||||||
# Draw the "A" monogram — same geometry as the old icon but in new color
|
|
||||||
cx = SIZE // 2
|
|
||||||
apex_y = 200
|
|
||||||
base_y = 800
|
|
||||||
leg_w = 64
|
|
||||||
base_half = 250
|
|
||||||
fill = (*LOGO_COLOR, 245)
|
|
||||||
|
|
||||||
def leg_x(y: int) -> tuple[float, float]:
|
def svg_bytes_to_image(svg_bytes: bytes, width: int, height: int | None = None) -> Image.Image:
|
||||||
t = (y - apex_y) / (base_y - apex_y)
|
"""Render SVG bytes to a Pillow RGBA Image."""
|
||||||
spread = base_half * t
|
h = height or width
|
||||||
return (cx - spread, cx + spread)
|
png_data = cairosvg.svg2png(
|
||||||
|
bytestring=svg_bytes, output_width=width, output_height=h,
|
||||||
ll_top, _ = leg_x(apex_y)
|
|
||||||
ll_bot, _ = leg_x(base_y)
|
|
||||||
_, lr_top = leg_x(apex_y)
|
|
||||||
_, lr_bot = leg_x(base_y)
|
|
||||||
|
|
||||||
left_leg = [
|
|
||||||
(ll_top - leg_w * 0.35, apex_y),
|
|
||||||
(ll_top + leg_w * 0.35, apex_y),
|
|
||||||
(ll_bot + leg_w, base_y),
|
|
||||||
(ll_bot - leg_w, base_y),
|
|
||||||
]
|
|
||||||
right_leg = [
|
|
||||||
(lr_top - leg_w * 0.35, apex_y),
|
|
||||||
(lr_top + leg_w * 0.35, apex_y),
|
|
||||||
(lr_bot + leg_w, base_y),
|
|
||||||
(lr_bot - leg_w, base_y),
|
|
||||||
]
|
|
||||||
|
|
||||||
draw.polygon(left_leg, fill=fill)
|
|
||||||
draw.polygon(right_leg, fill=fill)
|
|
||||||
|
|
||||||
# Crossbars — all same cream color with varying opacity for hierarchy
|
|
||||||
bar_h = 42
|
|
||||||
bar_r = 12
|
|
||||||
bar_pad = 14
|
|
||||||
|
|
||||||
tiers = [
|
|
||||||
(440, (*LOGO_COLOR, 255)),
|
|
||||||
(545, (*LOGO_COLOR, 210)),
|
|
||||||
(650, (*LOGO_COLOR, 175)),
|
|
||||||
]
|
|
||||||
|
|
||||||
for bar_cy, bar_fill in tiers:
|
|
||||||
left_cx, right_cx = leg_x(bar_cy)
|
|
||||||
bx0 = int(left_cx + bar_pad)
|
|
||||||
bx1 = int(right_cx - bar_pad)
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(bx0, bar_cy - bar_h // 2, bx1, bar_cy + bar_h // 2),
|
|
||||||
radius=bar_r,
|
|
||||||
fill=bar_fill,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apex beacon
|
|
||||||
dot_r = 20
|
|
||||||
dot_cy = apex_y - 2
|
|
||||||
draw.ellipse(
|
|
||||||
(cx - dot_r, dot_cy - dot_r, cx + dot_r, dot_cy + dot_r),
|
|
||||||
fill=(*LOGO_COLOR, 255),
|
|
||||||
)
|
)
|
||||||
|
return Image.open(io.BytesIO(png_data)).convert("RGBA")
|
||||||
return icon
|
|
||||||
|
|
||||||
|
|
||||||
def build_tray_icons() -> None:
|
# ── Web icons (circle background) ────────────────────────────────────
|
||||||
"""Generate tray/notification area icons for all platforms."""
|
|
||||||
# Color tray icon (Windows/Linux) — 32x32 and 64x64
|
|
||||||
for name, size in [("tray-icon.png", 32), ("tray-icon@2x.png", 64)]:
|
|
||||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
margin = 1
|
|
||||||
draw.ellipse((margin, margin, size - margin, size - margin), fill=(*BG_COLOR, 255))
|
|
||||||
|
|
||||||
# Simple A shape scaled to tray size
|
def generate_web_icons() -> None:
|
||||||
cx = size // 2
|
"""Generate favicon.ico and icon.png for the landing site (circle bg)."""
|
||||||
top_y = int(size * 0.18)
|
print("Generating web icons from icon.svg (circle bg)…")
|
||||||
bot_y = int(size * 0.82)
|
master = svg_to_image(WEB_SVG, SIZE)
|
||||||
spread = int(size * 0.28)
|
|
||||||
w = max(2, int(size * 0.06))
|
|
||||||
|
|
||||||
draw.polygon([
|
# PNG for apple-touch-icon / og
|
||||||
(cx - w, top_y), (cx + w, top_y),
|
web_png = LANDING / "icon.png"
|
||||||
(cx + spread + w, bot_y), (cx + spread - w, bot_y),
|
master.save(web_png, format="PNG")
|
||||||
], fill=(*LOGO_COLOR, 240))
|
print(f" → {web_png}")
|
||||||
draw.polygon([
|
|
||||||
(cx - w, top_y), (cx + w, top_y),
|
|
||||||
(cx - spread + w, bot_y), (cx - spread - w, bot_y),
|
|
||||||
], fill=(*LOGO_COLOR, 240))
|
|
||||||
|
|
||||||
# Crossbar
|
# Copy SVG to landing
|
||||||
bar_y = int(size * 0.52)
|
shutil.copy2(WEB_SVG, LANDING / "icon.svg")
|
||||||
bar_h = max(2, int(size * 0.06))
|
print(f" → {LANDING / 'icon.svg'}")
|
||||||
bar_left = cx - int(spread * 0.55)
|
|
||||||
bar_right = cx + int(spread * 0.55)
|
|
||||||
draw.rectangle((bar_left, bar_y, bar_right, bar_y + bar_h), fill=(*LOGO_COLOR, 255))
|
|
||||||
|
|
||||||
img.save(ASSETS / name, format="PNG")
|
# ICO with multiple sizes for favicon
|
||||||
|
favicon = LANDING / "favicon.ico"
|
||||||
# macOS template icons (monochrome black + alpha)
|
master.save(
|
||||||
for name, size in [("tray-iconTemplate.png", 22), ("tray-iconTemplate@2x.png", 44)]:
|
favicon, format="ICO",
|
||||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
cx = size // 2
|
|
||||||
top_y = int(size * 0.15)
|
|
||||||
bot_y = int(size * 0.85)
|
|
||||||
spread = int(size * 0.3)
|
|
||||||
w = max(1, int(size * 0.06))
|
|
||||||
|
|
||||||
draw.polygon([
|
|
||||||
(cx - w, top_y), (cx + w, top_y),
|
|
||||||
(cx + spread + w, bot_y), (cx + spread - w, bot_y),
|
|
||||||
], fill=(0, 0, 0, 220))
|
|
||||||
draw.polygon([
|
|
||||||
(cx - w, top_y), (cx + w, top_y),
|
|
||||||
(cx - spread + w, bot_y), (cx - spread - w, bot_y),
|
|
||||||
], fill=(0, 0, 0, 220))
|
|
||||||
|
|
||||||
bar_y = int(size * 0.52)
|
|
||||||
bar_h = max(1, int(size * 0.06))
|
|
||||||
bar_left = cx - int(spread * 0.55)
|
|
||||||
bar_right = cx + int(spread * 0.55)
|
|
||||||
draw.rectangle((bar_left, bar_y, bar_right, bar_y + bar_h), fill=(0, 0, 0, 240))
|
|
||||||
|
|
||||||
img.save(ASSETS / name, format="PNG")
|
|
||||||
|
|
||||||
|
|
||||||
def save_png_and_ico(image: Image.Image) -> None:
|
|
||||||
ASSETS.mkdir(parents=True, exist_ok=True)
|
|
||||||
image.save(PNG_PATH, format="PNG")
|
|
||||||
image.save(
|
|
||||||
ICO_PATH,
|
|
||||||
format="ICO",
|
|
||||||
sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
|
sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
|
||||||
)
|
)
|
||||||
|
print(f" → {favicon}")
|
||||||
|
|
||||||
|
|
||||||
def save_icns(image: Image.Image) -> None:
|
# ── App icons (square background, Apple HIG) ─────────────────────────
|
||||||
|
|
||||||
|
def generate_app_icons() -> None:
|
||||||
|
"""Generate Electron app icons: PNG, ICO, ICNS (square bg, Apple HIG)."""
|
||||||
|
print("Generating app icons from icon-app.svg (square bg, Apple HIG)…")
|
||||||
|
master = svg_to_image(APP_SVG, SIZE)
|
||||||
|
|
||||||
|
# Main PNG
|
||||||
|
png_path = ASSETS / "icon.png"
|
||||||
|
master.save(png_path, format="PNG")
|
||||||
|
print(f" → {png_path}")
|
||||||
|
|
||||||
|
# Windows ICO
|
||||||
|
ico_path = ASSETS / "icon.ico"
|
||||||
|
master.save(
|
||||||
|
ico_path, format="ICO",
|
||||||
|
sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
|
||||||
|
)
|
||||||
|
print(f" → {ico_path}")
|
||||||
|
|
||||||
|
# macOS ICNS via iconutil
|
||||||
|
generate_icns(master)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_icns(master: Image.Image) -> None:
|
||||||
|
"""Generate .icns from a master image using macOS iconutil."""
|
||||||
if ICONSET.exists():
|
if ICONSET.exists():
|
||||||
shutil.rmtree(ICONSET)
|
shutil.rmtree(ICONSET)
|
||||||
ICONSET.mkdir(parents=True, exist_ok=True)
|
ICONSET.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
for size in (16, 32, 64, 128, 256, 512):
|
for size in (16, 32, 64, 128, 256, 512):
|
||||||
image.resize((size, size), Image.Resampling.LANCZOS).save(
|
master.resize((size, size), Image.Resampling.LANCZOS).save(
|
||||||
ICONSET / f"icon_{size}x{size}.png", format="PNG",
|
ICONSET / f"icon_{size}x{size}.png", format="PNG",
|
||||||
)
|
)
|
||||||
image.resize((size * 2, size * 2), Image.Resampling.LANCZOS).save(
|
master.resize((size * 2, size * 2), Image.Resampling.LANCZOS).save(
|
||||||
ICONSET / f"icon_{size}x{size}@2x.png", format="PNG",
|
ICONSET / f"icon_{size}x{size}@2x.png", format="PNG",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
icns_path = ASSETS / "icon.icns"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["iconutil", "-c", "icns", str(ICONSET), "-o", str(ICNS_PATH)],
|
["iconutil", "-c", "icns", str(ICONSET), "-o", str(icns_path)],
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
shutil.rmtree(ICONSET)
|
shutil.rmtree(ICONSET)
|
||||||
|
print(f" → {icns_path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tray / notification area icons ───────────────────────────────────
|
||||||
|
|
||||||
|
def generate_tray_icons() -> None:
|
||||||
|
"""Generate tray icons following Apple HIG for menu bar.
|
||||||
|
|
||||||
|
- Color (Windows/Linux): square bg with logo, 32×32 and 64×64
|
||||||
|
- Template (macOS): monochrome black logo on transparent, 22×22 and 44×44
|
||||||
|
"""
|
||||||
|
print("Generating tray icons…")
|
||||||
|
|
||||||
|
# Color tray icons — render app icon (square bg) at small sizes
|
||||||
|
for name, size in [("tray-icon.png", 32), ("tray-icon@2x.png", 64)]:
|
||||||
|
img = svg_to_image(APP_SVG, size)
|
||||||
|
img.save(ASSETS / name, format="PNG")
|
||||||
|
print(f" → {ASSETS / name}")
|
||||||
|
|
||||||
|
# macOS template icons — black logo silhouette on transparent
|
||||||
|
# Read logo.svg paths, render as black on transparent
|
||||||
|
logo_svg = LOGO_SVG.read_text()
|
||||||
|
|
||||||
|
for name, size in [("tray-iconTemplate.png", 22), ("tray-iconTemplate@2x.png", 44)]:
|
||||||
|
# Create SVG with black fill, sized to target
|
||||||
|
template_svg = _make_template_svg(logo_svg, size)
|
||||||
|
img = svg_bytes_to_image(template_svg.encode("utf-8"), size)
|
||||||
|
img.save(ASSETS / name, format="PNG")
|
||||||
|
print(f" → {ASSETS / name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_template_svg(logo_svg_content: str, target_size: int) -> str:
|
||||||
|
"""Create a macOS template SVG: black logo silhouette on transparent bg."""
|
||||||
|
# Logo viewBox is 0 0 122 126 — scale to fit target with padding
|
||||||
|
padding = max(2, int(target_size * 0.1))
|
||||||
|
available = target_size - 2 * padding
|
||||||
|
# Scale to fit height (taller dimension)
|
||||||
|
scale = available / 126
|
||||||
|
logo_w = 122 * scale
|
||||||
|
# Center horizontally
|
||||||
|
offset_x = (target_size - logo_w) / 2
|
||||||
|
offset_y = padding
|
||||||
|
|
||||||
|
return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {target_size} {target_size}" fill="none">
|
||||||
|
<g transform="translate({offset_x:.1f} {offset_y:.1f}) scale({scale:.4f})">
|
||||||
|
<path fill="#000000" d="M 76.604813 111.661926 C 65.070183 111.662621 53.987053 111.662621 42.757744 111.662621 C 45.2239 101.998177 45.297546 101.949265 53.830105 102.036659 C 57.279823 102.071999 60.730202 102.04248 64.259201 102.04248 C 62.159458 93.316338 61.799103 93.009819 51.340881 91.341682 C 59.312901 73.847687 67.193756 56.553749 75.587578 38.134155 C 86.933441 62.965954 97.804642 86.758881 108.711754 110.630394 C 100.707413 111.871689 92.355965 107.246559 88.107536 98.852119 C 84.207748 91.146538 80.800591 83.191696 77.172813 75.348343 C 76.340195 73.54818 75.492958 71.754791 74.614624 69.877441 C 69.107384 75.691788 68.008286 81.475128 71.005882 88.088753 C 74.413452 95.606903 77.813919 103.128281 81.452705 111.167984 C 79.55114 111.381325 78.303734 111.521271 76.604813 111.661926 Z"/>
|
||||||
|
<path fill="#000000" d="M 26.805992 87.743256 C 38.616821 61.830872 50.261688 36.260269 62.470131 9.452141 C 65.732315 17.818909 71.145126 23.524567 66.258224 33.086182 C 55.518253 54.099815 46.339973 75.911873 36.533691 97.402336 C 32.699284 105.805443 26.495394 110.823158 16.012421 111.410957 C 19.72117 103.270836 23.180601 95.67794 26.805992 87.743256 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ASSETS.mkdir(parents=True, exist_ok=True)
|
ASSETS.mkdir(parents=True, exist_ok=True)
|
||||||
image = build_icon()
|
LANDING.mkdir(parents=True, exist_ok=True)
|
||||||
save_png_and_ico(image)
|
|
||||||
save_icns(image)
|
generate_web_icons()
|
||||||
build_tray_icons()
|
generate_app_icons()
|
||||||
print(f"Generated: {PNG_PATH}, {ICO_PATH}, {ICNS_PATH}")
|
generate_tray_icons()
|
||||||
print(f"Generated tray icons in {ASSETS}")
|
|
||||||
|
print("\nDone! All icons generated.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface AutomationDependencies {
|
|||||||
notify: (options: NotificationOptions) => void;
|
notify: (options: NotificationOptions) => void;
|
||||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||||
dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>;
|
dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>;
|
||||||
|
isQuitting: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RunFinishedEvent {
|
interface RunFinishedEvent {
|
||||||
@@ -86,9 +87,11 @@ export function createAutomationService({
|
|||||||
notify,
|
notify,
|
||||||
wakeAgentIfPossible,
|
wakeAgentIfPossible,
|
||||||
dispatchBrowserAction,
|
dispatchBrowserAction,
|
||||||
|
isQuitting,
|
||||||
}: AutomationDependencies) {
|
}: AutomationDependencies) {
|
||||||
const activeAutomationExecutions = new Set<string>();
|
const activeAutomationExecutions = new Set<string>();
|
||||||
const activeWorkflowTriggerExecutions = new Set<string>();
|
const activeWorkflowTriggerExecutions = new Set<string>();
|
||||||
|
const pendingScheduledTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] {
|
function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] {
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
@@ -642,6 +645,7 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dispatchAutomation(trigger: string, ctx: AutomationEventContext) {
|
function dispatchAutomation(trigger: string, ctx: AutomationEventContext) {
|
||||||
|
if (isQuitting()) return;
|
||||||
evaluateAutomationRules(trigger, ctx);
|
evaluateAutomationRules(trigger, ctx);
|
||||||
evaluateWorkflowTriggers(trigger, ctx);
|
evaluateWorkflowTriggers(trigger, ctx);
|
||||||
}
|
}
|
||||||
@@ -859,6 +863,7 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processRunCompletion(run: RunRecord, event: RunFinishedEvent) {
|
function processRunCompletion(run: RunRecord, event: RunFinishedEvent) {
|
||||||
|
if (isQuitting()) return;
|
||||||
let task: ReturnType<typeof db.getTask> | null = null;
|
let task: ReturnType<typeof db.getTask> | null = null;
|
||||||
try {
|
try {
|
||||||
task = db.getTask(run.taskId);
|
task = db.getTask(run.taskId);
|
||||||
@@ -1032,6 +1037,7 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleApprovalResolved(approvalId: string, companyId: string, decision: string) {
|
function handleApprovalResolved(approvalId: string, companyId: string, decision: string) {
|
||||||
|
if (isQuitting()) return;
|
||||||
const snapshot = db.listSnapshot();
|
const snapshot = db.listSnapshot();
|
||||||
const approval = snapshot.approvals.find((entry) => entry.id === approvalId);
|
const approval = snapshot.approvals.find((entry) => entry.id === approvalId);
|
||||||
if (!approval) return;
|
if (!approval) return;
|
||||||
@@ -1168,7 +1174,12 @@ export function createAutomationService({
|
|||||||
if (draft.scheduledAt) {
|
if (draft.scheduledAt) {
|
||||||
const delay = new Date(draft.scheduledAt).getTime() - Date.now();
|
const delay = new Date(draft.scheduledAt).getTime() - Date.now();
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
setTimeout(dispatchSocialAction, delay);
|
const timer = setTimeout(() => {
|
||||||
|
pendingScheduledTimers.delete(timer);
|
||||||
|
if (isQuitting()) return;
|
||||||
|
dispatchSocialAction();
|
||||||
|
}, delay);
|
||||||
|
pendingScheduledTimers.add(timer);
|
||||||
} else {
|
} else {
|
||||||
dispatchSocialAction();
|
dispatchSocialAction();
|
||||||
}
|
}
|
||||||
@@ -1229,6 +1240,7 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string) {
|
function handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string) {
|
||||||
|
if (isQuitting()) return;
|
||||||
if (previousStatus === newStatus) return;
|
if (previousStatus === newStatus) return;
|
||||||
|
|
||||||
let task: ReturnType<typeof db.getTask> | null = null;
|
let task: ReturnType<typeof db.getTask> | null = null;
|
||||||
@@ -1298,6 +1310,7 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTaskCreated(taskId: string, companyId: string) {
|
function handleTaskCreated(taskId: string, companyId: string) {
|
||||||
|
if (isQuitting()) return;
|
||||||
let task: ReturnType<typeof db.getTask> | null = null;
|
let task: ReturnType<typeof db.getTask> | null = null;
|
||||||
try {
|
try {
|
||||||
task = db.getTask(taskId);
|
task = db.getTask(taskId);
|
||||||
@@ -1322,6 +1335,13 @@ export function createAutomationService({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
for (const timer of pendingScheduledTimers) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
pendingScheduledTimers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
runWorkflowPipeline,
|
runWorkflowPipeline,
|
||||||
dispatchAutomation,
|
dispatchAutomation,
|
||||||
@@ -1329,5 +1349,6 @@ export function createAutomationService({
|
|||||||
handleApprovalResolved,
|
handleApprovalResolved,
|
||||||
handleTaskStatusChange,
|
handleTaskStatusChange,
|
||||||
handleTaskCreated,
|
handleTaskCreated,
|
||||||
|
destroy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -192,6 +192,7 @@ export async function bootstrapApplication({
|
|||||||
// Re-register timers for scheduled social posts (survives app restart).
|
// Re-register timers for scheduled social posts (survives app restart).
|
||||||
// Scan all companies for queued browser actions with a future scheduledAt time
|
// Scan all companies for queued browser actions with a future scheduledAt time
|
||||||
// and set up setTimeout to dispatch them at the right moment.
|
// and set up setTimeout to dispatch them at the right moment.
|
||||||
|
const scheduledPostTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
try {
|
try {
|
||||||
const snapshot = db.listSnapshot();
|
const snapshot = db.listSnapshot();
|
||||||
for (const company of snapshot.companies) {
|
for (const company of snapshot.companies) {
|
||||||
@@ -203,12 +204,15 @@ export async function bootstrapApplication({
|
|||||||
if (payload.scheduledAt) {
|
if (payload.scheduledAt) {
|
||||||
const delay = new Date(payload.scheduledAt).getTime() - Date.now();
|
const delay = new Date(payload.scheduledAt).getTime() - Date.now();
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const browserActions = serviceGraph.getBrowserActionService();
|
scheduledPostTimers.delete(timer);
|
||||||
|
if (isQuittingRef.current) return;
|
||||||
|
const browserActions = serviceGraph?.getBrowserActionService();
|
||||||
if (browserActions) {
|
if (browserActions) {
|
||||||
void browserActions.dispatchBrowserAction(action.id, company.id);
|
void browserActions.dispatchBrowserAction(action.id, company.id);
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
|
scheduledPostTimers.add(timer);
|
||||||
log.info(`[bootstrap] Re-registered scheduled post timer: action ${action.id} in ${Math.round(delay / 1000)}s`);
|
log.info(`[bootstrap] Re-registered scheduled post timer: action ${action.id} in ${Math.round(delay / 1000)}s`);
|
||||||
}
|
}
|
||||||
// If delay <= 0, the action is overdue -- normal dispatch cycle will pick it up
|
// If delay <= 0, the action is overdue -- normal dispatch cycle will pick it up
|
||||||
@@ -221,6 +225,16 @@ export async function bootstrapApplication({
|
|||||||
log.warn(`[bootstrap] Failed to re-register scheduled post timers: ${error instanceof Error ? error.message : String(error)}`);
|
log.warn(`[bootstrap] Failed to re-register scheduled post timers: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register cleanup for bootstrap-owned resources during quit.
|
||||||
|
// This runs BEFORE service-graph's onBeforeQuit (which is registered later).
|
||||||
|
app.on("before-quit", () => {
|
||||||
|
for (const timer of scheduledPostTimers) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
scheduledPostTimers.clear();
|
||||||
|
notificationService.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
// Show the window immediately so the user sees the app launch without delay.
|
// Show the window immediately so the user sees the app launch without delay.
|
||||||
await serviceGraph.getDesktopShellService()?.ensureWindow();
|
await serviceGraph.getDesktopShellService()?.ensureWindow();
|
||||||
serviceGraph.getAppLifecycleService()?.register(hasSingleInstanceLock);
|
serviceGraph.getAppLifecycleService()?.register(hasSingleInstanceLock);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface DesktopShellDependencies {
|
|||||||
onBackupProfile: () => Promise<void>;
|
onBackupProfile: () => Promise<void>;
|
||||||
onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void;
|
onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void;
|
||||||
notify: (options: NotificationOptions) => void;
|
notify: (options: NotificationOptions) => void;
|
||||||
|
getActiveRunCount: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSocialBrowserSession(contents: Electron.WebContents) {
|
function isSocialBrowserSession(contents: Electron.WebContents) {
|
||||||
@@ -55,11 +56,91 @@ export function createDesktopShellService({
|
|||||||
onBackupProfile,
|
onBackupProfile,
|
||||||
onShortcut,
|
onShortcut,
|
||||||
notify,
|
notify,
|
||||||
|
getActiveRunCount,
|
||||||
}: DesktopShellDependencies) {
|
}: DesktopShellDependencies) {
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let appTray: Tray | null = null;
|
let appTray: Tray | null = null;
|
||||||
let backgroundCloseNoticeShown = false;
|
let backgroundCloseNoticeShown = false;
|
||||||
let windowCreationPromise: Promise<BrowserWindow | null> | null = null;
|
let windowCreationPromise: Promise<BrowserWindow | null> | null = null;
|
||||||
|
let quitDialogShowing = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quit confirmation gate — shown when agents are actively running.
|
||||||
|
* Gives the user three choices:
|
||||||
|
* 0 = "Stop All & Quit" → terminate all agents and exit
|
||||||
|
* 1 = "Minimize to Background" → hide window, agents keep running
|
||||||
|
* 2 = "Cancel" → do nothing
|
||||||
|
*
|
||||||
|
* When no agents are active the app quits immediately without a dialog.
|
||||||
|
*/
|
||||||
|
async function requestQuitWithConfirmation() {
|
||||||
|
if (quitDialogShowing) return;
|
||||||
|
|
||||||
|
let activeRunCount = 0;
|
||||||
|
try {
|
||||||
|
activeRunCount = getActiveRunCount();
|
||||||
|
} catch {
|
||||||
|
// Cannot determine active runs — proceed with immediate quit as fail-safe
|
||||||
|
onRequestQuit();
|
||||||
|
app.quit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeRunCount === 0) {
|
||||||
|
onRequestQuit();
|
||||||
|
app.quit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
quitDialogShowing = true;
|
||||||
|
try {
|
||||||
|
const message = activeRunCount === 1
|
||||||
|
? "1 agent run is currently active."
|
||||||
|
: `${activeRunCount} agent runs are currently active.`;
|
||||||
|
|
||||||
|
const options: Electron.MessageBoxOptions = {
|
||||||
|
type: "question",
|
||||||
|
buttons: ["Stop All & Quit", "Minimize to Background", "Cancel"],
|
||||||
|
defaultId: 2,
|
||||||
|
cancelId: 2,
|
||||||
|
title: "AgentCompany",
|
||||||
|
message,
|
||||||
|
detail: "Quitting will stop all running agents and their tasks. You can also minimize to keep agents running in the background.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
|
||||||
|
const result = parentWindow
|
||||||
|
? await dialog.showMessageBox(parentWindow, options)
|
||||||
|
: await dialog.showMessageBox(options);
|
||||||
|
|
||||||
|
if (result.response === 0) {
|
||||||
|
// Stop All & Quit
|
||||||
|
onRequestQuit();
|
||||||
|
app.quit();
|
||||||
|
} else if (result.response === 1) {
|
||||||
|
// Minimize to Background
|
||||||
|
if (parentWindow) {
|
||||||
|
parentWindow.hide();
|
||||||
|
}
|
||||||
|
if (!backgroundCloseNoticeShown) {
|
||||||
|
backgroundCloseNoticeShown = true;
|
||||||
|
notify({
|
||||||
|
title: "Running in Background",
|
||||||
|
body: "AgentCompany is still running in the system tray",
|
||||||
|
urgency: "informational",
|
||||||
|
navigation: { section: "overview" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cancel: do nothing
|
||||||
|
} catch {
|
||||||
|
// Dialog failed (e.g. window destroyed mid-flight) — quit as fail-safe
|
||||||
|
onRequestQuit();
|
||||||
|
app.quit();
|
||||||
|
} finally {
|
||||||
|
quitDialogShowing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getMainWindow() {
|
function getMainWindow() {
|
||||||
return mainWindow;
|
return mainWindow;
|
||||||
@@ -195,7 +276,11 @@ export function createDesktopShellService({
|
|||||||
{ role: "hideOthers" },
|
{ role: "hideOthers" },
|
||||||
{ role: "unhide" },
|
{ role: "unhide" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "quit" },
|
{
|
||||||
|
label: `Quit ${app.name}`,
|
||||||
|
accelerator: "CmdOrCtrl+Q",
|
||||||
|
click: () => void requestQuitWithConfirmation(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const template: Electron.MenuItemConstructorOptions[] = [
|
const template: Electron.MenuItemConstructorOptions[] = [
|
||||||
@@ -222,7 +307,14 @@ export function createDesktopShellService({
|
|||||||
await onBackupProfile();
|
await onBackupProfile();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(process.platform === "darwin" ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
|
...(process.platform === "darwin" ? [] : [
|
||||||
|
{ type: "separator" as const },
|
||||||
|
{
|
||||||
|
label: "Quit",
|
||||||
|
accelerator: "Alt+F4",
|
||||||
|
click: () => void requestQuitWithConfirmation(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -333,10 +425,7 @@ export function createDesktopShellService({
|
|||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: "Quit",
|
label: "Quit",
|
||||||
click: () => {
|
click: () => void requestQuitWithConfirmation(),
|
||||||
onRequestQuit();
|
|
||||||
app.quit();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { delimiter as pathDelimiter, join } from "node:path";
|
import { delimiter as pathDelimiter, join } from "node:path";
|
||||||
import { app, nativeImage } from "electron";
|
import { app } from "electron";
|
||||||
|
|
||||||
interface HostEnvironmentDependencies {
|
interface HostEnvironmentDependencies {
|
||||||
iconPath: string;
|
iconPath: string;
|
||||||
@@ -91,10 +91,7 @@ export async function prepareHostEnvironment({
|
|||||||
logger.info("[path] Skipping interactive shell PATH probe; using deterministic PATH setup only.");
|
logger.info("[path] Skipping interactive shell PATH probe; using deterministic PATH setup only.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const appIcon = nativeImage.createFromPath(iconPath);
|
// Dock icon: macOS uses the .icns from the app bundle with the system squircle
|
||||||
if (!appIcon.isEmpty()) {
|
// mask applied automatically. Do not call dock.setIcon() — it bypasses the mask
|
||||||
if (process.platform === "darwin" && app.dock) {
|
// and renders the raw PNG oversized and square.
|
||||||
app.dock.setIcon(appIcon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,7 @@ interface OrchestrationDependencies {
|
|||||||
dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void;
|
dispatchAutomation: (trigger: string, ctx: AutomationDispatch) => void;
|
||||||
hasWorkerReady: () => boolean;
|
hasWorkerReady: () => boolean;
|
||||||
enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
|
enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
|
||||||
|
isQuitting: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOrchestrationService({
|
export function createOrchestrationService({
|
||||||
@@ -72,6 +73,7 @@ export function createOrchestrationService({
|
|||||||
dispatchAutomation,
|
dispatchAutomation,
|
||||||
hasWorkerReady,
|
hasWorkerReady,
|
||||||
enqueueWorkerRun,
|
enqueueWorkerRun,
|
||||||
|
isQuitting,
|
||||||
}: OrchestrationDependencies) {
|
}: OrchestrationDependencies) {
|
||||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
const scheduleLastFiredMap = new Map<string, number>();
|
const scheduleLastFiredMap = new Map<string, number>();
|
||||||
@@ -182,6 +184,9 @@ export function createOrchestrationService({
|
|||||||
trigger: string;
|
trigger: string;
|
||||||
}) {
|
}) {
|
||||||
const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params;
|
const { runId, companyId, agent, task, connector, workspace, workspaceMode, companyName, companyDescription, snapshot, trigger } = params;
|
||||||
|
if (isQuitting()) {
|
||||||
|
throw new Error("Application is shutting down — refusing to dispatch new run.");
|
||||||
|
}
|
||||||
if (!hasWorkerReady()) {
|
if (!hasWorkerReady()) {
|
||||||
throw new Error("Orchestration worker is not ready.");
|
throw new Error("Orchestration worker is not ready.");
|
||||||
}
|
}
|
||||||
@@ -311,6 +316,7 @@ export function createOrchestrationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null {
|
function startAgentHeartbeatRun(agentId: string, companyId: string, trigger: string): string | null {
|
||||||
|
if (isQuitting()) return null;
|
||||||
if (!hasWorkerReady()) return null;
|
if (!hasWorkerReady()) return null;
|
||||||
const companyStatus = db.getCompanyStatus(companyId);
|
const companyStatus = db.getCompanyStatus(companyId);
|
||||||
if (companyStatus !== "active") {
|
if (companyStatus !== "active") {
|
||||||
@@ -448,7 +454,7 @@ export function createOrchestrationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) {
|
function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) {
|
||||||
if (!agentId) return null;
|
if (!agentId || isQuitting()) return null;
|
||||||
try {
|
try {
|
||||||
const agent = db.getAgent(agentId);
|
const agent = db.getAgent(agentId);
|
||||||
if (agent.companyId !== companyId) {
|
if (agent.companyId !== companyId) {
|
||||||
@@ -473,6 +479,9 @@ export function createOrchestrationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function queueTaskRun(taskId: string, trigger: string, actor: string) {
|
function queueTaskRun(taskId: string, trigger: string, actor: string) {
|
||||||
|
if (isQuitting()) {
|
||||||
|
return fail("APP_SHUTTING_DOWN", "Application is shutting down.");
|
||||||
|
}
|
||||||
if (!hasWorkerReady()) {
|
if (!hasWorkerReady()) {
|
||||||
logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`);
|
logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`);
|
||||||
return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready.");
|
return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready.");
|
||||||
@@ -556,7 +565,7 @@ export function createOrchestrationService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resumeQueuedRuns() {
|
function resumeQueuedRuns() {
|
||||||
if (!hasWorkerReady()) {
|
if (isQuitting() || !hasWorkerReady()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const snapshot = db.listSnapshot();
|
const snapshot = db.listSnapshot();
|
||||||
@@ -610,7 +619,7 @@ export function createOrchestrationService({
|
|||||||
|
|
||||||
heartbeatTimer = setInterval(() => {
|
heartbeatTimer = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
if (!hasWorkerReady()) return;
|
if (isQuitting() || !hasWorkerReady()) return;
|
||||||
const activeCompanies = db.listActiveCompanyIds();
|
const activeCompanies = db.listActiveCompanyIds();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,14 @@ export function createServiceGraph({
|
|||||||
},
|
},
|
||||||
onShortcut: (action) => emitEvent({ type: "shortcut", action }),
|
onShortcut: (action) => emitEvent({ type: "shortcut", action }),
|
||||||
notify,
|
notify,
|
||||||
|
getActiveRunCount: () => {
|
||||||
|
try {
|
||||||
|
const snapshot = db.listSnapshot();
|
||||||
|
return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
desktopShellService.initialize();
|
desktopShellService.initialize();
|
||||||
|
|
||||||
@@ -169,6 +177,7 @@ export function createServiceGraph({
|
|||||||
enqueueWorkerRun: (payload) => {
|
enqueueWorkerRun: (payload) => {
|
||||||
workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload });
|
workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload });
|
||||||
},
|
},
|
||||||
|
isQuitting: () => isQuittingRef.current,
|
||||||
});
|
});
|
||||||
workerService.createWorker();
|
workerService.createWorker();
|
||||||
|
|
||||||
@@ -180,6 +189,7 @@ export function createServiceGraph({
|
|||||||
notify,
|
notify,
|
||||||
wakeAgentIfPossible,
|
wakeAgentIfPossible,
|
||||||
dispatchBrowserAction,
|
dispatchBrowserAction,
|
||||||
|
isQuitting: () => isQuittingRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
onboardingService = createOnboardingService({
|
onboardingService = createOnboardingService({
|
||||||
@@ -210,6 +220,7 @@ export function createServiceGraph({
|
|||||||
onBeforeQuit: () => {
|
onBeforeQuit: () => {
|
||||||
isQuittingRef.current = true;
|
isQuittingRef.current = true;
|
||||||
orchestrationService?.stopHeartbeatScheduler();
|
orchestrationService?.stopHeartbeatScheduler();
|
||||||
|
automationService?.destroy();
|
||||||
apiServerRef.current?.stop();
|
apiServerRef.current?.stop();
|
||||||
apiServerRef.current = null;
|
apiServerRef.current = null;
|
||||||
browserManagerRef.current?.shutdown();
|
browserManagerRef.current?.shutdown();
|
||||||
|
|||||||
@@ -83,8 +83,11 @@ export function createWorkerService({
|
|||||||
const MAX_CONSECUTIVE_WAKES = 5;
|
const MAX_CONSECUTIVE_WAKES = 5;
|
||||||
|
|
||||||
let worker: ReturnType<typeof utilityProcess.fork> | null = null;
|
let worker: ReturnType<typeof utilityProcess.fork> | null = null;
|
||||||
|
let destroyed = false;
|
||||||
|
const pendingRetryTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
function getWorker() {
|
function getWorker() {
|
||||||
|
if (destroyed) return null;
|
||||||
return worker;
|
return worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +101,7 @@ export function createWorkerService({
|
|||||||
worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`));
|
worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`));
|
||||||
|
|
||||||
worker.on("exit", (code) => {
|
worker.on("exit", (code) => {
|
||||||
if (isQuitting()) return;
|
if (isQuitting() || destroyed) return;
|
||||||
|
|
||||||
logger.warn(`[worker] Worker exited with code ${code}, recovering...`);
|
logger.warn(`[worker] Worker exited with code ${code}, recovering...`);
|
||||||
|
|
||||||
@@ -320,7 +323,7 @@ export function createWorkerService({
|
|||||||
publishDomainChanged();
|
publishDomainChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.status === "failed" || event.status === "timed_out") {
|
if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed) {
|
||||||
const retryCount = db.getTaskRetryCount(run.taskId);
|
const retryCount = db.getTaskRetryCount(run.taskId);
|
||||||
|
|
||||||
if (retryCount < MAX_RETRY_ATTEMPTS) {
|
if (retryCount < MAX_RETRY_ATTEMPTS) {
|
||||||
@@ -329,9 +332,12 @@ export function createWorkerService({
|
|||||||
db.advanceTaskStatus(run.taskId, "todo",
|
db.advanceTaskStatus(run.taskId, "todo",
|
||||||
`Run ${event.status} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}). Auto-retrying after ${RETRY_COOLDOWN_MS / 1000}s cooldown.`);
|
`Run ${event.status} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}). Auto-retrying after ${RETRY_COOLDOWN_MS / 1000}s cooldown.`);
|
||||||
logger.info(`[escalation] Retrying task ${run.taskId} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}) for agent ${run.agentId}`);
|
logger.info(`[escalation] Retrying task ${run.taskId} (attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}) for agent ${run.agentId}`);
|
||||||
setTimeout(() => {
|
const retryTimer = setTimeout(() => {
|
||||||
|
pendingRetryTimers.delete(retryTimer);
|
||||||
|
if (isQuitting() || destroyed) return;
|
||||||
wakeAgentIfPossible(run.agentId, run.companyId, "retry_failed");
|
wakeAgentIfPossible(run.agentId, run.companyId, "retry_failed");
|
||||||
}, RETRY_COOLDOWN_MS);
|
}, RETRY_COOLDOWN_MS);
|
||||||
|
pendingRetryTimers.add(retryTimer);
|
||||||
} else {
|
} else {
|
||||||
// Retry limit reached -- escalate up the chain
|
// Retry limit reached -- escalate up the chain
|
||||||
db.resetTaskRetryCount(run.taskId);
|
db.resetTaskRetryCount(run.taskId);
|
||||||
@@ -376,13 +382,15 @@ export function createWorkerService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pendingWakes = db.consumePendingWakes(run.agentId);
|
const pendingWakes = db.consumePendingWakes(run.agentId);
|
||||||
if (pendingWakes.length > 0) {
|
if (pendingWakes.length > 0 && !isQuitting() && !destroyed) {
|
||||||
for (const wake of pendingWakes) {
|
for (const wake of pendingWakes) {
|
||||||
wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger);
|
wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processRunCompletion(run, event as z.infer<typeof workerMessageSchema> & { type: "run-finished" });
|
if (!isQuitting() && !destroyed) {
|
||||||
|
processRunCompletion(run, event as z.infer<typeof workerMessageSchema> & { type: "run-finished" });
|
||||||
|
}
|
||||||
|
|
||||||
// Reset retry count on successful completion
|
// Reset retry count on successful completion
|
||||||
if (event.status === "succeeded") {
|
if (event.status === "succeeded") {
|
||||||
@@ -396,7 +404,7 @@ export function createWorkerService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Self-wake with circuit breaker to prevent infinite loops
|
// Self-wake with circuit breaker to prevent infinite loops
|
||||||
if (pendingWakes.length === 0) {
|
if (pendingWakes.length === 0 && !isQuitting() && !destroyed) {
|
||||||
try {
|
try {
|
||||||
const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]);
|
const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]);
|
||||||
if (actionableTasks.length > 0) {
|
if (actionableTasks.length > 0) {
|
||||||
@@ -426,7 +434,26 @@ export function createWorkerService({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
|
||||||
|
// Clear all pending retry timers so they never fire after quit
|
||||||
|
for (const timer of pendingRetryTimers) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
pendingRetryTimers.clear();
|
||||||
|
|
||||||
if (worker) {
|
if (worker) {
|
||||||
|
// Cancel all running/queued runs in the worker before killing it.
|
||||||
|
// This tells the worker to SIGTERM all child CLI processes (claude, codex, etc.)
|
||||||
|
const snapshot = db.listSnapshot();
|
||||||
|
for (const run of snapshot.runs) {
|
||||||
|
if (run.status === "running" || run.status === "queued") {
|
||||||
|
try {
|
||||||
|
worker.postMessage({ type: "cancel-run", runId: run.id });
|
||||||
|
} catch { /* worker may already be dead */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
worker.kill();
|
worker.kill();
|
||||||
// Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive)
|
// Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive)
|
||||||
const ref = worker;
|
const ref = worker;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolling ASCII agent-activity backdrop for the onboarding hero panel.
|
||||||
|
* Pure CSS animation (translateY on compositor thread) — no JS ticks.
|
||||||
|
* Content is doubled for seamless looping.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LINES = [
|
||||||
|
"",
|
||||||
|
" ╭──────────╮",
|
||||||
|
" │ CEO │",
|
||||||
|
" ╰────┬─────╯",
|
||||||
|
" ┌────┴─────┐",
|
||||||
|
" ╭──┴──╮ ╭──┴──╮",
|
||||||
|
" │ CTO │ │ CMO │",
|
||||||
|
" ╰──┬──╯ ╰──┬──╯",
|
||||||
|
" ╭──┴──╮ ╭──┴──╮",
|
||||||
|
" │ Eng │ │ Mkt │",
|
||||||
|
" ╰─────╯ ╰─────╯",
|
||||||
|
"",
|
||||||
|
" $ deploy --agents 12",
|
||||||
|
" ████████████████████ 100%",
|
||||||
|
" [OK] all agents online",
|
||||||
|
"",
|
||||||
|
" CEO │ analyzing requirements",
|
||||||
|
" CEO │ identifying deliverables",
|
||||||
|
" CEO │ delegating to leads",
|
||||||
|
"",
|
||||||
|
" CTO │ reviewing architecture",
|
||||||
|
" CTO │ → Dev.01: auth module",
|
||||||
|
" CTO │ → Dev.02: API layer",
|
||||||
|
" CTO │ → QA: test strategy",
|
||||||
|
"",
|
||||||
|
" CMO │ go-to-market strategy",
|
||||||
|
" CMO │ → Mkt.01: launch copy",
|
||||||
|
" CMO │ → Mkt.02: social plan",
|
||||||
|
"",
|
||||||
|
" ┌─ SPRINT 1 ────────────────┐",
|
||||||
|
" │ tasks ████████░░ 80% │",
|
||||||
|
" │ agents 12 active │",
|
||||||
|
" │ tests 128 passing │",
|
||||||
|
" └──────────────────────────┘",
|
||||||
|
"",
|
||||||
|
" Dev.01 │ scaffolding auth",
|
||||||
|
" Dev.02 │ REST endpoints",
|
||||||
|
" Design │ component library",
|
||||||
|
" QA │ integration tests",
|
||||||
|
" Mkt.01 │ blog post drafted",
|
||||||
|
"",
|
||||||
|
" CEO │ sprint review: ✓",
|
||||||
|
" CTO │ code review: merged",
|
||||||
|
" Dev.01 │ PR #12 → main",
|
||||||
|
" QA │ all tests green",
|
||||||
|
" CMO │ brief approved",
|
||||||
|
"",
|
||||||
|
" [INFO] Sprint 1 complete",
|
||||||
|
" [INFO] Advancing to Sprint 2",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOCK = LINES.join("\n");
|
||||||
|
|
||||||
|
export const AsciiAnimation = memo(function AsciiAnimation() {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 select-none overflow-hidden">
|
||||||
|
{/* Fade masks */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 top-0 z-10 h-32"
|
||||||
|
style={{ background: "linear-gradient(to bottom, var(--bg), transparent)" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-10 h-32"
|
||||||
|
style={{ background: "linear-gradient(to top, var(--bg), transparent)" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scrolling track — doubled for seamless loop */}
|
||||||
|
<div className="ascii-scroll-track whitespace-pre font-mono text-[11px] leading-[1.8] text-[color:var(--accent)] opacity-[0.08]">
|
||||||
|
{BLOCK}
|
||||||
|
{"\n"}
|
||||||
|
{BLOCK}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import type { ProfileSnapshot } from "@shared/types";
|
import type { ProfileSnapshot } from "@shared/types";
|
||||||
import type { AppLocale } from "@shared/locale";
|
import type { AppLocale } from "@shared/locale";
|
||||||
import { ConnectorReadiness } from "./ConnectorReadiness";
|
import { ConnectorReadiness } from "./ConnectorReadiness";
|
||||||
|
import { AsciiAnimation } from "./AsciiAnimation";
|
||||||
|
|
||||||
type Step = "language" | "company" | "mission" | "launch";
|
type Step = "language" | "company" | "mission" | "launch";
|
||||||
|
|
||||||
@@ -57,6 +58,9 @@ export function OnboardingHero({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ASCII agent activity backdrop */}
|
||||||
|
<AsciiAnimation />
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 24 }}
|
initial={{ opacity: 0, y: 24 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ export function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]
|
|||||||
onClick={() => onNavigate("goals")}
|
onClick={() => onNavigate("goals")}
|
||||||
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3.5 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm"
|
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3.5 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Target size={15} weight="fill" className="text-[color:var(--accent)]" />
|
<Target size={15} weight="fill" className="shrink-0 text-[color:var(--accent)]" />
|
||||||
<span className="truncate text-[13px] font-medium text-[color:var(--text)]">{goal.title}</span>
|
<span className="truncate text-[13px] font-medium text-[color:var(--text)]">{goal.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="whitespace-nowrap text-[13px] font-bold tabular-nums text-[color:var(--accent)]">{pct}%</span>
|
<span className="shrink-0 whitespace-nowrap text-[13px] font-bold tabular-nums text-[color:var(--accent)]">{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2.5 h-2 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
|
<div className="mt-2.5 h-2 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ export function GoalList({
|
|||||||
onClick={() => onSelect(goal.id)}
|
onClick={() => onSelect(goal.id)}
|
||||||
className={`cursor-pointer transition-colors duration-100 ${isSelected ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}`}
|
className={`cursor-pointer transition-colors duration-100 ${isSelected ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}`}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<CheckCircle size={16} weight="fill" className="text-[color:var(--accent)]" />
|
<CheckCircle size={16} weight="fill" className="shrink-0 text-[color:var(--accent)]" />
|
||||||
)}
|
)}
|
||||||
<div className="truncate text-[14px] font-semibold text-[color:var(--text)]">{goal.title}</div>
|
<div className="truncate text-[14px] font-semibold text-[color:var(--text)]">{goal.title}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import "@fontsource-variable/inter";
|
||||||
import "@fontsource-variable/jetbrains-mono";
|
import "@fontsource-variable/jetbrains-mono";
|
||||||
|
import "@fontsource/instrument-serif";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
--font-sans: "Inter Variable", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
"Microsoft YaHei", "PingFang SC", "Hiragino Sans GB",
|
Roboto, "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB",
|
||||||
"Noto Sans CJK SC", Helvetica, Arial, sans-serif;
|
"Noto Sans CJK SC", Helvetica, Arial, sans-serif;
|
||||||
|
--font-serif: "Instrument Serif", Georgia, "Times New Roman", serif;
|
||||||
--font-mono: "JetBrains Mono Variable", "Microsoft YaHei", ui-monospace,
|
--font-mono: "JetBrains Mono Variable", "Microsoft YaHei", ui-monospace,
|
||||||
SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Light theme — aligned with website ─── */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #f7f8fa;
|
--bg: #FBFAF5;
|
||||||
--bg-subtle: #ffffff;
|
--bg-subtle: #FFFFFF;
|
||||||
--panel: #ffffff;
|
--panel: #FFFFFF;
|
||||||
--panel-soft: #f0f2f5;
|
--panel-soft: #F3F2ED;
|
||||||
--panel-strong: #f7f8fa;
|
--panel-strong: #FBFAF5;
|
||||||
--line: rgba(50, 65, 85, 0.08);
|
--line: rgba(25, 25, 25, 0.08);
|
||||||
--line-strong: rgba(50, 65, 85, 0.15);
|
--line-strong: rgba(25, 25, 25, 0.15);
|
||||||
--text: #1a1d24;
|
--text: #191919;
|
||||||
--muted: #6b7280;
|
--muted: #7a7a72;
|
||||||
--muted-strong: #4b5563;
|
--muted-strong: #565650;
|
||||||
--accent: #4a5d7a;
|
--accent: #213448;
|
||||||
--accent-text: #3d5070;
|
--accent-text: #213448;
|
||||||
--accent-soft: #edf1f7;
|
--accent-soft: rgba(33, 52, 72, 0.07);
|
||||||
--accent-hover: #3d4f6d;
|
--accent-hover: #192838;
|
||||||
--text-on-accent: #ffffff;
|
--text-on-accent: #FBFAF5;
|
||||||
--danger: #c53030;
|
--danger: #c53030;
|
||||||
--danger-soft: #fef2f2;
|
--danger-soft: #fef2f2;
|
||||||
--warn: #b45309;
|
--warn: #b45309;
|
||||||
@@ -37,23 +39,24 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Dark theme — aligned with website ─── */
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #16181f;
|
--bg: #191919;
|
||||||
--bg-subtle: #1a1c24;
|
--bg-subtle: #1e1e1e;
|
||||||
--panel: #1e2028;
|
--panel: #222222;
|
||||||
--panel-soft: #24262e;
|
--panel-soft: #2a2a2a;
|
||||||
--panel-strong: #1c1e26;
|
--panel-strong: #1c1c1c;
|
||||||
--line: rgba(148, 163, 190, 0.12);
|
--line: rgba(251, 250, 245, 0.08);
|
||||||
--line-strong: rgba(148, 163, 190, 0.2);
|
--line-strong: rgba(251, 250, 245, 0.15);
|
||||||
--text: #edf0f7;
|
--text: #FBFAF5;
|
||||||
--muted: #8b95a5;
|
--muted: #8a8a84;
|
||||||
--muted-strong: #b0b8c8;
|
--muted-strong: #b8b8b2;
|
||||||
--accent: #7b9cc4;
|
--accent: #94B4C1;
|
||||||
--accent-text: #7b9cc4;
|
--accent-text: #94B4C1;
|
||||||
--accent-soft: rgba(123, 156, 196, 0.14);
|
--accent-soft: rgba(148, 180, 193, 0.12);
|
||||||
--accent-hover: #93b0d4;
|
--accent-hover: #a8c4cf;
|
||||||
--text-on-accent: #ffffff;
|
--text-on-accent: #191919;
|
||||||
--danger: #f87171;
|
--danger: #f87171;
|
||||||
--danger-soft: rgba(248, 113, 113, 0.12);
|
--danger-soft: rgba(248, 113, 113, 0.12);
|
||||||
--warn: #f59e0b;
|
--warn: #f59e0b;
|
||||||
@@ -65,21 +68,21 @@
|
|||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root[data-theme="system"] {
|
:root[data-theme="system"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #16181f;
|
--bg: #191919;
|
||||||
--bg-subtle: #1a1c24;
|
--bg-subtle: #1e1e1e;
|
||||||
--panel: #1e2028;
|
--panel: #222222;
|
||||||
--panel-soft: #24262e;
|
--panel-soft: #2a2a2a;
|
||||||
--panel-strong: #1c1e26;
|
--panel-strong: #1c1c1c;
|
||||||
--line: rgba(148, 163, 190, 0.12);
|
--line: rgba(251, 250, 245, 0.08);
|
||||||
--line-strong: rgba(148, 163, 190, 0.2);
|
--line-strong: rgba(251, 250, 245, 0.15);
|
||||||
--text: #edf0f7;
|
--text: #FBFAF5;
|
||||||
--muted: #8b95a5;
|
--muted: #8a8a84;
|
||||||
--muted-strong: #b0b8c8;
|
--muted-strong: #b8b8b2;
|
||||||
--accent: #7b9cc4;
|
--accent: #94B4C1;
|
||||||
--accent-text: #7b9cc4;
|
--accent-text: #94B4C1;
|
||||||
--accent-soft: rgba(123, 156, 196, 0.14);
|
--accent-soft: rgba(148, 180, 193, 0.12);
|
||||||
--accent-hover: #93b0d4;
|
--accent-hover: #a8c4cf;
|
||||||
--text-on-accent: #ffffff;
|
--text-on-accent: #191919;
|
||||||
--danger: #f87171;
|
--danger: #f87171;
|
||||||
--danger-soft: rgba(248, 113, 113, 0.12);
|
--danger-soft: rgba(248, 113, 113, 0.12);
|
||||||
--warn: #f59e0b;
|
--warn: #f59e0b;
|
||||||
@@ -133,11 +136,11 @@ select {
|
|||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(60, 75, 100, 0.2);
|
background: rgba(25, 25, 25, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(60, 75, 100, 0.32);
|
background: rgba(25, 25, 25, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
@@ -147,7 +150,7 @@ select {
|
|||||||
.surface {
|
.surface {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
box-shadow: 0 1px 3px rgba(26, 29, 36, 0.06), 0 0 0 0.5px rgba(26, 29, 36, 0.03);
|
box-shadow: 0 1px 3px rgba(25, 25, 25, 0.06), 0 0 0 0.5px rgba(25, 25, 25, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.focus-ring {
|
.focus-ring {
|
||||||
@@ -160,9 +163,19 @@ select {
|
|||||||
|
|
||||||
.focus-ring:focus-visible {
|
.focus-ring:focus-visible {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(74, 93, 122, 0.5),
|
0 0 0 1px rgba(33, 52, 72, 0.5),
|
||||||
0 0 0 4px rgba(74, 93, 122, 0.12);
|
0 0 0 4px rgba(33, 52, 72, 0.12);
|
||||||
border-color: rgba(74, 93, 122, 0.35);
|
border-color: rgba(33, 52, 72, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── ASCII animation ─── */
|
||||||
|
@keyframes ascii-scroll-up {
|
||||||
|
0% { transform: translateY(0); }
|
||||||
|
100% { transform: translateY(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ascii-scroll-track {
|
||||||
|
animation: ascii-scroll-up 80s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||