Merge pull request #55 from dj-oyu/feature/preact-miniapp
feat: Preact migration + 4-tab Mini App layout
This commit is contained in:
commit
8136dc79cc
27 changed files with 7344 additions and 2572 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^17.0.4"
|
||||
"marked": "^17.0.4",
|
||||
"preact": "^10.25.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
pkg/miniapp/frontend/src/app.tsx
Normal file
60
pkg/miniapp/frontend/src/app.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
pkg/miniapp/frontend/src/components/dev/dev-tab.tsx
Normal file
121
pkg/miniapp/frontend/src/components/dev/dev-tab.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
513
pkg/miniapp/frontend/src/components/plan/orch-canvas.tsx
Normal file
513
pkg/miniapp/frontend/src/components/plan/orch-canvas.tsx
Normal 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 = () => {};
|
||||
}
|
||||
205
pkg/miniapp/frontend/src/components/plan/plan-tab.tsx
Normal file
205
pkg/miniapp/frontend/src/components/plan/plan-tab.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
123
pkg/miniapp/frontend/src/components/plan/slide-approve.tsx
Normal file
123
pkg/miniapp/frontend/src/components/plan/slide-approve.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
179
pkg/miniapp/frontend/src/components/tools/logs-section.tsx
Normal file
179
pkg/miniapp/frontend/src/components/tools/logs-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
584
pkg/miniapp/frontend/src/components/tools/research-section.tsx
Normal file
584
pkg/miniapp/frontend/src/components/tools/research-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
pkg/miniapp/frontend/src/components/tools/skills-section.tsx
Normal file
106
pkg/miniapp/frontend/src/components/tools/skills-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
pkg/miniapp/frontend/src/components/tools/tools-tab.tsx
Normal file
23
pkg/miniapp/frontend/src/components/tools/tools-tab.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
289
pkg/miniapp/frontend/src/components/work/git-section.tsx
Normal file
289
pkg/miniapp/frontend/src/components/work/git-section.tsx
Normal 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} — {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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
386
pkg/miniapp/frontend/src/components/work/session-section.tsx
Normal file
386
pkg/miniapp/frontend/src/components/work/session-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
pkg/miniapp/frontend/src/components/work/work-tab.tsx
Normal file
75
pkg/miniapp/frontend/src/components/work/work-tab.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
pkg/miniapp/frontend/src/hooks/use-api.ts
Normal file
48
pkg/miniapp/frontend/src/hooks/use-api.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
85
pkg/miniapp/frontend/src/hooks/use-sse.ts
Normal file
85
pkg/miniapp/frontend/src/hooks/use-sse.ts
Normal 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 };
|
||||
}
|
||||
22
pkg/miniapp/frontend/src/index.tsx
Normal file
22
pkg/miniapp/frontend/src/index.tsx
Normal 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);
|
||||
}
|
||||
47
pkg/miniapp/frontend/src/markdown.ts
Normal file
47
pkg/miniapp/frontend/src/markdown.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
53
pkg/miniapp/frontend/src/utils.ts
Normal file
53
pkg/miniapp/frontend/src/utils.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
export function escapeHtml(s: string | null | undefined): string {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function escapeAttr(s: string | null | undefined): string {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
14
pkg/miniapp/frontend/tsconfig.json
Normal file
14
pkg/miniapp/frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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}'],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
2
pkg/miniapp/static/dist/app.css
vendored
2
pkg/miniapp/static/dist/app.css
vendored
|
|
@ -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;
|
||||
|
|
|
|||
6728
pkg/miniapp/static/dist/app.js
vendored
6728
pkg/miniapp/static/dist/app.js
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -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()">‹ 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>
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue