Merge pull request #55 from dj-oyu/feature/preact-miniapp

feat: Preact migration + 4-tab Mini App layout
This commit is contained in:
dj-oyu 2026-03-20 03:33:35 +09:00 committed by GitHub
commit 8136dc79cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 7344 additions and 2572 deletions

View file

@ -10,12 +10,15 @@ await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
const result = await Bun.build({
entrypoints: [path.join(srcDir, 'app.js')],
entrypoints: [path.join(srcDir, 'index.tsx')],
outdir: outDir,
target: 'browser',
format: 'iife',
sourcemap: 'none',
packages: 'bundle',
naming: {
entry: 'app.[ext]',
},
});
if (!result.success) {

View file

@ -7,6 +7,7 @@
"dependencies": {
"highlight.js": "^11.11.1",
"marked": "^17.0.4",
"preact": "^10.25.0",
},
"devDependencies": {
"happy-dom": "^16.8.1",
@ -173,6 +174,8 @@
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],

View file

@ -12,6 +12,7 @@
},
"dependencies": {
"highlight.js": "^11.11.1",
"marked": "^17.0.4"
"marked": "^17.0.4",
"preact": "^10.25.0"
}
}

View file

@ -0,0 +1,60 @@
import { useEffect, useState, useCallback, useRef } from 'preact/hooks';
import { useSSE } from './hooks/use-sse';
import { PlanTab } from './components/plan/plan-tab';
import { WorkTab } from './components/work/work-tab';
import { ToolsTab } from './components/tools/tools-tab';
import { DevTab } from './components/dev/dev-tab';
const TABS = [
{ id: 'plan', label: 'Plan' },
{ id: 'work', label: 'Work' },
{ id: 'tools', label: 'Tools' },
{ id: 'dev', label: 'Dev' },
] as const;
type TabId = (typeof TABS)[number]['id'];
export function App() {
const [activeTab, setActiveTab] = useState<TabId>('plan');
const indicatorRef = useRef<HTMLDivElement>(null);
const sse = useSSE();
const switchTab = useCallback((id: TabId, index: number) => {
setActiveTab(id);
if (indicatorRef.current) {
indicatorRef.current.style.transform = `translateX(${index * 100}%)`;
}
}, []);
return (
<>
<div class="tabs">
<div class="tabs-inner">
<div class="tab-indicator" ref={indicatorRef} />
{TABS.map((tab, i) => (
<button
key={tab.id}
class={`tab${activeTab === tab.id ? ' active' : ''}`}
onClick={() => switchTab(tab.id, i)}
>
{tab.label}
</button>
))}
</div>
</div>
<div class={`panel${activeTab === 'plan' ? ' active' : ''}`}>
<PlanTab active={activeTab === 'plan'} sse={sse} />
</div>
<div class={`panel${activeTab === 'work' ? ' active' : ''}`}>
<WorkTab active={activeTab === 'work'} sse={sse} />
</div>
<div class={`panel${activeTab === 'tools' ? ' active' : ''}`}>
<ToolsTab active={activeTab === 'tools'} sse={sse} />
</div>
<div class={`panel${activeTab === 'dev' ? ' active' : ''}`}>
<DevTab active={activeTab === 'dev'} sse={sse} />
</div>
</>
);
}

View file

@ -0,0 +1,121 @@
import { useEffect, useState, useCallback } from 'preact/hooks';
import type { SSEHook } from '../../hooks/use-sse';
import { apiFetch, apiPost } from '../../hooks/use-api';
import { isFresh } from '../../utils';
interface DevTabProps {
active: boolean;
sse: SSEHook;
}
export function DevTab({ active, sse }: DevTabProps) {
const [data, setData] = useState<any>(null);
const loadDev = useCallback(async () => {
try {
const d = await apiFetch('/miniapp/api/dev');
setData(d);
} catch {}
}, []);
useEffect(() => {
if (sse.dev) setData(sse.dev);
}, [sse.dev]);
useEffect(() => {
if (active && !isFresh(sse.lastUpdate, 'dev')) loadDev();
}, [active]);
const targets = data?.targets || [];
const activeId = data?.active_id || '';
const isActive = !!data?.active;
const handleToggle = async (id: string) => {
const action = id === activeId ? 'deactivate' : 'activate';
const body =
action === 'activate'
? { action: 'activate', id }
: { action: 'deactivate' };
try {
const d = await apiPost('/miniapp/api/dev', body);
if (!d.error) setData(d);
} catch {}
};
const handleDelete = async (id: string, name: string) => {
if (!confirm('Remove "' + name + '"?')) return;
try {
const d = await apiPost('/miniapp/api/dev', {
action: 'unregister',
id,
});
if (!d.error) setData(d);
} catch {}
};
const iframeSrc = isActive ? location.origin + '/miniapp/dev/' : '';
const targetDisplay = data?.target
? data.target.replace(/^https?:\/\//, '')
: '';
return (
<>
<div class="dev-header">
<span class={`dev-target-dot${isActive ? ' on' : ''}`} />
<span class="dev-header-title">Dev Preview</span>
<span class="dev-header-target">{targetDisplay}</span>
</div>
{targets.length === 0 ? (
<div class="empty-state">
No targets registered.
<br />
Ask the agent to start a dev server.
</div>
) : (
targets.map((t: any) => {
const isTargetActive = t.id === activeId;
const displayUrl = t.target.replace(/^https?:\/\//, '');
return (
<div
key={t.id}
class={`dev-target-item glass glass-interactive${isTargetActive ? ' active' : ''}`}
onClick={() => handleToggle(t.id)}
>
<span
class={`dev-target-dot${isTargetActive ? ' on' : ''}`}
/>
<span class="dev-target-name">{t.name}</span>
<span class="dev-target-url">{displayUrl}</span>
<span
class="dev-target-delete"
onClick={(e) => {
e.stopPropagation();
handleDelete(t.id, t.name);
}}
>
{'\u00D7'}
</span>
</div>
);
})
)}
{isActive && (
<div style={{ marginTop: '8px' }}>
<div class="card glass" style={{ padding: 0, overflow: 'hidden' }}>
<iframe
src={iframeSrc}
style={{
width: '100%',
height: '70vh',
border: 'none',
borderRadius: '16px',
}}
/>
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,513 @@
import { useEffect, useRef } from 'preact/hooks';
// Orch canvas character system - ported from vanilla JS
// All character/animation state is kept in module-level vars (same as original)
const BOB = [0, -1, -2, -1];
const FRAME_MS: Record<string, number> = {
idle: 450,
waiting: 650,
toolcall: 90,
talking: 280,
entering: 220,
exiting: 220,
};
const WALK = 55;
interface Char {
id: string;
emoji: string;
x: number;
y: number;
home: { x: number; y: number };
target: { x: number; y: number } | null;
state: string;
frame: number;
frameTimer: number;
bubble: { text: string; ttl: number } | null;
alive: boolean;
_onArrive: (() => void) | null;
statusText?: string | null;
facing?: number;
flipTimer?: number;
}
let conductor: Char;
let secretary: Char;
let heartbeat: Char;
let subagents: Char[];
let slots: Record<string, Char> = {};
let freeSlots: Char[] = [];
let inited = false;
let orchWs: WebSocket | null = null;
let orchReconnectTimer: any = null;
let lastTs: number | null = null;
function makeChar(
id: string,
emoji: string,
home: { x: number; y: number },
): Char {
return {
id,
emoji,
x: home.x,
y: home.y,
home,
target: null,
state: 'idle',
frame: 0,
frameTimer: 0,
bubble: null,
alive: false,
_onArrive: null,
};
}
function initChars() {
const MAP = window.MAP_POSITIONS;
conductor = makeChar('conductor', '\u{1F451}', MAP.conductor);
secretary = makeChar('secretary', '\u{1F469}\u{200D}\u{1F4BC}', MAP.secretary);
heartbeat = makeChar('heartbeat', '\u{1F54A}\uFE0F', MAP.heartbeat || { x: 230, y: 58 });
conductor.alive = true;
secretary.alive = false;
heartbeat.alive = true;
conductor.statusText = null;
heartbeat.facing = 1;
heartbeat.flipTimer = 0;
const ps = [
{ id: 's0', emoji: '\u{1F50D}' },
{ id: 's1', emoji: '\u{1F4CA}' },
{ id: 's2', emoji: '\u{1F4BB}' },
{ id: 's3', emoji: '\u{1F527}' },
{ id: 's4', emoji: '\u{1F3AF}' },
];
subagents = ps.map((p, i) => {
const c = makeChar(p.id, p.emoji, MAP.stations[i]);
c.x = MAP.door.x;
c.y = MAP.door.y;
return c;
});
slots = {};
freeSlots = subagents.slice();
}
function allChars(): Char[] {
return [conductor, secretary, heartbeat, ...subagents];
}
function syncBadge(id: string, state: string, alive: boolean) {
const el = document.getElementById('orch-badge-' + id);
if (!el) return;
el.className =
'orch-badge' +
(alive ? ' alive' : '') +
(state === 'talking' ? ' talking' : '') +
(state === 'toolcall' ? ' toolcall' : '') +
(state === 'waiting' ? ' waiting' : '');
}
function setState(c: Char, state: string) {
c.state = state;
syncBadge(c.id, state, c.alive);
if (c === conductor) {
if (state === 'waiting') c.statusText = '\u{1F914}';
else if (state === 'toolcall') c.statusText = '\u2328';
else if (state === 'user_waiting') c.statusText = '\u23F3';
else if (state === 'plan_interviewing') c.statusText = '\u{1F4CB}';
else if (state === 'plan_review') c.statusText = '\u{1F50D}';
else if (state === 'plan_executing') c.statusText = '\u25B6\uFE0F';
else if (state === 'plan_completed') c.statusText = '\u2705';
else c.statusText = null;
const inPlan = state.indexOf('plan_') === 0;
if (secretary.alive !== inPlan) {
secretary.alive = inPlan;
syncBadge('secretary', secretary.state, secretary.alive);
}
}
}
function moveTo(c: Char, pos: { x: number; y: number }, cb?: () => void) {
c.target = pos;
c._onArrive = cb || null;
}
function say(c: Char, text: string, ttl = 2200) {
c.bubble = { text, ttl };
}
function charForId(id: string): Char {
if (id === 'heartbeat') return heartbeat;
if (slots[id]) return slots[id];
return conductor;
}
function spawn(id: string) {
if (/^subagent-/.test(id)) {
const c = freeSlots.shift();
if (!c) return;
slots[id] = c;
c.alive = true;
c.x = window.MAP_POSITIONS.door.x;
c.y = window.MAP_POSITIONS.door.y;
setState(c, 'entering');
moveTo(c, c.home, () => setState(c, 'idle'));
} else {
const ch = charForId(id);
ch.alive = true;
setState(ch, 'waiting');
}
}
function gc(id: string) {
if (/^subagent-/.test(id)) {
const c = slots[id];
if (!c) return;
delete slots[id];
freeSlots.push(c);
setState(c, 'exiting');
moveTo(c, window.MAP_POSITIONS.door, () => {
c.alive = false;
setState(c, 'idle');
});
} else {
const ch = charForId(id);
if (ch === heartbeat) {
setState(ch, 'idle');
} else if (ch === conductor) {
setState(ch, 'user_waiting');
} else {
ch.alive = false;
setState(ch, 'idle');
}
}
}
function converse(fromId: string, toId: string, text: string) {
const from = charForId(fromId);
const to = charForId(toId);
if (!from || !to || from === to) return;
const label = (text || '').slice(0, 18);
const mid = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
setState(from, 'talking');
setState(to, 'talking');
moveTo(from, { x: mid.x - 18, y: mid.y }, () => say(from, label, 2400));
moveTo(to, { x: mid.x + 18, y: mid.y }, () => {
setTimeout(() => {
moveTo(from, from.home, () => setState(from, 'idle'));
moveTo(to, to.home, () => setState(to, 'idle'));
}, 2600);
});
}
function update(dt: number) {
allChars().forEach((c) => {
if (!c.alive && c.state !== 'entering') return;
if (c === heartbeat) {
if (c.state === 'idle') {
c.frame = 0;
} else {
c.frameTimer += dt;
const pDur = c.state === 'toolcall' ? 130 : 380;
if (c.frameTimer >= pDur) {
c.frame = (c.frame + 1) % 4;
c.frameTimer -= pDur;
}
}
} else {
c.frameTimer += dt;
const dur = FRAME_MS[c.state] || 450;
if (c.frameTimer >= dur) {
c.frame = (c.frame + 1) % 4;
c.frameTimer -= dur;
}
}
if (c.target) {
const dx = c.target.x - c.x;
const dy = c.target.y - c.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 1.5) {
const spd = (WALK * dt) / 1000;
c.x += (dx / dist) * spd;
c.y += (dy / dist) * spd;
} else {
c.x = c.target.x;
c.y = c.target.y;
c.target = null;
if (c._onArrive) {
c._onArrive();
c._onArrive = null;
}
}
}
if (c.bubble) {
c.bubble.ttl -= dt;
if (c.bubble.ttl <= 0) c.bubble = null;
}
if (c === heartbeat) {
if (c.target) {
const pdx = c.target.x - c.x;
if (Math.abs(pdx) > 1) c.facing = pdx > 0 ? 1 : -1;
} else {
const flipRate =
c.state === 'toolcall' ? 280 : c.state === 'waiting' ? 600 : 2800;
c.flipTimer = (c.flipTimer || 0) + dt;
if (c.flipTimer >= flipRate) {
c.flipTimer -= flipRate;
c.facing = -(c.facing || 1);
}
}
}
});
}
function drawStatus(ctx: CanvasRenderingContext2D, c: Char) {
if (!c.statusText) return;
const yOff = BOB[c.frame];
const cx = Math.floor(c.x);
const cy = Math.floor(c.y + yOff) - 20;
ctx.font = '11px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(c.statusText, cx, cy);
}
function drawBubble(ctx: CanvasRenderingContext2D, c: Char) {
if (!c.bubble) return;
const yOff = BOB[c.frame];
const bx = c.x;
const by = c.y + yOff - 18;
ctx.font = '7px Silkscreen,monospace';
const tw = ctx.measureText(c.bubble.text).width;
const pw = tw + 8;
const ph = 12;
const lx = Math.max(4, Math.min(316 - pw, bx - pw / 2));
ctx.fillStyle = '#facc15';
ctx.fillRect(Math.floor(lx), Math.floor(by - ph), Math.ceil(pw), Math.ceil(ph));
ctx.fillRect(Math.floor(bx) - 1, Math.floor(by), 3, 3);
ctx.fillStyle = '#0a0a00';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(c.bubble.text, Math.floor(lx + 4), Math.floor(by - ph / 2));
}
function drawChar(ctx: CanvasRenderingContext2D, c: Char) {
if (!c.alive && c.state !== 'entering' && c.state !== 'exiting') return;
const yOff = BOB[c.frame];
const cx = Math.floor(c.x);
const cy = Math.floor(c.y + yOff);
if (c.state === 'toolcall') {
ctx.fillStyle = 'rgba(251,146,60,0.35)';
ctx.beginPath();
ctx.arc(cx, cy, 13, 0, Math.PI * 2);
ctx.fill();
} else if (c.state === 'waiting') {
ctx.fillStyle = 'rgba(96,165,250,0.25)';
ctx.beginPath();
ctx.arc(cx, cy, 11, 0, Math.PI * 2);
ctx.fill();
} else if (c.state === 'user_waiting' || c.state === 'plan_review') {
ctx.fillStyle = 'rgba(167,139,250,0.18)';
ctx.beginPath();
ctx.arc(cx, cy, 10, 0, Math.PI * 2);
ctx.fill();
} else if (c.state === 'plan_executing') {
ctx.fillStyle = 'rgba(74,222,128,0.18)';
ctx.beginPath();
ctx.arc(cx, cy, 10, 0, Math.PI * 2);
ctx.fill();
}
ctx.font = '18px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (c.facing === -1) {
ctx.save();
ctx.translate(cx, cy);
ctx.scale(-1, 1);
ctx.fillText(c.emoji, 0, 0);
ctx.restore();
} else {
ctx.fillText(c.emoji, cx, cy);
}
ctx.font = '6px Silkscreen,monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = c.state === 'talking' ? '#facc15' : '#3a4a7a';
ctx.fillText(c.id.toUpperCase(), cx, cy + 11);
drawStatus(ctx, c);
drawBubble(ctx, c);
}
interface OrchCanvasProps {
active: boolean;
}
export function OrchCanvas({ active }: OrchCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animRef = useRef<number>(0);
useEffect(() => {
if (!active) {
// Disconnect WS when not active
if (orchReconnectTimer) {
clearTimeout(orchReconnectTimer);
orchReconnectTimer = null;
}
if (orchWs) {
orchWs.close();
orchWs = null;
}
if (animRef.current) {
cancelAnimationFrame(animRef.current);
animRef.current = 0;
}
return;
}
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Init characters once
if (!inited) {
inited = true;
initChars();
}
// Start render loop
lastTs = null;
function renderLoop(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 80);
lastTs = ts;
update(dt);
ctx!.imageSmoothingEnabled = false;
window.drawMap(ctx!);
allChars().forEach((c) => drawChar(ctx!, c));
animRef.current = requestAnimationFrame(renderLoop);
}
window.loadMapAsset(() => {
lastTs = null;
animRef.current = requestAnimationFrame(renderLoop);
});
// Connect orchestration WebSocket
connectOrchWs();
return () => {
if (animRef.current) {
cancelAnimationFrame(animRef.current);
animRef.current = 0;
}
};
}, [active]);
return (
<div class="card glass" style={{ marginTop: '16px', padding: '0' }}>
<div class="orch-room-row">
<div class="orch-side" id="orch-panel-left">
<div class="orch-badge alive" id="orch-badge-conductor">
<div class="orch-badge-emoji">{'\u{1F451}'}</div>
<div class="orch-badge-label">CNDR</div>
<div class="orch-badge-dot" />
</div>
<div class="orch-badge" id="orch-badge-secretary">
<div class="orch-badge-emoji">{'\u{1F469}\u{200D}\u{1F4BC}'}</div>
<div class="orch-badge-label">SEC</div>
<div class="orch-badge-dot" />
</div>
<div class="orch-badge alive" id="orch-badge-heartbeat">
<div class="orch-badge-emoji">{'\u{1F54A}\uFE0F'}</div>
<div class="orch-badge-label">HB</div>
<div class="orch-badge-dot" />
</div>
</div>
<div class="orch-canvas-wrap">
<canvas ref={canvasRef} id="orch-canvas" width="320" height="320" />
</div>
<div class="orch-side" id="orch-panel-right">
{[
{ id: 's0', emoji: '\u{1F50D}', label: 'SCOUT' },
{ id: 's1', emoji: '\u{1F4CA}', label: 'ANLY' },
{ id: 's2', emoji: '\u{1F4BB}', label: 'CODE' },
{ id: 's3', emoji: '\u{1F527}', label: 'WRKR' },
{ id: 's4', emoji: '\u{1F3AF}', label: 'CORD' },
].map((s) => (
<div class="orch-badge" id={`orch-badge-${s.id}`} key={s.id}>
<div class="orch-badge-emoji">{s.emoji}</div>
<div class="orch-badge-label">{s.label}</div>
<div class="orch-badge-dot" />
</div>
))}
</div>
</div>
<div class="orch-status">
<span class="orch-dot" id="orch-status-dot" />
<span id="orch-status-text">Connecting...</span>
</div>
</div>
);
}
function connectOrchWs() {
if (orchWs && orchWs.readyState <= 1) return;
const initData = window.Telegram?.WebApp?.initData || '';
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url =
proto +
'//' +
location.host +
'/miniapp/api/orchestration/ws?initData=' +
encodeURIComponent(initData);
orchWs = new WebSocket(url);
orchWs.onopen = () => {
const dot = document.getElementById('orch-status-dot');
const txt = document.getElementById('orch-status-text');
if (dot) dot.classList.add('on');
if (txt) txt.textContent = 'Live';
};
orchWs.onmessage = (e) => {
let msg: any;
try {
msg = JSON.parse(e.data);
} catch {
return;
}
if (msg.type === 'init') {
(msg.agents || []).forEach((info: any) => {
spawn(info.id);
if (info.state && info.state !== 'idle') {
const c = charForId(info.id);
if (c) setState(c, info.state);
}
});
} else if (msg.type === 'event') {
const ev = msg.event || {};
if (ev.type === 'agent_spawn') spawn(ev.id);
if (ev.type === 'agent_state') {
const c = charForId(ev.id);
if (c) setState(c, ev.state);
}
if (ev.type === 'agent_gc') gc(ev.id);
if (ev.type === 'conversation') converse(ev.from, ev.to, ev.text);
}
};
orchWs.onclose = () => {
const dot = document.getElementById('orch-status-dot');
const txt = document.getElementById('orch-status-text');
if (dot) dot.classList.remove('on');
if (txt) txt.textContent = 'Disconnected';
orchWs = null;
};
orchWs.onerror = () => {};
}

View file

@ -0,0 +1,205 @@
import { useEffect, useState, useCallback } from 'preact/hooks';
import type { SSEHook } from '../../hooks/use-sse';
import { apiFetch, sendCommand } from '../../hooks/use-api';
import { escapeHtml, isFresh } from '../../utils';
import { renderMarkdown } from '../../markdown';
import { SlideApprove } from './slide-approve';
import { OrchCanvas } from './orch-canvas';
interface PlanTabProps {
active: boolean;
sse: SSEHook;
}
export function PlanTab({ active, sse }: PlanTabProps) {
const [plan, setPlan] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const loadPlan = useCallback(async () => {
setLoading(true);
setError(false);
try {
const data = await apiFetch('/miniapp/api/plan');
setPlan(data);
} catch {
setError(true);
} finally {
setLoading(false);
}
}, []);
// SSE updates
useEffect(() => {
if (sse.plan) {
setPlan(sse.plan);
setLoading(false);
}
}, [sse.plan]);
// Load on tab switch if not fresh
useEffect(() => {
if (active && !isFresh(sse.lastUpdate, 'plan')) {
loadPlan();
}
}, [active]);
// Initial load
useEffect(() => {
loadPlan();
}, []);
if (loading && !plan) return <div class="loading">Loading plan...</div>;
if (error && !plan) return <div class="loading">Failed to load plan.</div>;
if (!plan) return null;
return (
<>
<PlanContent plan={plan} />
{window.ORCH_ENABLED && <OrchCanvas active={active} />}
</>
);
}
function PlanContent({ plan }: { plan: any }) {
if (!plan.has_plan) {
return <NoPlan />;
}
const isInterviewOrReview =
plan.status === 'interviewing' || plan.status === 'review';
return (
<>
<div class="card glass">
<div class="card-title">Status</div>
<div class="card-value">{plan.status}</div>
<div style={{ color: 'var(--hint)', marginTop: '4px' }}>
Phase {plan.current_phase} / {plan.total_phases}
</div>
</div>
{isInterviewOrReview && plan.memory && (
<div
class="memory-view glass"
dangerouslySetInnerHTML={{ __html: renderMarkdown(plan.memory) }}
/>
)}
{plan.status === 'review' && (
<>
<SlideApprove label="Slide to Approve" cmd="/plan start" />
<SlideApprove
label="Approve & Clear History"
cmd="/plan start clear"
warn
/>
</>
)}
{!isInterviewOrReview && plan.phases && plan.phases.length > 0 && (
<Phases phases={plan.phases} currentPhase={plan.current_phase} />
)}
</>
);
}
function NoPlan() {
const [task, setTask] = useState('');
const handleStart = async () => {
const t = task.trim();
if (!t) return;
const ok = await sendCommand('/plan ' + t);
if (ok) setTask('');
};
return (
<>
<div class="empty-state">No active plan.</div>
<div class="card glass" style={{ marginTop: '16px' }}>
<div class="card-title">Start a Plan</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<input
class="send-input glass glass-interactive"
placeholder="Describe your task..."
value={task}
onInput={(e) => setTask((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === 'Enter' && handleStart()}
/>
<button class="send-btn" onClick={handleStart}>
Start
</button>
</div>
</div>
</>
);
}
function Phases({
phases,
currentPhase,
}: {
phases: any[];
currentPhase: number;
}) {
const onStepClick = (phaseNum: number, stepIdx: number, done: boolean) => {
if (done) return;
sendCommand('/plan done ' + stepIdx);
};
return (
<>
{phases.map((phase) => {
const doneCount = phase.steps.filter((s: any) => s.done).length;
const total = phase.steps.length;
let indicatorClass: string;
let indicator: string;
if (
phase.number < currentPhase ||
(total > 0 && doneCount === total)
) {
indicatorClass = 'done';
indicator = '\u2713';
} else if (phase.number === currentPhase) {
indicatorClass = 'current';
indicator = String(phase.number);
} else {
indicatorClass = 'pending';
indicator = String(phase.number);
}
return (
<div class="phase" key={phase.number}>
<div class="phase-header">
<div class={`phase-indicator ${indicatorClass}`}>{indicator}</div>
<span class="phase-title">
{phase.title || 'Phase ' + phase.number}
</span>
{total > 0 && (
<span class="phase-progress">
{doneCount}/{total}
</span>
)}
</div>
{phase.steps.map((step: any) => (
<div
key={step.index}
class={`step${step.done ? ' step-done' : ''}`}
onClick={() =>
onStepClick(phase.number, step.index, step.done)
}
>
<div class={`step-check${step.done ? ' done' : ''}`} />
<div class={`step-text${step.done ? ' done' : ''}`}>
{step.description}
</div>
</div>
))}
</div>
);
})}
</>
);
}

View file

@ -0,0 +1,123 @@
import { useRef, useEffect } from 'preact/hooks';
import { sendCommand } from '../../hooks/use-api';
interface SlideApproveProps {
label: string;
cmd: string;
warn?: boolean;
}
export function SlideApprove({ label, cmd, warn }: SlideApproveProps) {
const trackRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const track = trackRef.current;
const thumb = thumbRef.current;
if (!track || !thumb) return;
const ac = new AbortController();
const signal = ac.signal;
let dragging = false;
let startX = 0;
let thumbStartLeft = 0;
function getMaxLeft() {
return track!.offsetWidth - thumb!.offsetWidth - 6;
}
function markApproved() {
track!.classList.add('approved');
if (labelRef.current) labelRef.current.textContent = 'Approved!';
thumb!.classList.add('hidden');
}
function onStart(e: MouseEvent | TouchEvent) {
if (track!.classList.contains('approved')) return;
dragging = true;
thumb!.classList.add('dragging');
const clientX =
'touches' in e ? e.touches[0].clientX : e.clientX;
startX = clientX;
thumbStartLeft = thumb!.offsetLeft - 3;
e.preventDefault();
}
function onMove(e: MouseEvent | TouchEvent) {
if (!dragging) return;
const clientX =
'touches' in e ? e.touches[0].clientX : e.clientX;
const dx = clientX - startX;
const newLeft = Math.max(
0,
Math.min(thumbStartLeft + dx, getMaxLeft()),
);
thumb!.style.left = newLeft + 3 + 'px';
e.preventDefault();
}
function onEnd() {
if (!dragging) return;
dragging = false;
thumb!.classList.remove('dragging');
const currentLeft = thumb!.offsetLeft - 3;
const maxLeft = getMaxLeft();
if (currentLeft >= maxLeft * 0.8) {
markApproved();
sendCommand(cmd);
} else {
thumb!.style.left = '3px';
}
}
thumb.addEventListener('touchstart', onStart, {
passive: false,
signal,
});
thumb.addEventListener('mousedown', onStart, { signal });
document.addEventListener('touchmove', onMove, {
passive: false,
signal,
});
document.addEventListener('mousemove', onMove, { signal });
document.addEventListener('touchend', onEnd, { signal });
document.addEventListener('mouseup', onEnd, { signal });
return () => ac.abort();
}, [cmd]);
const borderStyle = warn
? { borderColor: 'var(--warn, #ff9800)' }
: undefined;
const thumbStyle = warn
? { background: 'var(--warn, #ff9800)' }
: undefined;
return (
<div class="slide-approve-wrap">
<div
class="slide-approve-track glass glass-interactive"
ref={trackRef}
style={borderStyle}
>
<div class="slide-approve-thumb" ref={thumbRef} style={thumbStyle}>
<svg viewBox="0 0 24 24">
<path
d="M5 12h14m-6-6 6 6-6 6"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
</div>
<div class="slide-approve-label" ref={labelRef}>
{label}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,62 @@
import { useState, useRef } from 'preact/hooks';
import { sendCommand } from '../../hooks/use-api';
import { flashSent } from '../../utils';
const QUICK_CMDS = ['/session', '/skills', '/plan clear'];
export function CommandsSection() {
const [customCmd, setCustomCmd] = useState('');
const btnRef = useRef<HTMLButtonElement>(null);
const handleQuick = async (cmd: string, e: MouseEvent) => {
const ok = await sendCommand(cmd);
if (ok) flashSent(e.currentTarget as HTMLElement);
};
const handleCustom = async () => {
const cmd = customCmd.trim();
if (!cmd || !cmd.startsWith('/')) return;
const ok = await sendCommand(cmd);
if (ok) {
setCustomCmd('');
if (btnRef.current) flashSent(btnRef.current);
}
};
return (
<>
<div class="card glass">
<div class="card-title">Quick Commands</div>
<div class="cmd-tiles">
{QUICK_CMDS.map((cmd) => (
<button
key={cmd}
class="cmd-tile glass glass-interactive"
onClick={(e) => handleQuick(cmd, e)}
>
{cmd}
</button>
))}
</div>
</div>
<div class="card glass">
<div class="card-title">Custom Command</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<input
class="send-input glass glass-interactive"
placeholder="/command args..."
value={customCmd}
onInput={(e) =>
setCustomCmd((e.target as HTMLInputElement).value)
}
onKeyDown={(e) => e.key === 'Enter' && handleCustom()}
style={{ flex: 1 }}
/>
<button class="send-btn" ref={btnRef} onClick={handleCustom}>
Send
</button>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,179 @@
import { useEffect, useRef, useState, useCallback } from 'preact/hooks';
import { renderLogs as renderLogsView } from '../../logs_view.js';
const LOGS_PAGE_SIZE = 60;
interface LogsSectionProps {
active: boolean;
}
export function LogsSection({ active }: LogsSectionProps) {
const [entries, setEntries] = useState<any[]>([]);
const [component, setComponent] = useState('');
const [page, setPage] = useState(1);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
const connect = useCallback(() => {
if (wsRef.current && wsRef.current.readyState <= 1) return;
const initData = window.Telegram?.WebApp?.initData || '';
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
let url =
proto +
'//' +
location.host +
'/miniapp/api/logs/ws?initData=' +
encodeURIComponent(initData);
if (component) url += '&component=' + encodeURIComponent(component);
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'init') {
setEntries(msg.entries || []);
setPage(1);
} else if (msg.type === 'entry') {
setEntries((prev) => {
const next = [...prev, msg.entry];
return next.length > 200 ? next.slice(1) : next;
});
}
};
ws.onclose = () => {
setConnected(false);
wsRef.current = null;
if (active) {
reconnectRef.current = setTimeout(connect, 3000);
}
};
ws.onerror = () => {};
}, [component, active]);
const disconnect = useCallback(() => {
if (reconnectRef.current) {
clearTimeout(reconnectRef.current);
reconnectRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setConnected(false);
}, []);
useEffect(() => {
if (active) {
connect();
} else {
disconnect();
}
return disconnect;
}, [active, component]);
const handleFilterChange = (comp: string) => {
setComponent(comp);
setPage(1);
setEntries([]);
disconnect();
// Will reconnect via useEffect when component changes
};
const view = renderLogsView(entries, {
component,
page,
pageSize: LOGS_PAGE_SIZE,
});
const handleSaveSnapshot = async () => {
try {
const initData = window.Telegram?.WebApp?.initData || '';
const res = await fetch(
location.origin +
'/miniapp/api/logs/snapshot?initData=' +
encodeURIComponent(initData),
{ method: 'POST' },
);
if (!res.ok) return;
const data = await res.json();
if (data.download_url) {
const a = document.createElement('a');
a.href =
location.origin +
data.download_url +
'?initData=' +
encodeURIComponent(initData);
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
} catch {}
};
return (
<div class="card glass">
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px',
}}
>
<span class="card-title" style={{ margin: 0 }}>
Logs
</span>
<span class={`dev-target-dot${connected ? ' on' : ''}`} />
</div>
<div class="log-filter-chips">
{[
{ label: 'All', comp: '' },
{ label: 'Telego', comp: 'telego' },
{ label: 'Console', comp: 'dev-console' },
].map((f) => (
<button
key={f.comp}
class={`log-filter-chip${component === f.comp ? ' active' : ''}`}
onClick={() => handleFilterChange(f.comp)}
>
{f.label}
</button>
))}
</div>
<div
ref={containerRef}
id="logs-content"
dangerouslySetInnerHTML={{ __html: view.html || '<div class="empty-state">No logs.</div>' }}
/>
<div class="log-pagination">
<button
class="log-page-btn"
disabled={view.currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Newer
</button>
<span>
{view.currentPage}/{view.totalPages} ({view.totalItems})
</span>
<button
class="log-page-btn"
disabled={view.currentPage >= view.totalPages}
onClick={() => setPage((p) => p + 1)}
>
Older
</button>
</div>
<div class="log-actions">
<button class="log-snap-btn" onClick={handleSaveSnapshot}>
Save Snapshot
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,584 @@
import { useEffect, useState, useCallback } from 'preact/hooks';
import { apiFetch, apiPost } from '../../hooks/use-api';
import { escapeHtml } from '../../utils';
import { renderMarkdown } from '../../markdown';
const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
pending: { bg: 'rgba(234,179,8,0.15)', text: '#ca8a04' },
active: { bg: 'rgba(59,130,246,0.15)', text: '#2563eb' },
completed: { bg: 'rgba(34,197,94,0.15)', text: '#16a34a' },
failed: { bg: 'rgba(239,68,68,0.15)', text: '#dc2626' },
canceled: { bg: 'rgba(107,114,128,0.15)', text: '#6b7280' },
};
interface ResearchSectionProps {
active: boolean;
}
export function ResearchSection({ active }: ResearchSectionProps) {
const [tasks, setTasks] = useState<any[] | null>(null);
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
const [detailId, setDetailId] = useState<string | null>(null);
const loadTasks = useCallback(async () => {
setLoading(true);
try {
const data = await apiFetch('/miniapp/api/research');
setTasks(data);
} catch {
setTasks(null);
}
setLoading(false);
}, []);
useEffect(() => {
if (active) loadTasks();
}, [active]);
if (detailId) {
return (
<TaskDetail
taskId={detailId}
onBack={() => {
setDetailId(null);
loadTasks();
}}
/>
);
}
return (
<div class="card glass">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '12px',
}}
>
<span class="card-title" style={{ margin: 0 }}>
Research Tasks
</span>
<button
class="send-btn"
style={{ padding: '6px 14px', fontSize: '13px' }}
onClick={() => setShowForm(true)}
>
+ New
</button>
</div>
{showForm && (
<NewTaskForm
onCreated={() => {
setShowForm(false);
loadTasks();
}}
onCancel={() => setShowForm(false)}
/>
)}
{loading && !tasks ? (
<div class="loading" style={{ padding: '12px' }}>
Loading tasks...
</div>
) : !tasks || tasks.length === 0 ? (
<div class="empty-state" style={{ padding: '24px' }}>
No research tasks yet.
</div>
) : (
tasks.map((t) => {
const sc = STATUS_COLORS[t.status] || STATUS_COLORS.pending;
return (
<div
key={t.id}
class="card glass glass-interactive"
style={{
cursor: 'pointer',
padding: '14px',
...(t.focused
? { borderLeft: '3px solid #a855f7' }
: {}),
}}
onClick={() => setDetailId(t.id)}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
}}
>
<span style={{ fontWeight: 600, fontSize: '15px' }}>
{t.title}
</span>
<div
style={{
display: 'flex',
gap: '4px',
alignItems: 'center',
}}
>
{t.focused && (
<span
style={{
fontSize: '10px',
fontWeight: 600,
padding: '2px 6px',
borderRadius: '8px',
background: 'rgba(168,85,247,0.2)',
color: '#a855f7',
}}
>
focused
</span>
)}
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: '10px',
background: sc.bg,
color: sc.text,
}}
>
{t.status}
</span>
</div>
</div>
{t.description && (
<div
style={{
color: 'var(--hint)',
fontSize: '13px',
marginTop: '4px',
lineHeight: 1.4,
}}
>
{t.description.substring(0, 120)}
</div>
)}
<div
style={{
color: 'var(--hint)',
fontSize: '11px',
marginTop: '6px',
}}
>
{t.document_count} docs{' \u00B7 \u23F1 '}
{(t.interval === '24h' ? '1d' : t.interval) || '1d'}
{t.last_researched_at &&
' \u00B7 last: ' +
new Date(t.last_researched_at).toLocaleDateString()}
</div>
</div>
);
})
)}
</div>
);
}
function NewTaskForm({
onCreated,
onCancel,
}: {
onCreated: () => void;
onCancel: () => void;
}) {
const [title, setTitle] = useState('');
const [desc, setDesc] = useState('');
const handleCreate = async () => {
const t = title.trim();
if (!t) return;
try {
await apiPost('/miniapp/api/research', {
title: t,
description: desc.trim(),
});
onCreated();
} catch {}
};
return (
<div class="card glass" style={{ padding: '12px', marginBottom: '12px' }}>
<input
class="send-input glass glass-interactive"
placeholder="Task title..."
value={title}
onInput={(e) => setTitle((e.target as HTMLInputElement).value)}
style={{ width: '100%', marginBottom: '8px' }}
/>
<textarea
class="send-input glass glass-interactive"
placeholder="Description (optional)..."
value={desc}
onInput={(e) => setDesc((e.target as HTMLTextAreaElement).value)}
style={{
width: '100%',
minHeight: '60px',
resize: 'vertical',
marginBottom: '8px',
borderRadius: '12px',
padding: '10px 16px',
}}
/>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button
class="send-btn"
style={{
padding: '6px 14px',
fontSize: '13px',
background: 'var(--hint)',
}}
onClick={onCancel}
>
Cancel
</button>
<button
class="send-btn"
style={{ padding: '6px 14px', fontSize: '13px' }}
onClick={handleCreate}
>
Create
</button>
</div>
</div>
);
}
function TaskDetail({
taskId,
onBack,
}: {
taskId: string;
onBack: () => void;
}) {
const [task, setTask] = useState<any>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await apiFetch('/miniapp/api/research/' + taskId);
setTask(data);
} catch {}
setLoading(false);
}, [taskId]);
useEffect(() => {
load();
}, [taskId]);
const handleAction = async (action: string) => {
try {
await apiPost('/miniapp/api/research/' + taskId, { action });
load();
} catch {}
};
const handleFocus = async (recall: boolean) => {
try {
await apiPost('/miniapp/api/research/focus', {
action: recall ? 'recall' : 'forget',
task_id: taskId,
});
load();
} catch {}
};
const handleInterval = async (interval: string) => {
try {
await apiPost('/miniapp/api/research/' + taskId, {
action: 'set_interval',
interval,
});
load();
} catch {}
};
if (loading || !task) {
return (
<div class="card glass">
<button class="git-back-btn" onClick={onBack}>
{'\u2039'} Back
</button>
<div class="loading">Loading...</div>
</div>
);
}
const sc = STATUS_COLORS[task.status] || STATUS_COLORS.pending;
const canCancel = task.status === 'pending' || task.status === 'active';
const canReopen = task.status === 'completed' || task.status === 'failed';
const curInterval = (task.interval === '24h' ? '1d' : task.interval) || '1d';
return (
<>
<button class="git-back-btn" onClick={onBack}>
{'\u2039'} Back
</button>
<div class="card glass">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
marginBottom: '8px',
}}
>
<span style={{ fontWeight: 700, fontSize: '17px' }}>
{task.title}
</span>
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: '10px',
background: sc.bg,
color: sc.text,
}}
>
{task.status}
</span>
</div>
{task.description && (
<div
style={{
color: 'var(--hint)',
fontSize: '13px',
lineHeight: 1.5,
marginBottom: '8px',
whiteSpace: 'pre-wrap',
}}
>
{task.description}
</div>
)}
<div
style={{
color: 'var(--hint)',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<span>Interval:</span>
<select
value={curInterval}
onChange={(e) =>
handleInterval((e.target as HTMLSelectElement).value)
}
style={{
fontSize: '11px',
padding: '1px 4px',
borderRadius: '6px',
background: 'var(--tab-track-bg)',
color: 'var(--text)',
border: '1px solid var(--glass-divider)',
outline: 'none',
}}
>
{['30m', '1h', '6h', '12h', '1d', '3d', '7d'].map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
<span>
{task.last_researched_at
? '\u00B7 Last: ' +
new Date(task.last_researched_at).toLocaleString()
: '\u00B7 Not yet researched'}
</span>
</div>
<div
style={{
color: 'var(--hint)',
fontSize: '11px',
marginTop: '2px',
}}
>
Created: {new Date(task.created_at).toLocaleString()}
{task.completed_at &&
' \u00B7 Completed: ' +
new Date(task.completed_at).toLocaleString()}
</div>
<div style={{ marginTop: '10px', display: 'flex', gap: '8px' }}>
{task.focused ? (
<button
class="worktree-btn dispose"
style={{
background: 'rgba(168,85,247,0.15)',
color: '#a855f7',
borderColor: '#a855f7',
}}
onClick={() => handleFocus(false)}
>
Forget
</button>
) : (
<button
class="worktree-btn merge"
style={{
background: 'rgba(168,85,247,0.15)',
color: '#a855f7',
borderColor: '#a855f7',
}}
onClick={() => handleFocus(true)}
>
Recall
</button>
)}
{task.status === 'pending' && (
<button
class="worktree-btn merge"
onClick={() => handleAction('activate')}
>
Activate
</button>
)}
{task.status === 'active' && (
<button
class="worktree-btn merge"
style={{
background: 'rgba(34,197,94,0.15)',
color: '#22c55e',
borderColor: '#22c55e',
}}
onClick={() => handleAction('complete')}
>
Complete
</button>
)}
{canCancel && (
<button
class="worktree-btn dispose"
onClick={() => handleAction('cancel')}
>
Cancel
</button>
)}
{canReopen && (
<button
class="worktree-btn merge"
onClick={() => handleAction('reopen')}
>
Reopen
</button>
)}
</div>
</div>
<div class="card-title" style={{ marginTop: '12px' }}>
Documents ({task.documents.length})
</div>
{task.documents.length === 0 ? (
<div class="empty-state" style={{ padding: '24px' }}>
No documents yet.
</div>
) : (
task.documents.map((d: any) => (
<DocCard key={d.id} doc={d} taskId={taskId} />
))
)}
</>
);
}
function DocCard({ doc, taskId }: { doc: any; taskId: string }) {
const [expanded, setExpanded] = useState(false);
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const toggle = async () => {
if (expanded) {
setExpanded(false);
return;
}
setExpanded(true);
if (content !== null) return;
setLoading(true);
try {
const data = await apiFetch(
'/miniapp/api/research/' + taskId + '/doc/' + doc.id,
);
setContent(data.content);
} catch {
setContent('Failed to load document.');
}
setLoading(false);
};
return (
<div
class="card glass"
style={{ padding: '12px', cursor: 'pointer' }}
onClick={toggle}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
color: 'var(--hint)',
fontFamily: 'monospace',
fontSize: '12px',
}}
>
#{doc.seq}
</span>
<span style={{ fontWeight: 600, fontSize: '14px', flex: 1 }}>
{doc.title}
</span>
<span
style={{
fontSize: '10px',
padding: '2px 6px',
borderRadius: '8px',
background: 'var(--tab-track-bg)',
color: 'var(--hint)',
}}
>
{doc.doc_type}
</span>
</div>
{doc.summary && (
<div
style={{
color: 'var(--hint)',
fontSize: '12px',
marginTop: '4px',
}}
>
{doc.summary}
</div>
)}
{expanded && (
<div
style={{
marginTop: '8px',
borderTop: '1px solid var(--glass-divider)',
paddingTop: '8px',
}}
>
{loading ? (
<div class="loading" style={{ padding: '12px' }}>
Loading...
</div>
) : content ? (
<div
class="md-rendered"
style={{ maxHeight: '50vh', overflow: 'auto', padding: '8px 0' }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
) : null}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,106 @@
import { useEffect, useState } from 'preact/hooks';
import type { SSEHook } from '../../hooks/use-sse';
import { apiFetch, sendCommand } from '../../hooks/use-api';
import { isFresh, flashSent } from '../../utils';
interface SkillsSectionProps {
active: boolean;
sse: SSEHook;
}
export function SkillsSection({ active, sse }: SkillsSectionProps) {
const [skills, setSkills] = useState<any[] | null>(null);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<string | null>(null);
const [msg, setMsg] = useState('');
const loadSkills = async () => {
setLoading(true);
try {
const data = await apiFetch('/miniapp/api/skills');
setSkills(data);
} catch {}
setLoading(false);
};
useEffect(() => {
if (sse.skills) {
setSkills(sse.skills);
setLoading(false);
}
}, [sse.skills]);
useEffect(() => {
if (active && !isFresh(sse.lastUpdate, 'skills')) loadSkills();
}, [active]);
const handleSend = async () => {
if (!selected) return;
const m = msg.trim();
const cmd = m ? '/skill ' + selected + ' ' + m : '/skill ' + selected;
const ok = await sendCommand(cmd);
if (ok) setMsg('');
};
return (
<div class="card glass">
<div class="card-title">Skills</div>
{loading && !skills ? (
<div class="loading" style={{ padding: '12px' }}>
Loading skills...
</div>
) : !skills || skills.length === 0 ? (
<div
class="empty-state"
style={{ padding: '12px 0' }}
>
No skills installed.
</div>
) : (
<>
{skills.map((s) => (
<div
key={s.name}
class={`skill-item glass glass-interactive${selected === s.name ? ' selected' : ''}`}
onClick={() => {
setSelected(selected === s.name ? null : s.name);
}}
>
<div class="skill-body">
<div class="skill-name">{s.name}</div>
<div class="skill-desc">
{s.description || 'No description'}
</div>
<span class="skill-source">{s.source}</span>
</div>
<span class="skill-arrow">{'\u203A'}</span>
</div>
))}
{selected && (
<div
style={{
display: 'flex',
gap: '8px',
marginTop: '10px',
}}
>
<input
class="send-input glass glass-interactive"
placeholder={`Message for /${selected}...`}
value={msg}
onInput={(e) =>
setMsg((e.target as HTMLInputElement).value)
}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
style={{ flex: 1 }}
/>
<button class="send-btn" onClick={handleSend}>
Send
</button>
</div>
)}
</>
)}
</div>
);
}

View file

@ -0,0 +1,23 @@
import { useEffect, useState } from 'preact/hooks';
import type { SSEHook } from '../../hooks/use-sse';
import { isFresh } from '../../utils';
import { SkillsSection } from './skills-section';
import { CommandsSection } from './commands-section';
import { LogsSection } from './logs-section';
import { ResearchSection } from './research-section';
interface ToolsTabProps {
active: boolean;
sse: SSEHook;
}
export function ToolsTab({ active, sse }: ToolsTabProps) {
return (
<>
<SkillsSection active={active} sse={sse} />
<CommandsSection />
<LogsSection active={active} />
<ResearchSection active={active} />
</>
);
}

View file

@ -0,0 +1,289 @@
import { useState, useCallback } from 'preact/hooks';
import { apiFetch, apiPost } from '../../hooks/use-api';
import { escapeHtml } from '../../utils';
interface GitSectionProps {
repos: any[] | null;
worktrees: any[];
onReload: () => void;
}
export function GitSection({ repos, worktrees, onReload }: GitSectionProps) {
const [detailRepo, setDetailRepo] = useState<any>(null);
const [detailName, setDetailName] = useState<string | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const loadDetail = useCallback(async (name: string) => {
setDetailName(name);
setLoadingDetail(true);
try {
const data = await apiFetch(
'/miniapp/api/git?repo=' + encodeURIComponent(name),
);
setDetailRepo(data);
} catch {}
setLoadingDetail(false);
}, []);
const goBack = useCallback(() => {
setDetailRepo(null);
setDetailName(null);
onReload();
}, [onReload]);
if (detailName) {
return (
<RepoDetail
repo={detailRepo}
name={detailName}
loading={loadingDetail}
onBack={goBack}
/>
);
}
return (
<>
<Worktrees items={worktrees} onReload={onReload} />
<RepoList repos={repos} onSelect={loadDetail} />
</>
);
}
function Worktrees({
items,
onReload,
}: {
items: any[];
onReload: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const handleAction = async (
action: string,
name: string,
isDirty: boolean,
) => {
let force = false;
if (action === 'merge') {
if (!confirm('Merge "' + name + '" into base branch?')) return;
} else if (action === 'dispose') {
if (isDirty) {
if (
!confirm(
'"' +
name +
'" has uncommitted changes. Force dispose and auto-commit before removal?',
)
)
return;
force = true;
} else if (!confirm('Dispose worktree "' + name + '"?')) {
return;
}
}
setBusy(name + ':' + action);
try {
await apiPost('/miniapp/api/worktrees', { action, name, force });
onReload();
} catch (err: any) {
alert(err.message || 'Action failed');
}
setBusy(null);
};
return (
<div class="card glass">
<div class="card-title">Worktrees</div>
{items.length === 0 ? (
<div class="empty-state" style={{ padding: '12px 0 4px' }}>
No active worktrees.
</div>
) : (
<div class="worktree-list">
{items.map((wt) => {
let last = '(no commits)';
if (wt.last_commit_hash) {
last =
wt.last_commit_hash +
' ' +
(wt.last_commit_subject || '');
if (wt.last_commit_age) last += ' (' + wt.last_commit_age + ')';
}
return (
<div
class={`worktree-item${wt.has_uncommitted ? ' dirty' : ''}`}
key={wt.name}
>
<div class="worktree-main">
<div class="worktree-name-row">
<span class="worktree-name">{wt.name}</span>
{wt.has_uncommitted ? (
<span class="worktree-dirty">DIRTY</span>
) : (
<span class="worktree-clean">CLEAN</span>
)}
</div>
<div class="worktree-branch">{wt.branch || '?'}</div>
<div class="worktree-last">{last}</div>
</div>
<div class="worktree-actions">
<button
class="worktree-btn merge"
disabled={busy === wt.name + ':merge'}
onClick={() =>
handleAction('merge', wt.name, wt.has_uncommitted)
}
>
{busy === wt.name + ':merge' ? 'Merging...' : 'Merge'}
</button>
<button
class="worktree-btn dispose"
disabled={busy === wt.name + ':dispose'}
onClick={() =>
handleAction('dispose', wt.name, wt.has_uncommitted)
}
>
{busy === wt.name + ':dispose'
? 'Disposing...'
: 'Dispose'}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function RepoList({
repos,
onSelect,
}: {
repos: any[] | null;
onSelect: (name: string) => void;
}) {
if (!repos || repos.length === 0) {
return (
<div class="empty-state" style={{ marginTop: '12px' }}>
No git repositories found.
</div>
);
}
return (
<>
<div
style={{
padding: '10px 4px 8px',
fontSize: '12px',
color: 'var(--hint)',
}}
>
Repositories
</div>
{repos.map((r) => (
<div
key={r.name}
class="git-repo-item glass glass-interactive"
onClick={() => onSelect(r.name)}
>
<div class="git-repo-body">
<div class="git-repo-name">{r.name}</div>
<div class="git-repo-branch">{r.branch || '?'}</div>
</div>
<span class="git-repo-arrow">{'\u203A'}</span>
</div>
))}
</>
);
}
function RepoDetail({
repo,
name,
loading,
onBack,
}: {
repo: any;
name: string;
loading: boolean;
onBack: () => void;
}) {
if (loading || !repo) {
return (
<>
<button class="git-back-btn" onClick={onBack}>
{'\u2190'} {name}
</button>
<div class="loading">Loading {name}...</div>
</>
);
}
return (
<>
<button class="git-back-btn" onClick={onBack}>
{'\u2190'} {repo.name || name}
</button>
<div class="card glass">
<div class="card-title">
{repo.name} &mdash; {repo.branch || '?'}
</div>
{repo.modified && repo.modified.length > 0 && (
<>
<div
style={{
padding: '4px 12px 8px',
fontSize: '12px',
color: 'var(--hint)',
}}
>
Changes ({repo.modified.length})
</div>
{repo.modified.map((f: any, i: number) => (
<div class="git-commit" key={i}>
<span
class={`git-status git-status-${f.status === '??' ? 'u' : f.status.toLowerCase()}`}
>
{f.status}
</span>
<span class="git-subject">{f.path}</span>
</div>
))}
</>
)}
{repo.commits && repo.commits.length > 0 ? (
<>
<div
style={{
padding: '4px 12px 8px',
fontSize: '12px',
color: 'var(--hint)',
}}
>
Commits
</div>
{repo.commits.map((c: any, i: number) => (
<div class="git-commit" key={i}>
<span class="git-hash">{c.hash}</span>
<span class="git-subject">{c.subject}</span>
<span class="git-meta">{c.date}</span>
</div>
))}
</>
) : (
<div style={{ padding: '12px', color: 'var(--hint)' }}>
No commits found.
</div>
)}
</div>
</>
);
}

View file

@ -0,0 +1,386 @@
import { useState } from 'preact/hooks';
import { apiFetch } from '../../hooks/use-api';
import { formatAge, formatTokens, formatSessionLabel, escapeHtml } from '../../utils';
interface SessionSectionProps {
sessions: any[] | null;
stats: any;
graph: any;
context: any;
}
export function SessionSection({
sessions,
stats,
graph,
context,
}: SessionSectionProps) {
return (
<>
<ActiveSessions sessions={sessions} />
<StatsCards stats={stats} />
{context && <ContextCard context={context} />}
{graph && <SessionGraph graph={graph} />}
</>
);
}
function ActiveSessions({ sessions }: { sessions: any[] | null }) {
if (!sessions || sessions.length === 0) {
return (
<div class="card glass">
<div class="card-title">Active Sessions</div>
<div style={{ color: 'var(--hint)', fontSize: '13px' }}>
No active sessions
</div>
</div>
);
}
return (
<div class="card glass">
<div class="card-title">Active Sessions</div>
{sessions.map((s) => {
const label = formatSessionLabel(s.session_key);
const isHeartbeat = s.session_key.startsWith('heartbeat:');
const latestMsg = s.latest_message || null;
return (
<div
key={s.session_key}
style={{
padding: '8px 0',
borderBottom: '1px solid var(--secondary-bg)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<span
style={{
color: isHeartbeat ? 'var(--link)' : 'var(--done)',
fontSize: '10px',
}}
>
{isHeartbeat ? '\u{1F916}' : '\u25CF'}
</span>
<span style={{ fontWeight: 600, fontSize: '14px', flex: 1 }}>
{label}
</span>
<span
style={{
color: 'var(--hint)',
fontSize: '12px',
flexShrink: 0,
}}
>
{s.turn_count || 0} turns
</span>
<span
style={{
marginLeft: '4px',
color: 'var(--hint)',
fontSize: '12px',
}}
>
{formatAge(s.age_sec)}
</span>
</div>
{latestMsg && (
<div
style={{
color: 'var(--hint)',
fontSize: '12px',
paddingLeft: '22px',
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{latestMsg}
</div>
)}
</div>
);
})}
</div>
);
}
function StatsCards({ stats }: { stats: any }) {
if (!stats || stats.status === 'stats not enabled') {
return (
<div class="empty-state">
Stats tracking not enabled.
<br />
Start gateway with --stats flag.
</div>
);
}
const since = stats.since
? new Date(stats.since).toLocaleDateString()
: 'N/A';
const today = stats.today || {};
return (
<>
<div class="card glass">
<div class="card-title">Today</div>
<div class="stat-row">
<span class="stat-label">Prompts</span>
<span class="stat-value">{today.prompts || 0}</span>
</div>
<div class="stat-row">
<span class="stat-label">Requests</span>
<span class="stat-value">{today.requests || 0}</span>
</div>
<div class="stat-row">
<span class="stat-label">Tokens</span>
<span class="stat-value">
{formatTokens(today.total_tokens || 0)}
</span>
</div>
</div>
<div class="card glass">
<div class="card-title">All Time (since {since})</div>
<div class="stat-row">
<span class="stat-label">Prompts</span>
<span class="stat-value">{stats.total_prompts || 0}</span>
</div>
<div class="stat-row">
<span class="stat-label">Requests</span>
<span class="stat-value">{stats.total_requests || 0}</span>
</div>
<div class="stat-row">
<span class="stat-label">Total Tokens</span>
<span class="stat-value">
{formatTokens(stats.total_tokens || 0)}
</span>
</div>
<div class="stat-row">
<span class="stat-label">Prompt Tokens</span>
<span class="stat-value">
{formatTokens(stats.total_prompt_tokens || 0)}
</span>
</div>
<div class="stat-row">
<span class="stat-label">Completion Tokens</span>
<span class="stat-value">
{formatTokens(stats.total_completion_tokens || 0)}
</span>
</div>
</div>
</>
);
}
function ContextCard({ context }: { context: any }) {
const [showPrompt, setShowPrompt] = useState(false);
const [promptText, setPromptText] = useState<string | null>(null);
const [promptLoading, setPromptLoading] = useState(false);
const togglePrompt = async () => {
if (showPrompt) {
setShowPrompt(false);
return;
}
setPromptLoading(true);
try {
const data = await apiFetch('/miniapp/api/prompt');
setPromptText(data.prompt || '(empty)');
setShowPrompt(true);
} catch {}
setPromptLoading(false);
};
const wd = context.work_dir || '\u2014';
const pwd = context.plan_work_dir || '\u2014';
const ws = context.workspace || '\u2014';
return (
<div class="card glass">
<div class="card-title">Context</div>
<div style={{ fontSize: '12px' }}>
<div class="stat-row">
<span class="stat-label">workDir</span>
<span
class="stat-value"
style={{
fontSize: '12px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
title={wd}
>
{wd}
</span>
</div>
<div class="stat-row">
<span class="stat-label">planWorkDir</span>
<span
class="stat-value"
style={{
fontSize: '12px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
title={pwd}
>
{pwd}
</span>
</div>
<div class="stat-row">
<span class="stat-label">workspace</span>
<span
class="stat-value"
style={{
fontSize: '12px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
title={ws}
>
{ws}
</span>
</div>
</div>
{context.bootstrap && context.bootstrap.length > 0 && (
<div style={{ marginTop: '8px' }}>
{context.bootstrap.map((b: any, i: number) => {
const path = b.path || '\u2014';
const scope = b.scope === 'global' ? 'global' : 'project';
return (
<div
key={i}
style={{
display: 'flex',
gap: '8px',
padding: '2px 0',
fontSize: '12px',
}}
>
<span
style={{
minWidth: '90px',
fontWeight: 600,
color: b.path ? 'var(--text)' : 'var(--hint)',
}}
>
{b.name}
</span>
<span
style={{
color: 'var(--hint)',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={path}
>
{path}
</span>
<span style={{ color: 'var(--hint)', fontSize: '11px' }}>
{scope}
</span>
</div>
);
})}
</div>
)}
<div style={{ marginTop: '8px', textAlign: 'center' }}>
<button
onClick={togglePrompt}
style={{
background: 'var(--secondary-bg)',
color: 'var(--text)',
border: 'none',
padding: '6px 12px',
borderRadius: '8px',
fontSize: '12px',
cursor: 'pointer',
}}
>
{promptLoading
? 'Loading...'
: showPrompt
? 'Hide System Prompt'
: 'Show System Prompt'}
</button>
</div>
{showPrompt && promptText && (
<pre
style={{
marginTop: '8px',
fontSize: '11px',
maxHeight: '400px',
overflow: 'auto',
background: 'var(--secondary-bg)',
padding: '8px',
borderRadius: '6px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{promptText}
</pre>
)}
</div>
);
}
function SessionGraph({ graph }: { graph: any }) {
if (!graph || !graph.nodes || graph.nodes.length === 0) return null;
// Build parent->children map
const childrenMap: Record<string, string[]> = {};
const nodeMap: Record<string, any> = {};
const roots: string[] = [];
graph.nodes.forEach((n: any) => {
childrenMap[n.key] = [];
nodeMap[n.key] = n;
});
graph.edges.forEach((e: any) => {
if (childrenMap[e.from]) childrenMap[e.from].push(e.to);
});
graph.nodes.forEach((n: any) => {
const isChild = graph.edges.some((e: any) => e.to === n.key);
if (!isChild) roots.push(n.key);
});
function renderNode(key: string): any {
const n = nodeMap[key];
if (!n) return null;
const icon = n.status === 'completed' ? '\u2713' : '\u25CF';
const iconClass = n.status === 'completed' ? 'completed' : 'active';
const label = n.label || n.short_key || n.key;
const kids = childrenMap[key] || [];
return (
<li class="session-tree-node" key={key}>
<span class={`session-tree-icon ${iconClass}`}>{icon}</span>
<span class="session-tree-label">{label}</span>
<span class="session-tree-meta">turns={n.turn_count}</span>
{kids.length > 0 && (
<ul class="session-tree-children">
{kids.map(renderNode)}
</ul>
)}
</li>
);
}
return (
<div class="card glass">
<div class="card-title">Session Graph</div>
<ul class="session-tree">{roots.map(renderNode)}</ul>
</div>
);
}

View file

@ -0,0 +1,75 @@
import { useEffect, useState, useCallback } from 'preact/hooks';
import type { SSEHook } from '../../hooks/use-sse';
import { apiFetch } from '../../hooks/use-api';
import { isFresh } from '../../utils';
import { GitSection } from './git-section';
import { SessionSection } from './session-section';
interface WorkTabProps {
active: boolean;
sse: SSEHook;
}
export function WorkTab({ active, sse }: WorkTabProps) {
const [gitRepos, setGitRepos] = useState<any[] | null>(null);
const [worktrees, setWorktrees] = useState<any[]>([]);
const [sessions, setSessions] = useState<any[] | null>(null);
const [stats, setStats] = useState<any>(null);
const [graph, setGraph] = useState<any>(null);
const [context, setContext] = useState<any>(null);
const [loading, setLoading] = useState(true);
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [gitData, wtData, sessionData, sessionsData, ctxData, graphData] =
await Promise.all([
apiFetch('/miniapp/api/git'),
apiFetch('/miniapp/api/worktrees').catch(() => []),
apiFetch('/miniapp/api/session'),
apiFetch('/miniapp/api/sessions').catch(() => []),
apiFetch('/miniapp/api/context').catch(() => null),
apiFetch('/miniapp/api/sessions/graph').catch(() => null),
]);
setGitRepos(gitData);
setWorktrees(wtData);
setStats(sessionData);
setSessions(sessionsData);
setContext(ctxData);
setGraph(graphData);
} catch {}
setLoading(false);
}, []);
// SSE session updates
useEffect(() => {
if (sse.session) {
setSessions(sse.session.sessions || []);
setStats(sse.session.stats || null);
if (sse.session.graph) setGraph(sse.session.graph);
}
}, [sse.session]);
useEffect(() => {
if (sse.context) setContext(sse.context);
}, [sse.context]);
useEffect(() => {
if (active) loadAll();
}, [active]);
if (loading && !gitRepos && !sessions)
return <div class="loading">Loading...</div>;
return (
<>
<GitSection repos={gitRepos} worktrees={worktrees} onReload={loadAll} />
<SessionSection
sessions={sessions}
stats={stats}
graph={graph}
context={context}
/>
</>
);
}

View file

@ -0,0 +1,48 @@
const API_BASE = location.origin;
function getInitData(): string {
return window.Telegram?.WebApp?.initData || '';
}
export async function apiFetch<T = any>(path: string): Promise<T> {
const sep = path.includes('?') ? '&' : '?';
const res = await fetch(
API_BASE + path + sep + 'initData=' + encodeURIComponent(getInitData()),
);
if (!res.ok) throw new Error('API error: ' + res.status);
return res.json();
}
export async function apiPost<T = any>(
path: string,
body: Record<string, any>,
): Promise<T> {
const sep = path.includes('?') ? '&' : '?';
const res = await fetch(
API_BASE + path + sep + 'initData=' + encodeURIComponent(getInitData()),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
if (!res.ok) {
let errMsg = 'API error: ' + res.status;
try {
const data = await res.json();
if (data.error) errMsg = data.error;
} catch {}
throw new Error(errMsg);
}
return res.json();
}
export async function sendCommand(cmd: string): Promise<boolean> {
if (!cmd.startsWith('/')) return false;
try {
await apiPost('/miniapp/api/command', { command: cmd });
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from 'preact/hooks';
export interface SSEData {
plan: any | null;
session: any | null;
skills: any[] | null;
dev: any | null;
context: any | null;
prompt: string | null;
}
export interface SSEHook extends SSEData {
lastUpdate: Record<string, number>;
}
export function useSSE(): SSEHook {
const [data, setData] = useState<SSEData>({
plan: null,
session: null,
skills: null,
dev: null,
context: null,
prompt: null,
});
const lastUpdate = useRef<Record<string, number>>({});
useEffect(() => {
const initData = window.Telegram?.WebApp?.initData || '';
const url =
location.origin +
'/miniapp/api/events?initData=' +
encodeURIComponent(initData);
const es = new EventSource(url);
es.addEventListener('plan', (e: any) => {
try {
const d = JSON.parse(e.data);
lastUpdate.current.plan = Date.now();
setData((prev) => ({ ...prev, plan: d }));
} catch {}
});
es.addEventListener('session', (e: any) => {
try {
const d = JSON.parse(e.data);
lastUpdate.current.session = Date.now();
setData((prev) => ({ ...prev, session: d }));
} catch {}
});
es.addEventListener('skills', (e: any) => {
try {
const d = JSON.parse(e.data);
lastUpdate.current.skills = Date.now();
setData((prev) => ({ ...prev, skills: d }));
} catch {}
});
es.addEventListener('dev', (e: any) => {
try {
const d = JSON.parse(e.data);
lastUpdate.current.dev = Date.now();
setData((prev) => ({ ...prev, dev: d }));
} catch {}
});
es.addEventListener('context', (e: any) => {
try {
const d = JSON.parse(e.data);
setData((prev) => ({ ...prev, context: d }));
} catch {}
});
es.addEventListener('prompt', (e: any) => {
try {
const d = JSON.parse(e.data);
setData((prev) => ({ ...prev, prompt: d.prompt || null }));
} catch {}
});
return () => es.close();
}, []);
return { ...data, lastUpdate: lastUpdate.current };
}

View file

@ -0,0 +1,22 @@
import './styles.css';
import 'highlight.js/styles/github-dark.min.css';
import { render } from 'preact';
import { App } from './app';
declare global {
interface Window {
Telegram: { WebApp: any };
ORCH_ENABLED: boolean;
MAP_POSITIONS: any;
loadMapAsset: (cb: () => void) => void;
drawMap: (ctx: CanvasRenderingContext2D) => void;
}
}
const tg = window.Telegram.WebApp;
tg.ready();
const root = document.getElementById('app');
if (root) {
render(<App />, root);
}

View file

@ -0,0 +1,47 @@
import { marked } from 'marked';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import python from 'highlight.js/lib/languages/python';
import go from 'highlight.js/lib/languages/go';
import bash from 'highlight.js/lib/languages/bash';
import json from 'highlight.js/lib/languages/json';
import yaml from 'highlight.js/lib/languages/yaml';
import typescript from 'highlight.js/lib/languages/typescript';
import sql from 'highlight.js/lib/languages/sql';
import xml from 'highlight.js/lib/languages/xml';
import css from 'highlight.js/lib/languages/css';
import markdown from 'highlight.js/lib/languages/markdown';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('js', javascript);
hljs.registerLanguage('python', python);
hljs.registerLanguage('py', python);
hljs.registerLanguage('go', go);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('sh', bash);
hljs.registerLanguage('json', json);
hljs.registerLanguage('yaml', yaml);
hljs.registerLanguage('yml', yaml);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('ts', typescript);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('html', xml);
hljs.registerLanguage('css', css);
hljs.registerLanguage('markdown', markdown);
hljs.registerLanguage('md', markdown);
marked.setOptions({
highlight: function (code: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
},
breaks: true,
gfm: true,
});
export function renderMarkdown(text: string): string {
return marked.parse(text) as string;
}

View file

@ -76,7 +76,7 @@
top: 2px;
bottom: 2px;
left: 2px;
width: calc(100% / var(--tab-count, 8) - 2px);
width: calc(100% / var(--tab-count, 4) - 2px);
border-radius: 8px;
background: var(--tab-pill-bg);
box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08);

View file

@ -0,0 +1,53 @@
export function escapeHtml(s: string | null | undefined): string {
if (!s) return '';
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function escapeAttr(s: string | null | undefined): string {
if (!s) return '';
return s
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function formatAge(sec: number): string {
if (sec < 60) return sec + 's ago';
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
return Math.floor(sec / 3600) + 'h ago';
}
export function formatTokens(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
}
export function flashSent(el: HTMLElement) {
el.classList.add('sent');
setTimeout(() => el.classList.remove('sent'), 600);
}
export function formatSessionLabel(key: string): string {
if (key.startsWith('heartbeat:')) return 'Heartbeat';
const parts = key.split(':');
if (parts.length >= 4) {
const channel = parts[2]; // "telegram"
const scope = parts[3]; // "group" or "dm"
return capitalize(channel) + ' ' + capitalize(scope);
}
if (parts.length > 2) return parts.slice(2).join(':');
return key;
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
export function isFresh(lastUpdate: Record<string, number>, key: string, ms = 5000): boolean {
return !!lastUpdate[key] && Date.now() - lastUpdate[key] < ms;
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowJs": true
},
"include": ["src"]
}

View file

@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['src/**/*.test.js'],
include: ['src/**/*.test.{js,ts,tsx}'],
},
});

View file

@ -76,7 +76,7 @@ body {
.tab-indicator {
position: absolute;
width: calc(100% / var(--tab-count, 8) - 2px);
width: calc(100% / var(--tab-count, 4) - 2px);
background: var(--tab-pill-bg);
z-index: 0;
border-radius: 8px;

File diff suppressed because it is too large Load diff

View file

@ -11,179 +11,7 @@
</head>
<body>
<div class="tabs">
<div class="tabs-inner">
<div class="tab-indicator"></div>
<button class="tab active" data-panel="plan">Plan</button>
<button class="tab" data-panel="git">Git</button>
<button class="tab" data-panel="skills">Skills</button>
<button class="tab" data-panel="session">Session</button>
<button class="tab" data-panel="config">Config</button>
<button class="tab" data-panel="research">Research</button>
<button class="tab" data-panel="dev">Dev</button>
<button class="tab" data-panel="orch">Orch</button>
</div>
</div>
<div id="plan" class="panel active">
<div class="loading" id="plan-loading">Loading plan...</div>
<div id="plan-content" class="hidden"></div>
</div>
<div id="skills" class="panel">
<div class="loading" id="skills-loading">Loading skills...</div>
<div id="skills-list" class="hidden"></div>
</div>
<div id="session" class="panel">
<div class="loading" id="session-loading">Loading session...</div>
<div id="session-content" class="hidden"></div>
<div id="session-graph" class="hidden"></div>
<div id="context-content"></div>
</div>
<div id="config" class="panel">
<div class="card glass">
<div class="card-title">Quick Commands</div>
<div class="cmd-tiles">
<button class="cmd-tile glass glass-interactive" data-cmd="/session">/session</button>
<button class="cmd-tile glass glass-interactive" data-cmd="/skills">/skills</button>
<button class="cmd-tile glass glass-interactive" data-cmd="/plan clear">/plan clear</button>
</div>
</div>
<div class="card glass">
<div class="card-title">Custom Command</div>
<div style="display:flex;gap:8px;margin-top:8px">
<input id="custom-cmd" class="send-input glass glass-interactive" placeholder="/command args..." style="flex:1">
<button class="send-btn" onclick="sendCustomCmd()">Send</button>
</div>
</div>
<div class="card glass" id="logs-section">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span class="card-title" style="margin:0">Logs</span>
<span id="logs-status" class="dev-target-dot"></span>
</div>
<div class="log-filter-chips">
<button class="log-filter-chip active" data-component="">All</button>
<button class="log-filter-chip" data-component="telego">Telego</button>
<button class="log-filter-chip" data-component="dev-console">Console</button>
</div>
<div id="logs-content"></div>
<div class="log-pagination">
<button class="log-page-btn" id="logs-page-prev" type="button">Newer</button>
<span id="logs-page-info">1/1 (0)</span>
<button class="log-page-btn" id="logs-page-next" type="button">Older</button>
</div>
<div class="log-actions">
<button class="log-snap-btn" onclick="saveLogSnapshot()">Save Snapshot</button>
</div>
</div>
</div>
<div id="research" class="panel">
<div id="research-list-view">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<span class="card-title" style="margin:0">Research Tasks</span>
<button class="send-btn" style="padding:6px 14px;font-size:13px" onclick="showNewTaskForm()">+ New</button>
</div>
<div id="research-new-form" class="hidden" style="margin-bottom:12px">
<div class="card glass" style="padding:12px">
<input id="research-title" class="send-input glass glass-interactive" placeholder="Task title..." style="width:100%;margin-bottom:8px">
<textarea id="research-desc" class="send-input glass glass-interactive" placeholder="Description (optional)..." style="width:100%;min-height:60px;resize:vertical;margin-bottom:8px;border-radius:12px;padding:10px 16px"></textarea>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="send-btn" style="padding:6px 14px;font-size:13px;background:var(--hint)" onclick="hideNewTaskForm()">Cancel</button>
<button class="send-btn" style="padding:6px 14px;font-size:13px" onclick="createResearchTask()">Create</button>
</div>
</div>
</div>
<div class="loading hidden" id="research-loading">Loading tasks...</div>
<div id="research-tasks"></div>
</div>
<div id="research-detail-view" class="hidden">
<button class="git-back-btn" onclick="showResearchList()">&#8249; Back</button>
<div id="research-detail-content"></div>
</div>
</div>
<div id="git" class="panel">
<div class="loading" id="git-loading">Loading git log...</div>
<div id="git-content" class="hidden"></div>
</div>
<div id="dev" class="panel">
<div id="dev-header" class="dev-header">
<span id="dev-dot" class="dev-target-dot"></span>
<span class="dev-header-title">Dev Preview</span>
<span id="dev-header-target" class="dev-header-target"></span>
</div>
<div id="dev-targets-list"></div>
<div id="dev-iframe-wrap" class="hidden" style="margin-top:8px">
<div class="card glass" style="padding:0;overflow:hidden">
<iframe id="dev-iframe" src="" style="width:100%;height:70vh;border:none;border-radius:16px"></iframe>
</div>
</div>
</div>
<div id="orch" class="panel">
<div class="orch-room-row">
<div class="orch-side" id="orch-panel-left">
<div class="orch-badge alive" id="orch-badge-conductor">
<div class="orch-badge-emoji">👑</div>
<div class="orch-badge-label">CNDR</div>
<div class="orch-badge-dot"></div>
</div>
<div class="orch-badge" id="orch-badge-secretary">
<div class="orch-badge-emoji">👩‍💼</div>
<div class="orch-badge-label">SEC</div>
<div class="orch-badge-dot"></div>
</div>
<div class="orch-badge alive" id="orch-badge-heartbeat">
<div class="orch-badge-emoji">🕊️</div>
<div class="orch-badge-label">HB</div>
<div class="orch-badge-dot"></div>
</div>
</div>
<div class="orch-canvas-wrap">
<canvas id="orch-canvas" width="320" height="320"></canvas>
</div>
<div class="orch-side" id="orch-panel-right">
<div class="orch-badge" id="orch-badge-s0">
<div class="orch-badge-emoji">🔍</div>
<div class="orch-badge-label">SCOUT</div>
<div class="orch-badge-dot"></div>
</div>
<div class="orch-badge" id="orch-badge-s1">
<div class="orch-badge-emoji">📊</div>
<div class="orch-badge-label">ANLY</div>
<div class="orch-badge-dot"></div>
</div>
<div class="orch-badge" id="orch-badge-s2">
<div class="orch-badge-emoji">💻</div>
<div class="orch-badge-label">CODE</div>
<div class="orch-badge-dot"></div>
</div>
<div class="orch-badge" id="orch-badge-s3">
<div class="orch-badge-emoji">🔧</div>
<div class="orch-badge-label">WRKR</div>
<div class="orch-badge-dot"></div>
</div>
<div class="orch-badge" id="orch-badge-s4">
<div class="orch-badge-emoji">🎯</div>
<div class="orch-badge-label">CORD</div>
<div class="orch-badge-dot"></div>
</div>
</div>
</div>
<div class="orch-status">
<span class="orch-dot" id="orch-status-dot"></span>
<span id="orch-status-text">Connecting…</span>
</div>
</div>
<div class="send-bar hidden" id="send-bar">
<input id="skill-msg" class="send-input glass glass-interactive" placeholder="Message for skill...">
<button class="send-btn" id="send-skill-btn" onclick="sendSkillCommand()">Send</button>
</div>
<div id="app"></div>
<script>
window.ORCH_ENABLED = {{if .OrchEnabled}}true{{else}}false{{end}};
@ -191,5 +19,3 @@ window.ORCH_ENABLED = {{if .OrchEnabled}}true{{else}}false{{end}};
<script src="/miniapp/dist/app.js"></script>
</body>
</html>