Fix conductor mission tracking and portable fallback
(cherry picked from commit 2ce6c799b17e5bd69ecae41fcf7b85e67ebb2a8c)
This commit is contained in:
committed by
Aurora release bot
parent
3ef7a60324
commit
e2425f698e
13
docs/conductor-bug-log.md
Normal file
13
docs/conductor-bug-log.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Conductor Bug Log
|
||||||
|
|
||||||
|
## 1. Portable Conductor jobs never executed
|
||||||
|
|
||||||
|
- Symptom: portable-mode missions were being created as scheduled Hermes jobs, but the jobs stayed in `scheduled` state and never ran.
|
||||||
|
- Fix: portable Conductor now uses the existing `/api/send-stream` session-streaming path instead of the dead jobs path.
|
||||||
|
- Validation: portable API smoke test returned `started`, `chunk`, and `done` SSE events, and the build passed.
|
||||||
|
|
||||||
|
## 2. Dashboard-backed mission was running but the UI showed `0 active`
|
||||||
|
|
||||||
|
- Symptom: the conductor page launched a dashboard-backed mission, but the activity panel stayed at `0 active` even while the dashboard showed live mission sessions.
|
||||||
|
- Fix: the conductor session filter now matches recent mission-related sessions by exact key and by mission text/summary, not just `worker-*` / `conductor-*` labels.
|
||||||
|
- Validation: after reloading the conductor page, the mission showed `1 active` and the worker card appeared.
|
||||||
@@ -70,7 +70,7 @@ Working directory: {cwd}
|
|||||||
- Do NOT start servers or long-running processes
|
- Do NOT start servers or long-running processes
|
||||||
- Do NOT modify files outside your working directory
|
- Do NOT modify files outside your working directory
|
||||||
- Verify your own work before finishing — run the exit criteria commands yourself
|
- Verify your own work before finishing — run the exit criteria commands yourself
|
||||||
- Commit when done
|
- Commit only if the mission explicitly allows commits; otherwise leave changes uncommitted and report them
|
||||||
```
|
```
|
||||||
|
|
||||||
On retry, append:
|
On retry, append:
|
||||||
|
|||||||
67
src/lib/jobs-api.test.ts
Normal file
67
src/lib/jobs-api.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
findJobById,
|
||||||
|
getJobErrorText,
|
||||||
|
getLatestJobOutputText,
|
||||||
|
isFailedJobState,
|
||||||
|
isTerminalJobState,
|
||||||
|
normalizeJobState,
|
||||||
|
normalizeJobsResponse,
|
||||||
|
} from './jobs-api'
|
||||||
|
import type { HermesJob, JobOutput } from './jobs-api'
|
||||||
|
|
||||||
|
const job = {
|
||||||
|
id: 'job-1',
|
||||||
|
name: 'Example job',
|
||||||
|
prompt: 'Run the example job',
|
||||||
|
schedule: {},
|
||||||
|
enabled: true,
|
||||||
|
state: 'scheduled',
|
||||||
|
} satisfies HermesJob
|
||||||
|
|
||||||
|
describe('normalizeJobsResponse', () => {
|
||||||
|
it('accepts dashboard cron jobs returned as a top-level array', () => {
|
||||||
|
expect(normalizeJobsResponse([job])).toEqual([job])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts gateway jobs returned in an object wrapper', () => {
|
||||||
|
expect(normalizeJobsResponse({ jobs: [job] })).toEqual([job])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to an empty list for unexpected payloads', () => {
|
||||||
|
expect(normalizeJobsResponse({ jobs: null })).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('job helpers', () => {
|
||||||
|
it('finds jobs by id', () => {
|
||||||
|
expect(findJobById([job], 'job-1')).toEqual(job)
|
||||||
|
expect(findJobById([job], 'missing')).toBeNull()
|
||||||
|
expect(findJobById([job], null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes and classifies job states', () => {
|
||||||
|
expect(normalizeJobState(' Running ')).toBe('running')
|
||||||
|
expect(isFailedJobState('errored')).toBe(true)
|
||||||
|
expect(isFailedJobState('running')).toBe(false)
|
||||||
|
expect(isTerminalJobState('success')).toBe(true)
|
||||||
|
expect(isTerminalJobState('done')).toBe(true)
|
||||||
|
expect(isTerminalJobState('scheduled')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the latest non-empty job output text', () => {
|
||||||
|
const outputs: Array<JobOutput> = [
|
||||||
|
{ filename: 'a.log', timestamp: '2026-04-30T12:00:00Z', content: 'older run', size: 9 },
|
||||||
|
{ filename: 'b.log', timestamp: '2026-04-30T12:05:00Z', content: ' ', size: 3 },
|
||||||
|
{ filename: 'c.log', timestamp: '2026-04-30T12:10:00Z', content: 'newest run', size: 10 },
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(getLatestJobOutputText(outputs)).toBe('newest run')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers explicit job error text', () => {
|
||||||
|
expect(getJobErrorText({ ...job, last_run_error: ' boom ' })).toBe('boom')
|
||||||
|
expect(getJobErrorText({ ...job, last_run_error: null, error: 'oops' })).toBe('oops')
|
||||||
|
expect(getJobErrorText(null)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,6 +15,8 @@ export type ClaudeJob = {
|
|||||||
next_run_at?: string | null
|
next_run_at?: string | null
|
||||||
last_run_at?: string | null
|
last_run_at?: string | null
|
||||||
last_run_success?: boolean | null
|
last_run_success?: boolean | null
|
||||||
|
last_run_error?: string | null
|
||||||
|
error?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
deliver?: Array<string>
|
deliver?: Array<string>
|
||||||
@@ -23,6 +25,8 @@ export type ClaudeJob = {
|
|||||||
run_count?: number
|
run_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HermesJob = ClaudeJob
|
||||||
|
|
||||||
export type JobOutput = {
|
export type JobOutput = {
|
||||||
filename: string
|
filename: string
|
||||||
timestamp: string
|
timestamp: string
|
||||||
@@ -30,11 +34,92 @@ export type JobOutput = {
|
|||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeJobsResponse(data: unknown): Array<ClaudeJob> {
|
||||||
|
if (Array.isArray(data)) return data as Array<ClaudeJob>
|
||||||
|
if (
|
||||||
|
typeof data === 'object' &&
|
||||||
|
data !== null &&
|
||||||
|
'jobs' in data &&
|
||||||
|
Array.isArray((data as { jobs?: unknown }).jobs)
|
||||||
|
) {
|
||||||
|
return (data as { jobs: Array<ClaudeJob> }).jobs
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findJobById(
|
||||||
|
jobs: Array<ClaudeJob>,
|
||||||
|
jobId: string | null | undefined,
|
||||||
|
): ClaudeJob | null {
|
||||||
|
if (!jobId) return null
|
||||||
|
return jobs.find((job) => job.id === jobId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeJobState(state: unknown): string | null {
|
||||||
|
return typeof state === 'string' && state.trim() ? state.trim().toLowerCase() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFailedJobState(state: unknown): boolean {
|
||||||
|
const normalized = normalizeJobState(state)
|
||||||
|
return (
|
||||||
|
normalized === 'failed' ||
|
||||||
|
normalized === 'error' ||
|
||||||
|
normalized === 'errored' ||
|
||||||
|
normalized === 'cancelled' ||
|
||||||
|
normalized === 'canceled' ||
|
||||||
|
normalized === 'aborted'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTerminalJobState(state: unknown): boolean {
|
||||||
|
const normalized = normalizeJobState(state)
|
||||||
|
return (
|
||||||
|
normalized === 'completed' ||
|
||||||
|
normalized === 'complete' ||
|
||||||
|
normalized === 'succeeded' ||
|
||||||
|
normalized === 'success' ||
|
||||||
|
normalized === 'finished' ||
|
||||||
|
normalized === 'done' ||
|
||||||
|
isFailedJobState(normalized)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLatestJobOutputText(outputs: Array<JobOutput>): string {
|
||||||
|
let latestContent = ''
|
||||||
|
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
for (const output of outputs) {
|
||||||
|
const content = typeof output.content === 'string' ? output.content.trim() : ''
|
||||||
|
if (!content) continue
|
||||||
|
|
||||||
|
const timestamp = new Date(output.timestamp).getTime()
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp < latestTimestamp) continue
|
||||||
|
|
||||||
|
latestTimestamp = timestamp
|
||||||
|
latestContent = content
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestContent
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJobErrorText(job: ClaudeJob | null | undefined): string | null {
|
||||||
|
if (!job) return null
|
||||||
|
|
||||||
|
const candidates = [job.last_run_error, job.error]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === 'string' && candidate.trim()) {
|
||||||
|
return candidate.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchJobs(): Promise<Array<ClaudeJob>> {
|
export async function fetchJobs(): Promise<Array<ClaudeJob>> {
|
||||||
const res = await fetch(`${CLAUDE_API}?include_disabled=true`)
|
const res = await fetch(`${CLAUDE_API}?include_disabled=true`)
|
||||||
if (!res.ok) throw new Error(`Failed to fetch jobs: ${res.status}`)
|
if (!res.ok) throw new Error(`Failed to fetch jobs: ${res.status}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return data.jobs ?? []
|
return normalizeJobsResponse(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createJob(input: {
|
export async function createJob(input: {
|
||||||
|
|||||||
@@ -1,15 +1,3 @@
|
|||||||
/**
|
|
||||||
* Conductor mission spawn — Claude-backed.
|
|
||||||
*
|
|
||||||
* Spawns a one-shot Claude job whose prompt is the orchestrator instructions.
|
|
||||||
* The orchestrator session, when it runs, uses the create_task / delegate
|
|
||||||
* tools to spawn worker agents. The Conductor UI then polls /api/sessions
|
|
||||||
* + /api/history to track workers.
|
|
||||||
*
|
|
||||||
* Replaces the previous OCPlatform JSON-RPC implementation
|
|
||||||
* (gatewayRpc('cron.add', ...)) which only worked when the OCPlatform
|
|
||||||
* gateway was running on ws://127.0.0.1:18789.
|
|
||||||
*/
|
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
@@ -17,12 +5,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
|||||||
import { json } from '@tanstack/react-start'
|
import { json } from '@tanstack/react-start'
|
||||||
import { isAuthenticated } from '../../server/auth-middleware'
|
import { isAuthenticated } from '../../server/auth-middleware'
|
||||||
import { requireJsonContentType } from '../../server/rate-limit'
|
import { requireJsonContentType } from '../../server/rate-limit'
|
||||||
import {
|
import { dashboardFetch, ensureGatewayProbed } from '../../server/gateway-capabilities'
|
||||||
CLAUDE_API,
|
|
||||||
BEARER_TOKEN,
|
|
||||||
dashboardFetch,
|
|
||||||
ensureGatewayProbed,
|
|
||||||
} from '../../server/gateway-capabilities'
|
|
||||||
|
|
||||||
let cachedSkill: string | null = null
|
let cachedSkill: string | null = null
|
||||||
|
|
||||||
@@ -35,12 +18,9 @@ type ConductorSpawnBody = {
|
|||||||
supervised?: unknown
|
supervised?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the workspace root from this module's location so we find the
|
|
||||||
// bundled skill regardless of where the server is launched from.
|
|
||||||
function repoRoot(): string {
|
function repoRoot(): string {
|
||||||
try {
|
try {
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
// src/routes/api -> repo root (../..)
|
|
||||||
return resolve(here, '..', '..', '..')
|
return resolve(here, '..', '..', '..')
|
||||||
} catch {
|
} catch {
|
||||||
return process.cwd()
|
return process.cwd()
|
||||||
@@ -49,22 +29,18 @@ function repoRoot(): string {
|
|||||||
|
|
||||||
function loadDispatchSkill(): string {
|
function loadDispatchSkill(): string {
|
||||||
if (cachedSkill !== null) return cachedSkill
|
if (cachedSkill !== null) return cachedSkill
|
||||||
|
const home = process.env.HOME ?? ''
|
||||||
const candidates = [
|
const candidates = [
|
||||||
resolve(repoRoot(), 'skills/workspace-dispatch/SKILL.md'),
|
resolve(repoRoot(), 'skills/workspace-dispatch/SKILL.md'),
|
||||||
resolve(process.cwd(), 'skills/workspace-dispatch/SKILL.md'),
|
resolve(process.cwd(), 'skills/workspace-dispatch/SKILL.md'),
|
||||||
resolve(process.env.HOME ?? '~', '.claude/skills/workspace-dispatch/SKILL.md'),
|
...(home ? [resolve(home, '.hermes/skills/workspace-dispatch/SKILL.md')] : []),
|
||||||
resolve(
|
...(home ? [resolve(home, '.openclaw/workspace/skills/workspace-dispatch/SKILL.md')] : []),
|
||||||
process.env.HOME ?? '~',
|
|
||||||
'.ocplatform/workspace/skills/workspace-dispatch/SKILL.md',
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
try {
|
try {
|
||||||
cachedSkill = readFileSync(p, 'utf-8')
|
cachedSkill = readFileSync(p, 'utf-8')
|
||||||
return cachedSkill
|
return cachedSkill
|
||||||
} catch {
|
} catch {}
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
cachedSkill = ''
|
cachedSkill = ''
|
||||||
return cachedSkill
|
return cachedSkill
|
||||||
@@ -91,9 +67,7 @@ function buildOrchestratorPrompt(
|
|||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const outputBase = options.projectsDir || '/tmp'
|
const outputBase = options.projectsDir || '/tmp'
|
||||||
const outputPrefix =
|
const outputPrefix = outputBase === '/tmp' ? '/tmp/dispatch-<slug>' : `${outputBase}/dispatch-<slug>`
|
||||||
outputBase === '/tmp' ? '/tmp/dispatch-<slug>' : `${outputBase}/dispatch-<slug>`
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'You are a mission orchestrator. Execute this mission autonomously.',
|
'You are a mission orchestrator. Execute this mission autonomously.',
|
||||||
'',
|
'',
|
||||||
@@ -104,24 +78,12 @@ function buildOrchestratorPrompt(
|
|||||||
'## Mission',
|
'## Mission',
|
||||||
'',
|
'',
|
||||||
`Goal: ${goal}`,
|
`Goal: ${goal}`,
|
||||||
...(options.orchestratorModel
|
...(options.orchestratorModel ? ['', `Use model: ${options.orchestratorModel} for the orchestrator`] : []),
|
||||||
? ['', `Use model: ${options.orchestratorModel} for the orchestrator`]
|
...(options.workerModel ? ['', `Use model: ${options.workerModel} for all workers`] : []),
|
||||||
: []),
|
|
||||||
...(options.workerModel
|
|
||||||
? ['', `Use model: ${options.workerModel} for all workers`]
|
|
||||||
: []),
|
|
||||||
...(options.maxParallel > 1
|
...(options.maxParallel > 1
|
||||||
? [
|
? ['', `Run up to ${options.maxParallel} workers in parallel when tasks are independent`]
|
||||||
'',
|
: ['', 'Spawn workers one at a time. Do NOT wait for workers to finish — the UI handles tracking.']),
|
||||||
`Run up to ${options.maxParallel} workers in parallel when tasks are independent`,
|
...(options.supervised ? ['', 'Supervised mode is enabled. Require approval before each task.'] : []),
|
||||||
]
|
|
||||||
: [
|
|
||||||
'',
|
|
||||||
'Spawn workers one at a time. Do NOT wait for workers to finish — the UI handles tracking.',
|
|
||||||
]),
|
|
||||||
...(options.supervised
|
|
||||||
? ['', 'Supervised mode is enabled. Require approval before each task.']
|
|
||||||
: []),
|
|
||||||
'',
|
'',
|
||||||
'## Critical Rules',
|
'## Critical Rules',
|
||||||
'- Use create_task / delegate_task to create worker agents for each task',
|
'- Use create_task / delegate_task to create worker agents for each task',
|
||||||
@@ -136,125 +98,115 @@ function buildOrchestratorPrompt(
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function authHeaders(): Record<string, string> {
|
async function createDashboardConductorMission(payload: { name: string; prompt: string }): Promise<{
|
||||||
return BEARER_TOKEN ? { Authorization: `Bearer ${BEARER_TOKEN}` } : {}
|
id?: string
|
||||||
}
|
name?: string
|
||||||
|
sessionKey?: string
|
||||||
function nowPlusSecondsIso(seconds: number): string {
|
error?: string
|
||||||
const t = new Date(Date.now() + seconds * 1000)
|
}> {
|
||||||
// Claude accepts ISO-8601 timestamps; strip milliseconds for cleanliness
|
const res = await dashboardFetch('/api/conductor/missions', {
|
||||||
return t.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
method: 'POST',
|
||||||
}
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: payload.name, prompt: payload.prompt }),
|
||||||
async function createClaudeJob(payload: {
|
|
||||||
name: string
|
|
||||||
schedule: string
|
|
||||||
prompt: string
|
|
||||||
deliver?: string
|
|
||||||
}): Promise<{ id?: string; name?: string; error?: string }> {
|
|
||||||
const body = JSON.stringify({
|
|
||||||
name: payload.name,
|
|
||||||
schedule: payload.schedule,
|
|
||||||
prompt: payload.prompt,
|
|
||||||
deliver: payload.deliver ?? 'local',
|
|
||||||
})
|
})
|
||||||
const capabilities = await ensureGatewayProbed()
|
|
||||||
const res = capabilities.dashboard.available
|
|
||||||
? await dashboardFetch('/api/cron/jobs', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
: await fetch(`${CLAUDE_API}/api/jobs`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
let data: { job?: { id?: string; name?: string }; error?: string } = {}
|
let data: { id?: string; name?: string; session_id?: string; error?: string; detail?: string } = {}
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(text)
|
data = JSON.parse(text)
|
||||||
} catch {
|
} catch {
|
||||||
return { error: text || `HTTP ${res.status}` }
|
return { error: text || `HTTP ${res.status}` }
|
||||||
}
|
}
|
||||||
if (!res.ok || data.error) {
|
if (!res.ok || data.error || data.detail) {
|
||||||
return { error: data.error || `HTTP ${res.status}` }
|
return { error: data.error || data.detail || `HTTP ${res.status}` }
|
||||||
}
|
}
|
||||||
return { id: data.job?.id, name: data.job?.name }
|
return { id: data.id, name: data.name, sessionKey: data.session_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/conductor-spawn')({
|
export const Route = createFileRoute('/api/conductor-spawn')({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request }) => {
|
GET: async ({ request }) => {
|
||||||
if (!isAuthenticated(request)) {
|
if (!isAuthenticated(request)) return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
const url = new URL(request.url)
|
||||||
|
const missionId = url.searchParams.get('missionId')?.trim()
|
||||||
|
const requestedLines = Number(url.searchParams.get('lines') || '200')
|
||||||
|
const lines = Number.isFinite(requestedLines) ? Math.min(2000, Math.max(1, requestedLines)) : 200
|
||||||
|
if (!missionId) return json({ ok: false, error: 'missionId required' }, { status: 400 })
|
||||||
|
|
||||||
|
const capabilities = await ensureGatewayProbed()
|
||||||
|
if (!capabilities.dashboard.available) {
|
||||||
|
return json({ ok: false, error: 'Hermes dashboard API is unavailable' }, { status: 503 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const res = await dashboardFetch(`/api/conductor/missions/${encodeURIComponent(missionId)}?lines=${lines}`)
|
||||||
|
const text = await res.text()
|
||||||
|
let mission: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
mission = JSON.parse(text) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return json({ ok: false, error: text || `HTTP ${res.status}` }, { status: res.ok ? 502 : res.status })
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = typeof mission.detail === 'string' ? mission.detail : typeof mission.error === 'string' ? mission.error : `HTTP ${res.status}`
|
||||||
|
return json({ ok: false, error }, { status: res.status })
|
||||||
|
}
|
||||||
|
return json({ ok: true, mission })
|
||||||
|
},
|
||||||
|
POST: async ({ request }) => {
|
||||||
|
if (!isAuthenticated(request)) return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
const csrfCheck = requireJsonContentType(request)
|
const csrfCheck = requireJsonContentType(request)
|
||||||
if (csrfCheck) return csrfCheck
|
if (csrfCheck) return csrfCheck
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = (await request
|
const body = (await request.json().catch(() => ({}))) as ConductorSpawnBody
|
||||||
.json()
|
|
||||||
.catch(() => ({}))) as ConductorSpawnBody
|
|
||||||
const goal = readOptionalString(body.goal)
|
const goal = readOptionalString(body.goal)
|
||||||
const orchestratorModel = readOptionalString(body.orchestratorModel)
|
const orchestratorModel = readOptionalString(body.orchestratorModel)
|
||||||
const workerModel = readOptionalString(body.workerModel)
|
const workerModel = readOptionalString(body.workerModel)
|
||||||
const projectsDir = readOptionalString(body.projectsDir)
|
const projectsDir = readOptionalString(body.projectsDir)
|
||||||
const maxParallel = readMaxParallel(body.maxParallel)
|
const maxParallel = readMaxParallel(body.maxParallel)
|
||||||
const supervised = body.supervised === true
|
const supervised = body.supervised === true
|
||||||
|
if (!goal) return json({ ok: false, error: 'goal required' }, { status: 400 })
|
||||||
|
|
||||||
if (!goal) {
|
const prompt = buildOrchestratorPrompt(goal, loadDispatchSkill(), {
|
||||||
return json({ ok: false, error: 'goal required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const skill = loadDispatchSkill()
|
|
||||||
const prompt = buildOrchestratorPrompt(goal, skill, {
|
|
||||||
orchestratorModel,
|
orchestratorModel,
|
||||||
workerModel,
|
workerModel,
|
||||||
projectsDir,
|
projectsDir,
|
||||||
maxParallel,
|
maxParallel,
|
||||||
supervised,
|
supervised,
|
||||||
})
|
})
|
||||||
|
const missionName = `conductor-${Date.now()}`
|
||||||
|
const capabilities = await ensureGatewayProbed()
|
||||||
|
|
||||||
const jobName = `conductor-${Date.now()}`
|
if (!capabilities.dashboard.available) {
|
||||||
// Schedule a one-shot job ~5s in the future so the cron loop
|
return json({
|
||||||
// picks it up promptly without racing with the create response.
|
ok: true,
|
||||||
const result = await createClaudeJob({
|
mode: 'portable',
|
||||||
name: jobName,
|
prompt,
|
||||||
schedule: nowPlusSecondsIso(5),
|
missionId: null,
|
||||||
prompt,
|
sessionKey: missionName,
|
||||||
deliver: 'local',
|
sessionKeyPrefix: null,
|
||||||
})
|
jobId: null,
|
||||||
|
jobName: missionName,
|
||||||
if (result.error) {
|
runId: null,
|
||||||
return json(
|
})
|
||||||
{ ok: false, error: result.error },
|
|
||||||
{ status: 502 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude runs cron jobs in sessions keyed `cron_<jobId>_<timestamp>`.
|
const result = await createDashboardConductorMission({ name: missionName, prompt })
|
||||||
// We can't know the timestamp until the cron loop fires, so we return
|
if (result.error) return json({ ok: false, error: result.error }, { status: 502 })
|
||||||
// a prefix and the UI polls for any session whose key starts with it.
|
const missionId = result.id ?? missionName
|
||||||
const jobId = result.id ?? jobName
|
|
||||||
return json({
|
return json({
|
||||||
ok: true,
|
ok: true,
|
||||||
sessionKey: `cron_${jobId}_pending`,
|
mode: 'dashboard',
|
||||||
sessionKeyPrefix: `cron_${jobId}_`,
|
prompt: null,
|
||||||
jobId,
|
missionId,
|
||||||
jobName: result.name ?? jobName,
|
sessionKey: result.sessionKey ?? null,
|
||||||
|
sessionKeyPrefix: null,
|
||||||
|
jobId: missionId,
|
||||||
|
jobName: result.name ?? missionName,
|
||||||
runId: null,
|
runId: null,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(
|
return json({ ok: false, error: error instanceof Error ? error.message : String(error) }, { status: 500 })
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { createFileRoute } from '@tanstack/react-router'
|
|||||||
import { json } from '@tanstack/react-start'
|
import { json } from '@tanstack/react-start'
|
||||||
import { isAuthenticated } from '../../server/auth-middleware'
|
import { isAuthenticated } from '../../server/auth-middleware'
|
||||||
import { requireJsonContentType } from '../../server/rate-limit'
|
import { requireJsonContentType } from '../../server/rate-limit'
|
||||||
import { deleteSession, ensureGatewayProbed } from '../../server/claude-api'
|
import { deleteSession } from '../../server/claude-api'
|
||||||
|
import {
|
||||||
|
dashboardFetch,
|
||||||
|
ensureGatewayProbed,
|
||||||
|
} from '../../server/gateway-capabilities'
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/conductor-stop')({
|
export const Route = createFileRoute('/api/conductor-stop')({
|
||||||
server: {
|
server: {
|
||||||
@@ -15,7 +19,6 @@ export const Route = createFileRoute('/api/conductor-stop')({
|
|||||||
if (csrfCheck) return csrfCheck
|
if (csrfCheck) return csrfCheck
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureGatewayProbed()
|
|
||||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
|
||||||
const sessionKeys = Array.isArray(body.sessionKeys)
|
const sessionKeys = Array.isArray(body.sessionKeys)
|
||||||
? body.sessionKeys.filter(
|
? body.sessionKeys.filter(
|
||||||
@@ -23,8 +26,30 @@ export const Route = createFileRoute('/api/conductor-stop')({
|
|||||||
typeof value === 'string' && value.trim().length > 0,
|
typeof value === 'string' && value.trim().length > 0,
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
|
const missionIds = Array.isArray(body.missionIds)
|
||||||
|
? body.missionIds.filter(
|
||||||
|
(value): value is string =>
|
||||||
|
typeof value === 'string' && value.trim().length > 0,
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
|
||||||
let deleted = 0
|
let deleted = 0
|
||||||
|
let stoppedMissions = 0
|
||||||
|
const capabilities = await ensureGatewayProbed()
|
||||||
|
if (capabilities.dashboard.available) {
|
||||||
|
for (const missionId of missionIds) {
|
||||||
|
try {
|
||||||
|
const res = await dashboardFetch(
|
||||||
|
`/api/conductor/missions/${encodeURIComponent(missionId)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
if (res.ok) stoppedMissions += 1
|
||||||
|
} catch {
|
||||||
|
// Ignore per-mission stop errors so session cleanup still runs.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const sessionKey of sessionKeys) {
|
for (const sessionKey of sessionKeys) {
|
||||||
try {
|
try {
|
||||||
await deleteSession(sessionKey)
|
await deleteSession(sessionKey)
|
||||||
@@ -34,7 +59,7 @@ export const Route = createFileRoute('/api/conductor-stop')({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ ok: true, deleted })
|
return json({ ok: true, deleted, stoppedMissions })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1073,9 +1073,12 @@ export const Route = createFileRoute('/api/send-stream')({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
// Reader cancellation happens when the user navigates away from Chat.
|
// Browser navigation/unmount cancels the response reader. That
|
||||||
// Do not abort the upstream Hermes run; the UI can recover from
|
// must not cancel the Hermes run itself: the chat/conductor should
|
||||||
// session history / active-run polling when the user returns.
|
// keep thinking server-side so the user can return and recover the
|
||||||
|
// answer from session history. Mark this client stream closed so we
|
||||||
|
// stop enqueueing SSE chunks, but deliberately leave the upstream
|
||||||
|
// abortController alone.
|
||||||
streamClosed = true
|
streamClosed = true
|
||||||
if (unregisterTimer) {
|
if (unregisterTimer) {
|
||||||
clearTimeout(unregisterTimer)
|
clearTimeout(unregisterTimer)
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
|
|||||||
const finishedRef = useRef(false)
|
const finishedRef = useRef(false)
|
||||||
const thinkingRef = useRef<string>('')
|
const thinkingRef = useRef<string>('')
|
||||||
const activeRunIdRef = useRef<string | null>(null)
|
const activeRunIdRef = useRef<string | null>(null)
|
||||||
const delayedUnregisterTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const delayedUnregisterTimerRef = useRef<ReturnType<
|
||||||
|
typeof setTimeout
|
||||||
|
> | null>(null)
|
||||||
const activeSessionKeyRef = useRef<string>('main')
|
const activeSessionKeyRef = useRef<string>('main')
|
||||||
const lifecyclePhaseRef = useRef<StreamLifecyclePhase>('idle')
|
const lifecyclePhaseRef = useRef<StreamLifecyclePhase>('idle')
|
||||||
const acceptedAtRef = useRef<number | null>(null)
|
const acceptedAtRef = useRef<number | null>(null)
|
||||||
@@ -268,15 +270,17 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
|
|||||||
])
|
])
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
function handOffAcceptedRunOnUnmount() {
|
function keepAcceptedRunAliveOnUnmount() {
|
||||||
return function cleanup() {
|
return function cleanup() {
|
||||||
if (!eventSourceRef.current || finishedRef.current) return
|
if (!eventSourceRef.current || finishedRef.current) return
|
||||||
|
|
||||||
// Abort only this browser reader. The server route keeps the upstream
|
// Navigating away from Chat unmounts this hook. Previously this cleanup
|
||||||
// Hermes run alive after reader cancel, so another transport or history
|
// aborted /api/send-stream and reset the local stream state, which made
|
||||||
// polling can be authoritative when Chat remounts.
|
// the UI look like Hermes stopped thinking. Leave the accepted request
|
||||||
eventSourceRef.current.abort()
|
// alive instead: the server-side route deliberately keeps the upstream
|
||||||
eventSourceRef.current = null
|
// Hermes run alive after the browser reader is cancelled, and the
|
||||||
|
// persisted waiting/session state lets the screen recover from history
|
||||||
|
// or active-run polling when the user comes back.
|
||||||
lifecyclePhaseRef.current = 'handoff'
|
lifecyclePhaseRef.current = 'handoff'
|
||||||
clearSendStreamRun()
|
clearSendStreamRun()
|
||||||
clearHandoffTimer()
|
clearHandoffTimer()
|
||||||
@@ -607,9 +611,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
|
|||||||
type: 'done',
|
type: 'done',
|
||||||
state: doneState ?? 'final',
|
state: doneState ?? 'final',
|
||||||
errorMessage,
|
errorMessage,
|
||||||
message: (payload).message as
|
message: payload.message as Record<string, unknown> | undefined,
|
||||||
| Record<string, unknown>
|
|
||||||
| undefined,
|
|
||||||
runId: activeRunIdRef.current ?? undefined,
|
runId: activeRunIdRef.current ?? undefined,
|
||||||
sessionKey: activeSessionKeyRef.current,
|
sessionKey: activeSessionKeyRef.current,
|
||||||
transport: 'send-stream',
|
transport: 'send-stream',
|
||||||
@@ -712,7 +714,12 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [
|
content: [
|
||||||
...(thinkingRef.current
|
...(thinkingRef.current
|
||||||
? [{ type: 'thinking' as const, thinking: thinkingRef.current }]
|
? [
|
||||||
|
{
|
||||||
|
type: 'thinking' as const,
|
||||||
|
thinking: thinkingRef.current,
|
||||||
|
},
|
||||||
|
]
|
||||||
: []),
|
: []),
|
||||||
{ type: 'text' as const, text: fullTextRef.current },
|
{ type: 'text' as const, text: fullTextRef.current },
|
||||||
],
|
],
|
||||||
@@ -752,7 +759,10 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
|
|||||||
attachments: params.attachments,
|
attachments: params.attachments,
|
||||||
idempotencyKey: params.idempotencyKey ?? crypto.randomUUID(),
|
idempotencyKey: params.idempotencyKey ?? crypto.randomUUID(),
|
||||||
model: params.model || undefined,
|
model: params.model || undefined,
|
||||||
locale: typeof window !== 'undefined' ? localStorage.getItem('hermes-workspace-locale') || 'en' : 'en',
|
locale:
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('hermes-workspace-locale') || 'en'
|
||||||
|
: 'en',
|
||||||
}),
|
}),
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import {
|
import { ArrowDown01Icon, ArrowRight01Icon, PlayIcon, Rocket01Icon, Search01Icon, Settings01Icon, TaskDone01Icon } from '@hugeicons/core-free-icons'
|
||||||
ArrowDown01Icon,
|
|
||||||
ArrowRight01Icon,
|
|
||||||
PlayIcon,
|
|
||||||
Rocket01Icon,
|
|
||||||
Search01Icon,
|
|
||||||
Settings01Icon,
|
|
||||||
TaskDone01Icon,
|
|
||||||
} from '@hugeicons/core-free-icons'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Markdown } from '@/components/prompt-kit/markdown'
|
import { Markdown } from '@/components/prompt-kit/markdown'
|
||||||
import { OfficeView } from './components/office-view'
|
import { OfficeView } from './components/office-view'
|
||||||
@@ -106,6 +98,27 @@ const QUICK_ACTIONS: Array<{
|
|||||||
const AGENT_NAMES = ['Nova', 'Pixel', 'Blaze', 'Echo', 'Sage', 'Drift', 'Flux', 'Volt']
|
const AGENT_NAMES = ['Nova', 'Pixel', 'Blaze', 'Echo', 'Sage', 'Drift', 'Flux', 'Volt']
|
||||||
const AGENT_EMOJIS = ['🤖', '⚡', '🔥', '🌊', '🌿', '💫', '🔮', '⭐']
|
const AGENT_EMOJIS = ['🤖', '⚡', '🔥', '🌊', '🌿', '💫', '🔮', '⭐']
|
||||||
const BLENDED_COST_PER_MILLION_TOKENS = 5
|
const BLENDED_COST_PER_MILLION_TOKENS = 5
|
||||||
|
const CONDUCTOR_GOAL_DRAFT_STORAGE_KEY = 'conductor:goal-draft'
|
||||||
|
|
||||||
|
function loadConductorGoalDraft(): string {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage?.getItem(CONDUCTOR_GOAL_DRAFT_STORAGE_KEY) ?? ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistConductorGoalDraft(value: string): void {
|
||||||
|
try {
|
||||||
|
if (value.trim()) {
|
||||||
|
globalThis.localStorage?.setItem(CONDUCTOR_GOAL_DRAFT_STORAGE_KEY, value)
|
||||||
|
} else {
|
||||||
|
globalThis.localStorage?.removeItem(CONDUCTOR_GOAL_DRAFT_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures; the in-memory state still works.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getAgentPersona(index: number) {
|
function getAgentPersona(index: number) {
|
||||||
return {
|
return {
|
||||||
@@ -122,39 +135,19 @@ function formatUsd(value: number): string {
|
|||||||
return `$${value.toFixed(value >= 0.1 ? 2 : 3)}`
|
return `$${value.toFixed(value >= 0.1 ? 2 : 3)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function MissionCostSection({
|
function MissionCostSection({ totalTokens, workers, expanded, onToggle }: { totalTokens: number; workers: MissionCostWorker[]; expanded: boolean; onToggle: () => void }) {
|
||||||
totalTokens,
|
|
||||||
workers,
|
|
||||||
expanded,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
totalTokens: number
|
|
||||||
workers: MissionCostWorker[]
|
|
||||||
expanded: boolean
|
|
||||||
onToggle: () => void
|
|
||||||
}) {
|
|
||||||
const estimatedCost = estimateTokenCost(totalTokens)
|
const estimatedCost = estimateTokenCost(totalTokens)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
<div className="overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
||||||
<button
|
<button type="button" onClick={onToggle} aria-expanded={expanded} className="flex w-full items-start justify-between gap-4 text-left">
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
aria-expanded={expanded}
|
|
||||||
className="flex w-full items-start justify-between gap-4 text-left"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Mission Cost</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Mission Cost</p>
|
||||||
<p className="mt-1 text-sm text-[var(--theme-muted-2)]">Approximate at $5 / 1M tokens blended from input/output pricing.</p>
|
<p className="mt-1 text-sm text-[var(--theme-muted-2)]">Approximate at $5 / 1M tokens blended from input/output pricing.</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="inline-flex items-center gap-2 rounded-xl border border-[var(--theme-border)] bg-[var(--theme-card2)] px-3 py-2 text-xs font-medium text-[var(--theme-text)]">
|
<span className="inline-flex items-center gap-2 rounded-xl border border-[var(--theme-border)] bg-[var(--theme-card2)] px-3 py-2 text-xs font-medium text-[var(--theme-text)]">
|
||||||
{expanded ? 'Hide' : 'Show'}
|
{expanded ? 'Hide' : 'Show'}
|
||||||
<HugeiconsIcon
|
<HugeiconsIcon icon={ArrowDown01Icon} size={16} strokeWidth={1.7} className={cn('transition-transform duration-200', expanded ? 'rotate-180' : 'rotate-0')} />
|
||||||
icon={ArrowDown01Icon}
|
|
||||||
size={16}
|
|
||||||
strokeWidth={1.7}
|
|
||||||
className={cn('transition-transform duration-200', expanded ? 'rotate-180' : 'rotate-0')}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -180,7 +173,9 @@ function MissionCostSection({
|
|||||||
<div className="divide-y divide-[var(--theme-border)]">
|
<div className="divide-y divide-[var(--theme-border)]">
|
||||||
{workers.map((worker) => (
|
{workers.map((worker) => (
|
||||||
<div key={worker.id} className="flex items-center gap-3 px-4 py-3 text-sm">
|
<div key={worker.id} className="flex items-center gap-3 px-4 py-3 text-sm">
|
||||||
<span className="font-medium text-[var(--theme-text)]">{worker.personaEmoji} {worker.personaName}</span>
|
<span className="font-medium text-[var(--theme-text)]">
|
||||||
|
{worker.personaEmoji} {worker.personaName}
|
||||||
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-[var(--theme-muted)]">{worker.label}</span>
|
<span className="min-w-0 flex-1 truncate text-[var(--theme-muted)]">{worker.label}</span>
|
||||||
<span className="text-xs text-[var(--theme-muted)]">{worker.totalTokens.toLocaleString()} tok</span>
|
<span className="text-xs text-[var(--theme-muted)]">{worker.totalTokens.toLocaleString()} tok</span>
|
||||||
<span className="min-w-[4.5rem] text-right font-medium text-[var(--theme-text)]">{formatUsd(estimateTokenCost(worker.totalTokens))}</span>
|
<span className="min-w-[4.5rem] text-right font-medium text-[var(--theme-text)]">{formatUsd(estimateTokenCost(worker.totalTokens))}</span>
|
||||||
@@ -210,15 +205,7 @@ const WORKING_STEPS = [
|
|||||||
'🚀 Almost there…',
|
'🚀 Almost there…',
|
||||||
]
|
]
|
||||||
|
|
||||||
function CyclingStatus({
|
function CyclingStatus({ steps, intervalMs = 3000, isPaused = false }: { steps: string[]; intervalMs?: number; isPaused?: boolean }) {
|
||||||
steps,
|
|
||||||
intervalMs = 3000,
|
|
||||||
isPaused = false,
|
|
||||||
}: {
|
|
||||||
steps: string[]
|
|
||||||
intervalMs?: number
|
|
||||||
isPaused?: boolean
|
|
||||||
}) {
|
|
||||||
const [step, setStep] = useState(0)
|
const [step, setStep] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -230,9 +217,7 @@ function CyclingStatus({
|
|||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 py-3">
|
<div className="flex items-center gap-3 py-3">
|
||||||
<div className="flex size-3.5 items-center justify-center rounded-full border border-amber-400/60 bg-amber-500/10 text-[9px] text-amber-300">
|
<div className="flex size-3.5 items-center justify-center rounded-full border border-amber-400/60 bg-amber-500/10 text-[9px] text-amber-300">||</div>
|
||||||
||
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--theme-muted)]">Paused</p>
|
<p className="text-sm text-[var(--theme-muted)]">Paused</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -354,27 +339,12 @@ function WorkerCard({
|
|||||||
const dot = getWorkerDot(worker.status)
|
const dot = getWorkerDot(worker.status)
|
||||||
const persona = getAgentPersona(index)
|
const persona = getAgentPersona(index)
|
||||||
const workerOutput = conductor.workerOutputs[worker.key] ?? getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)
|
const workerOutput = conductor.workerOutputs[worker.key] ?? getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)
|
||||||
const workerStartedAt =
|
const workerStartedAt = typeof worker.raw.createdAt === 'string' ? worker.raw.createdAt : typeof worker.raw.startedAt === 'string' ? worker.raw.startedAt : conductor.missionStartedAt
|
||||||
typeof worker.raw.createdAt === 'string'
|
|
||||||
? worker.raw.createdAt
|
|
||||||
: typeof worker.raw.startedAt === 'string'
|
|
||||||
? worker.raw.startedAt
|
|
||||||
: conductor.missionStartedAt
|
|
||||||
const workerEndTime =
|
const workerEndTime =
|
||||||
worker.status === 'complete' || worker.status === 'stale'
|
worker.status === 'complete' || worker.status === 'stale' ? new Date(worker.updatedAt ?? new Date().toISOString()).getTime() : conductor.isPaused ? (conductor.pausedAtMs ?? now) : now
|
||||||
? new Date(worker.updatedAt ?? new Date().toISOString()).getTime()
|
|
||||||
: conductor.isPaused
|
|
||||||
? conductor.pausedAtMs ?? now
|
|
||||||
: now
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={worker.key} className={cn('overflow-hidden rounded-2xl border border-[var(--theme-border)] border-l-4 bg-[var(--theme-card)] px-4 py-3', getWorkerBorderClass(worker.status))}>
|
||||||
key={worker.key}
|
|
||||||
className={cn(
|
|
||||||
'overflow-hidden rounded-2xl border border-[var(--theme-border)] border-l-4 bg-[var(--theme-card)] px-4 py-3',
|
|
||||||
getWorkerBorderClass(worker.status),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -510,9 +480,7 @@ function groupModelsByProvider(models: AvailableModel[]) {
|
|||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
.map(([provider, providerModels]) => ({
|
.map(([provider, providerModels]) => ({
|
||||||
provider,
|
provider,
|
||||||
models: [...providerModels].sort((a, b) =>
|
models: [...providerModels].sort((a, b) => getModelDisplayName(a, a.id).localeCompare(getModelDisplayName(b, b.id))),
|
||||||
getModelDisplayName(a, a.id).localeCompare(getModelDisplayName(b, b.id)),
|
|
||||||
),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,9 +563,7 @@ function ModelSelectorDropdown({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex min-h-[3rem] w-full items-center justify-between gap-3 rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-4 py-3 text-left text-sm text-[var(--theme-text)] shadow-[0_8px_24px_color-mix(in_srgb,var(--theme-shadow)_18%,transparent)] transition-colors',
|
'inline-flex min-h-[3rem] w-full items-center justify-between gap-3 rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-4 py-3 text-left text-sm text-[var(--theme-text)] shadow-[0_8px_24px_color-mix(in_srgb,var(--theme-shadow)_18%,transparent)] transition-colors',
|
||||||
disabled
|
disabled ? 'cursor-not-allowed opacity-60' : 'hover:border-[var(--theme-accent)] focus:border-[var(--theme-accent)]',
|
||||||
? 'cursor-not-allowed opacity-60'
|
|
||||||
: 'hover:border-[var(--theme-accent)] focus:border-[var(--theme-accent)]',
|
|
||||||
)}
|
)}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
@@ -605,21 +571,11 @@ function ModelSelectorDropdown({
|
|||||||
>
|
>
|
||||||
<span className="inline-flex min-w-0 items-center gap-2">
|
<span className="inline-flex min-w-0 items-center gap-2">
|
||||||
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--theme-border)] bg-[var(--theme-card2)] px-3 py-1 text-xs font-medium text-[var(--theme-text)]">
|
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--theme-border)] bg-[var(--theme-card2)] px-3 py-1 text-xs font-medium text-[var(--theme-text)]">
|
||||||
<span
|
<span className={cn('size-2 rounded-full', value ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]')} />
|
||||||
className={cn(
|
|
||||||
'size-2 rounded-full',
|
|
||||||
value ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{getModelDisplayName(selectedModel, value)}</span>
|
<span className="truncate">{getModelDisplayName(selectedModel, value)}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<HugeiconsIcon
|
<HugeiconsIcon icon={ArrowDown01Icon} size={16} strokeWidth={1.8} className={cn('shrink-0 text-[var(--theme-muted)] transition-transform', open && 'rotate-180')} />
|
||||||
icon={ArrowDown01Icon}
|
|
||||||
size={16}
|
|
||||||
strokeWidth={1.8}
|
|
||||||
className={cn('shrink-0 text-[var(--theme-muted)] transition-transform', open && 'rotate-180')}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
@@ -633,30 +589,19 @@ function ModelSelectorDropdown({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors',
|
'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors',
|
||||||
!value
|
!value ? 'bg-[var(--theme-accent-soft)] text-[var(--theme-text)]' : 'text-[var(--theme-text)] hover:bg-[var(--theme-bg)]',
|
||||||
? 'bg-[var(--theme-accent-soft)] text-[var(--theme-text)]'
|
|
||||||
: 'text-[var(--theme-text)] hover:bg-[var(--theme-bg)]',
|
|
||||||
)}
|
)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={!value}
|
aria-selected={!value}
|
||||||
>
|
>
|
||||||
<span
|
<span className={cn('size-2 rounded-full', !value ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]')} />
|
||||||
className={cn(
|
|
||||||
'size-2 rounded-full',
|
|
||||||
!value ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1 truncate">Default (auto)</span>
|
<span className="min-w-0 flex-1 truncate">Default (auto)</span>
|
||||||
<span className="rounded-full border border-[var(--theme-border)] bg-[var(--theme-card2)] px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[var(--theme-muted)]">
|
<span className="rounded-full border border-[var(--theme-border)] bg-[var(--theme-card2)] px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[var(--theme-muted)]">Auto</span>
|
||||||
Auto
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{groupedModels.map((group) => (
|
{groupedModels.map((group) => (
|
||||||
<div key={group.provider} className="mt-2 first:mt-3">
|
<div key={group.provider} className="mt-2 first:mt-3">
|
||||||
<div className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">
|
<div className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">{group.provider}</div>
|
||||||
{group.provider}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{group.models.map((model) => {
|
{group.models.map((model) => {
|
||||||
const modelId = model.id ?? ''
|
const modelId = model.id ?? ''
|
||||||
@@ -671,19 +616,12 @@ function ModelSelectorDropdown({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors',
|
'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors',
|
||||||
active
|
active ? 'bg-[var(--theme-accent-soft)] text-[var(--theme-text)]' : 'text-[var(--theme-text)] hover:bg-[var(--theme-bg)]',
|
||||||
? 'bg-[var(--theme-accent-soft)] text-[var(--theme-text)]'
|
|
||||||
: 'text-[var(--theme-text)] hover:bg-[var(--theme-bg)]',
|
|
||||||
)}
|
)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
>
|
>
|
||||||
<span
|
<span className={cn('size-2 rounded-full', active ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]')} />
|
||||||
className={cn(
|
|
||||||
'size-2 rounded-full',
|
|
||||||
active ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1 truncate">{getModelDisplayName(model, modelId)}</span>
|
<span className="min-w-0 flex-1 truncate">{getModelDisplayName(model, modelId)}</span>
|
||||||
<span className="rounded-full border border-[var(--theme-border)] bg-[var(--theme-card2)] px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[var(--theme-muted)]">
|
<span className="rounded-full border border-[var(--theme-border)] bg-[var(--theme-card2)] px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[var(--theme-muted)]">
|
||||||
{group.provider}
|
{group.provider}
|
||||||
@@ -773,7 +711,7 @@ function deriveSessionStatus(session: GatewaySession): 'running' | 'completed' |
|
|||||||
|
|
||||||
export function Conductor() {
|
export function Conductor() {
|
||||||
const conductor = useConductorGateway()
|
const conductor = useConductorGateway()
|
||||||
const [goalDraft, setGoalDraft] = useState('')
|
const [goalDraft, setGoalDraft] = useState(() => loadConductorGoalDraft())
|
||||||
const [missionModalOpen, setMissionModalOpen] = useState(false)
|
const [missionModalOpen, setMissionModalOpen] = useState(false)
|
||||||
const [continueDraft, setContinueDraft] = useState('')
|
const [continueDraft, setContinueDraft] = useState('')
|
||||||
const [continueModalOpen, setContinueModalOpen] = useState(false)
|
const [continueModalOpen, setContinueModalOpen] = useState(false)
|
||||||
@@ -794,7 +732,10 @@ export function Conductor() {
|
|||||||
queryKey: ['conductor', 'models'],
|
queryKey: ['conductor', 'models'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/api/models')
|
const res = await fetch('/api/models')
|
||||||
const data = (await res.json()) as { ok?: boolean; models?: Array<{ id?: string; provider?: string; name?: string }> }
|
const data = (await res.json()) as {
|
||||||
|
ok?: boolean
|
||||||
|
models?: Array<{ id?: string; provider?: string; name?: string }>
|
||||||
|
}
|
||||||
return data.models ?? []
|
return data.models ?? []
|
||||||
},
|
},
|
||||||
enabled: settingsOpen,
|
enabled: settingsOpen,
|
||||||
@@ -825,9 +766,7 @@ export function Conductor() {
|
|||||||
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setDirectoryBrowserPath(typeof data.root === 'string' && data.root.trim() ? data.root : directoryBrowserPath)
|
setDirectoryBrowserPath(typeof data.root === 'string' && data.root.trim() ? data.root : directoryBrowserPath)
|
||||||
setDirectoryBrowserEntries(
|
setDirectoryBrowserEntries(Array.isArray(data.entries) ? data.entries.filter((entry) => entry?.type === 'folder') : [])
|
||||||
Array.isArray(data.entries) ? data.entries.filter((entry) => entry?.type === 'folder') : [],
|
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setDirectoryBrowserEntries([])
|
setDirectoryBrowserEntries([])
|
||||||
@@ -852,17 +791,22 @@ export function Conductor() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [conductor.isPaused, conductor.phase])
|
}, [conductor.isPaused, conductor.phase])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
persistConductorGoalDraft(goalDraft)
|
||||||
|
}, [goalDraft])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conductor.isPaused) return
|
if (!conductor.isPaused) return
|
||||||
setNow(conductor.pausedAtMs ?? Date.now())
|
setNow(conductor.pausedAtMs ?? Date.now())
|
||||||
}, [conductor.isPaused, conductor.pausedAtMs])
|
}, [conductor.isPaused, conductor.pausedAtMs])
|
||||||
|
|
||||||
|
|
||||||
// Set body background to match Conductor theme so no gray shows behind keyboard/tab bar
|
// Set body background to match Conductor theme so no gray shows behind keyboard/tab bar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prev = document.body.style.backgroundColor
|
const prev = document.body.style.backgroundColor
|
||||||
document.body.style.backgroundColor = 'var(--color-surface)'
|
document.body.style.backgroundColor = 'var(--color-surface)'
|
||||||
return () => { document.body.style.backgroundColor = prev }
|
return () => {
|
||||||
|
document.body.style.backgroundColor = prev
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const phase: ConductorPhase = useMemo(() => {
|
const phase: ConductorPhase = useMemo(() => {
|
||||||
@@ -875,6 +819,7 @@ export function Conductor() {
|
|||||||
const handleNewMission = () => {
|
const handleNewMission = () => {
|
||||||
conductor.resetMission()
|
conductor.resetMission()
|
||||||
setGoalDraft('')
|
setGoalDraft('')
|
||||||
|
persistConductorGoalDraft('')
|
||||||
setMissionModalOpen(false)
|
setMissionModalOpen(false)
|
||||||
setContinueDraft('')
|
setContinueDraft('')
|
||||||
setContinueModalOpen(false)
|
setContinueModalOpen(false)
|
||||||
@@ -887,6 +832,8 @@ export function Conductor() {
|
|||||||
setMissionModalOpen(false)
|
setMissionModalOpen(false)
|
||||||
setContinueDraft('')
|
setContinueDraft('')
|
||||||
await conductor.sendMission(trimmed)
|
await conductor.sendMission(trimmed)
|
||||||
|
persistConductorGoalDraft('')
|
||||||
|
setGoalDraft('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleQuickActionSelect = (action: (typeof QUICK_ACTIONS)[number]) => {
|
const handleQuickActionSelect = (action: (typeof QUICK_ACTIONS)[number]) => {
|
||||||
@@ -906,9 +853,7 @@ export function Conductor() {
|
|||||||
const continuationSummarySource =
|
const continuationSummarySource =
|
||||||
completeSummary ??
|
completeSummary ??
|
||||||
Object.values(conductor.workerOutputs).find((output) => output.trim()) ??
|
Object.values(conductor.workerOutputs).find((output) => output.trim()) ??
|
||||||
conductor.workers
|
conductor.workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)).find((output) => output.trim()) ??
|
||||||
.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined))
|
|
||||||
.find((output) => output.trim()) ??
|
|
||||||
conductor.streamText
|
conductor.streamText
|
||||||
|
|
||||||
const combinedPrompt = [
|
const combinedPrompt = [
|
||||||
@@ -999,9 +944,7 @@ export function Conductor() {
|
|||||||
const s = session as GatewaySession
|
const s = session as GatewaySession
|
||||||
const updatedAt = typeof s.updatedAt === 'string' ? new Date(s.updatedAt).getTime() : typeof s.updatedAt === 'number' ? s.updatedAt : 0
|
const updatedAt = typeof s.updatedAt === 'string' ? new Date(s.updatedAt).getTime() : typeof s.updatedAt === 'number' ? s.updatedAt : 0
|
||||||
const statusText = `${s.status ?? ''} ${s.kind ?? ''}`.toLowerCase()
|
const statusText = `${s.status ?? ''} ${s.kind ?? ''}`.toLowerCase()
|
||||||
const status = /error|failed/.test(statusText) ? 'error' as const
|
const status = /error|failed/.test(statusText) ? ('error' as const) : /pause/.test(statusText) ? ('paused' as const) : Date.now() - updatedAt < 120_000 ? ('active' as const) : ('idle' as const)
|
||||||
: /pause/.test(statusText) ? 'paused' as const
|
|
||||||
: Date.now() - updatedAt < 120_000 ? 'active' as const : 'idle' as const
|
|
||||||
return {
|
return {
|
||||||
id: s.key ?? `session-${i}`,
|
id: s.key ?? `session-${i}`,
|
||||||
name: OFFICE_NAMES[i % OFFICE_NAMES.length],
|
name: OFFICE_NAMES[i % OFFICE_NAMES.length],
|
||||||
@@ -1055,10 +998,9 @@ export function Conductor() {
|
|||||||
}, [conductor.conductorSettings.workerModel, conductor.goal, conductor.isPaused, conductor.tasks, conductor.workerOutputs, conductor.workers])
|
}, [conductor.conductorSettings.workerModel, conductor.goal, conductor.isPaused, conductor.tasks, conductor.workerOutputs, conductor.workers])
|
||||||
|
|
||||||
const completePhaseProjectPath = useMemo(() => {
|
const completePhaseProjectPath = useMemo(() => {
|
||||||
const workerOutputTexts = [
|
const workerOutputTexts = [...Object.values(conductor.workerOutputs), ...conductor.workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined))].filter(
|
||||||
...Object.values(conductor.workerOutputs),
|
Boolean,
|
||||||
...conductor.workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)),
|
)
|
||||||
].filter(Boolean)
|
|
||||||
|
|
||||||
for (const text of workerOutputTexts) {
|
for (const text of workerOutputTexts) {
|
||||||
const extractedPath = extractProjectPath(text)
|
const extractedPath = extractProjectPath(text)
|
||||||
@@ -1077,14 +1019,9 @@ export function Conductor() {
|
|||||||
const candidates = buildProjectPathCandidates(conductor.workers, conductor.missionStartedAt)
|
const candidates = buildProjectPathCandidates(conductor.workers, conductor.missionStartedAt)
|
||||||
return candidates[0] ?? null
|
return candidates[0] ?? null
|
||||||
}, [conductor.tasks, conductor.streamText, conductor.workerOutputs, conductor.workers, conductor.missionStartedAt])
|
}, [conductor.tasks, conductor.streamText, conductor.workerOutputs, conductor.workers, conductor.missionStartedAt])
|
||||||
const completePhaseOutputLabel = useMemo(
|
const completePhaseOutputLabel = useMemo(() => getOutputDisplayName(completePhaseProjectPath), [completePhaseProjectPath])
|
||||||
() => getOutputDisplayName(completePhaseProjectPath),
|
|
||||||
[completePhaseProjectPath],
|
|
||||||
)
|
|
||||||
|
|
||||||
const previewUrl = completePhaseProjectPath
|
const previewUrl = completePhaseProjectPath ? `/api/preview-file?path=${encodeURIComponent(`${completePhaseProjectPath}/index.html`)}` : null
|
||||||
? `/api/preview-file?path=${encodeURIComponent(`${completePhaseProjectPath}/index.html`)}`
|
|
||||||
: null
|
|
||||||
|
|
||||||
const selectedHistoryOutputPath = useMemo(() => {
|
const selectedHistoryOutputPath = useMemo(() => {
|
||||||
const entry = conductor.selectedHistoryEntry
|
const entry = conductor.selectedHistoryEntry
|
||||||
@@ -1099,13 +1036,8 @@ export function Conductor() {
|
|||||||
)
|
)
|
||||||
return candidates[0] ?? null
|
return candidates[0] ?? null
|
||||||
}, [conductor.selectedHistoryEntry])
|
}, [conductor.selectedHistoryEntry])
|
||||||
const selectedHistoryOutputLabel = useMemo(
|
const selectedHistoryOutputLabel = useMemo(() => getOutputDisplayName(selectedHistoryOutputPath), [selectedHistoryOutputPath])
|
||||||
() => getOutputDisplayName(selectedHistoryOutputPath),
|
const selectedHistoryPreviewUrl = selectedHistoryOutputPath ? `/api/preview-file?path=${encodeURIComponent(`${selectedHistoryOutputPath}/index.html`)}` : null
|
||||||
[selectedHistoryOutputPath],
|
|
||||||
)
|
|
||||||
const selectedHistoryPreviewUrl = selectedHistoryOutputPath
|
|
||||||
? `/api/preview-file?path=${encodeURIComponent(`${selectedHistoryOutputPath}/index.html`)}`
|
|
||||||
: null
|
|
||||||
|
|
||||||
// Skip preview probe for history entries — /tmp files are ephemeral and won't exist later.
|
// Skip preview probe for history entries — /tmp files are ephemeral and won't exist later.
|
||||||
// Only probe if the mission just completed (still in complete phase with matching output path).
|
// Only probe if the mission just completed (still in complete phase with matching output path).
|
||||||
@@ -1148,9 +1080,7 @@ export function Conductor() {
|
|||||||
const summarySource =
|
const summarySource =
|
||||||
completeSummary ??
|
completeSummary ??
|
||||||
Object.values(conductor.workerOutputs).find((output) => output.trim()) ??
|
Object.values(conductor.workerOutputs).find((output) => output.trim()) ??
|
||||||
conductor.workers
|
conductor.workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)).find((output) => output.trim()) ??
|
||||||
.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined))
|
|
||||||
.find((output) => output.trim()) ??
|
|
||||||
conductor.streamText
|
conductor.streamText
|
||||||
return truncateContinuationText(summarySource ?? '')
|
return truncateContinuationText(summarySource ?? '')
|
||||||
}, [completeSummary, conductor.streamText, conductor.workerOutputs, conductor.workers])
|
}, [completeSummary, conductor.streamText, conductor.workerOutputs, conductor.workers])
|
||||||
@@ -1165,9 +1095,7 @@ export function Conductor() {
|
|||||||
const filteredSessions = (() => {
|
const filteredSessions = (() => {
|
||||||
const sessions = conductor.recentSessions
|
const sessions = conductor.recentSessions
|
||||||
if (activityFilter === 'all') return sessions
|
if (activityFilter === 'all') return sessions
|
||||||
return sessions
|
return sessions.filter((session) => ((session.label as string) ?? '').startsWith('worker-')).filter((session) => deriveSessionStatus(session as GatewaySession) === activityFilter)
|
||||||
.filter((session) => ((session.label as string) ?? '').startsWith('worker-'))
|
|
||||||
.filter((session) => deriveSessionStatus(session as GatewaySession) === activityFilter)
|
|
||||||
})()
|
})()
|
||||||
const activityItems: Array<MissionHistoryEntry | GatewaySession> = hasMissionHistory ? filteredHistory : filteredSessions
|
const activityItems: Array<MissionHistoryEntry | GatewaySession> = hasMissionHistory ? filteredHistory : filteredSessions
|
||||||
const ACTIVITY_PAGE_SIZE = 3
|
const ACTIVITY_PAGE_SIZE = 3
|
||||||
@@ -1199,9 +1127,7 @@ export function Conductor() {
|
|||||||
const showHistoryOutputFallback = !!historyOutputText && (!selectedHistoryOutputPath || selectedHistoryPreview.unavailable)
|
const showHistoryOutputFallback = !!historyOutputText && (!selectedHistoryOutputPath || selectedHistoryPreview.unavailable)
|
||||||
const historyStatusLabel = selectedHistoryEntry.status === 'completed' ? 'Complete' : 'Stopped'
|
const historyStatusLabel = selectedHistoryEntry.status === 'completed' ? 'Complete' : 'Stopped'
|
||||||
const historyStatusClasses =
|
const historyStatusClasses =
|
||||||
selectedHistoryEntry.status === 'completed'
|
selectedHistoryEntry.status === 'completed' ? 'border border-emerald-400/35 bg-emerald-500/10 text-emerald-300' : 'border border-red-400/35 bg-red-500/10 text-red-300'
|
||||||
? 'border border-emerald-400/35 bg-emerald-500/10 text-emerald-300'
|
|
||||||
: 'border border-red-400/35 bg-red-500/10 text-red-300'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col overflow-y-auto bg-[var(--theme-bg)] text-[var(--theme-text)]" style={THEME_STYLE}>
|
<div className="flex min-h-dvh flex-col overflow-y-auto bg-[var(--theme-bg)] text-[var(--theme-text)]" style={THEME_STYLE}>
|
||||||
@@ -1223,7 +1149,8 @@ export function Conductor() {
|
|||||||
</p>
|
</p>
|
||||||
<h1 className="mt-2 text-xl font-semibold tracking-tight text-[var(--theme-text)] sm:text-2xl">{selectedHistoryEntry.goal}</h1>
|
<h1 className="mt-2 text-xl font-semibold tracking-tight text-[var(--theme-text)] sm:text-2xl">{selectedHistoryEntry.goal}</h1>
|
||||||
<p className="mt-2 text-xs text-[var(--theme-muted-2)]">
|
<p className="mt-2 text-xs text-[var(--theme-muted-2)]">
|
||||||
{selectedHistoryEntry.workerCount}/{Math.max(selectedHistoryEntry.workerCount, 1)} workers finished · {formatDurationRange(selectedHistoryEntry.startedAt, selectedHistoryEntry.completedAt, now)} total elapsed
|
{selectedHistoryEntry.workerCount}/{Math.max(selectedHistoryEntry.workerCount, 1)} workers finished ·{' '}
|
||||||
|
{formatDurationRange(selectedHistoryEntry.startedAt, selectedHistoryEntry.completedAt, now)} total elapsed
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -1258,12 +1185,7 @@ export function Conductor() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 overflow-auto rounded-2xl border border-[var(--theme-border)] bg-white">
|
<div className="mt-4 overflow-auto rounded-2xl border border-[var(--theme-border)] bg-white">
|
||||||
<iframe
|
<iframe src={selectedHistoryPreviewUrl!} className="h-[clamp(280px,55vh,520px)] w-full" sandbox="allow-scripts allow-same-origin" title="Mission history output preview" />
|
||||||
src={selectedHistoryPreviewUrl!}
|
|
||||||
className="h-[clamp(280px,55vh,520px)] w-full"
|
|
||||||
sandbox="allow-scripts allow-same-origin"
|
|
||||||
title="Mission history output preview"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : selectedHistoryOutputPath && selectedHistoryPreview.loading ? (
|
) : selectedHistoryOutputPath && selectedHistoryPreview.loading ? (
|
||||||
@@ -1274,7 +1196,9 @@ export function Conductor() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : selectedHistoryOutputPath && selectedHistoryPreview.unavailable ? (
|
) : selectedHistoryOutputPath && selectedHistoryPreview.unavailable ? (
|
||||||
showHistoryOutputFallback ? null : <p className="px-1 text-sm text-[var(--theme-muted)]">No preview available.</p>
|
showHistoryOutputFallback ? null : (
|
||||||
|
<p className="px-1 text-sm text-[var(--theme-muted)]">No preview available.</p>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] p-6 shadow-[0_24px_80px_var(--theme-shadow)]">
|
<section className="overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] p-6 shadow-[0_24px_80px_var(--theme-shadow)]">
|
||||||
@@ -1282,9 +1206,7 @@ export function Conductor() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Agent Summary</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Agent Summary</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn('rounded-full px-3 py-1 text-xs font-medium', historyStatusClasses)}>
|
<span className={cn('rounded-full px-3 py-1 text-xs font-medium', historyStatusClasses)}>{historyStatusLabel}</span>
|
||||||
{historyStatusLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
<div className="mt-4 overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
||||||
{historySummary ? (
|
{historySummary ? (
|
||||||
@@ -1298,9 +1220,13 @@ export function Conductor() {
|
|||||||
{historyWorkerDetails.map((worker: MissionHistoryWorkerDetail, index) => (
|
{historyWorkerDetails.map((worker: MissionHistoryWorkerDetail, index) => (
|
||||||
<div key={`${selectedHistoryEntry.id}-worker-${index}`} className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm">
|
<div key={`${selectedHistoryEntry.id}-worker-${index}`} className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm">
|
||||||
<span className={cn('size-2 rounded-full', selectedHistoryEntry.status === 'completed' ? 'bg-emerald-400' : 'bg-red-400')} />
|
<span className={cn('size-2 rounded-full', selectedHistoryEntry.status === 'completed' ? 'bg-emerald-400' : 'bg-red-400')} />
|
||||||
<span className="font-medium text-[var(--theme-text)]">{worker.personaEmoji} {worker.personaName}</span>
|
<span className="font-medium text-[var(--theme-text)]">
|
||||||
|
{worker.personaEmoji} {worker.personaName}
|
||||||
|
</span>
|
||||||
<span className="text-[var(--theme-muted)]">{worker.label}</span>
|
<span className="text-[var(--theme-muted)]">{worker.label}</span>
|
||||||
<span className="ml-auto text-xs text-[var(--theme-muted)]">{getShortModelName(worker.model)} · {worker.totalTokens.toLocaleString()} tok</span>
|
<span className="ml-auto text-xs text-[var(--theme-muted)]">
|
||||||
|
{getShortModelName(worker.model)} · {worker.totalTokens.toLocaleString()} tok
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1331,7 +1257,8 @@ export function Conductor() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Output</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Output</p>
|
||||||
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">
|
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">
|
||||||
Preview unavailable{selectedHistoryOutputPath ? ` for ${selectedHistoryOutputLabel}` : ''}.
|
Preview unavailable
|
||||||
|
{selectedHistoryOutputPath ? ` for ${selectedHistoryOutputLabel}` : ''}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1407,13 +1334,15 @@ export function Conductor() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{(hasMissionHistory || conductor.recentSessions.length > 0) ? (
|
{hasMissionHistory || conductor.recentSessions.length > 0 ? (
|
||||||
<section className="mt-6 w-full space-y-3">
|
<section className="mt-6 w-full space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--theme-muted)]">Recent Missions</h2>
|
<h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--theme-muted)]">Recent Missions</h2>
|
||||||
{activityTotalPages > 1 && (
|
{activityTotalPages > 1 && (
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
<span className="text-[10px] text-[var(--theme-muted-2)]">{safeActivityPage + 1}/{activityTotalPages}</span>
|
<span className="text-[10px] text-[var(--theme-muted-2)]">
|
||||||
|
{safeActivityPage + 1}/{activityTotalPages}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={safeActivityPage === 0}
|
disabled={safeActivityPage === 0}
|
||||||
@@ -1469,9 +1398,7 @@ export function Conductor() {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[76px] shrink-0 rounded-full border px-2 py-0.5 text-center text-[10px] font-medium uppercase tracking-[0.12em]',
|
'w-[76px] shrink-0 rounded-full border px-2 py-0.5 text-center text-[10px] font-medium uppercase tracking-[0.12em]',
|
||||||
entry.status === 'completed'
|
entry.status === 'completed' ? 'border-emerald-400/35 bg-emerald-500/10 text-emerald-300' : 'border-red-400/35 bg-red-500/10 text-red-300',
|
||||||
? 'border-emerald-400/35 bg-emerald-500/10 text-emerald-300'
|
|
||||||
: 'border-red-400/35 bg-red-500/10 text-red-300',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{entry.status === 'completed' ? 'Complete' : 'Failed'}
|
{entry.status === 'completed' ? 'Complete' : 'Failed'}
|
||||||
@@ -1496,18 +1423,10 @@ export function Conductor() {
|
|||||||
? recentSession.createdAt
|
? recentSession.createdAt
|
||||||
: null
|
: null
|
||||||
const sessionStatus = deriveSessionStatus(recentSession)
|
const sessionStatus = deriveSessionStatus(recentSession)
|
||||||
const dotClass =
|
const dotClass = sessionStatus === 'completed' ? 'bg-emerald-400' : sessionStatus === 'failed' ? 'bg-red-400' : 'bg-sky-400 animate-pulse'
|
||||||
sessionStatus === 'completed'
|
|
||||||
? 'bg-emerald-400'
|
|
||||||
: sessionStatus === 'failed'
|
|
||||||
? 'bg-red-400'
|
|
||||||
: 'bg-sky-400 animate-pulse'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={recentSession.key} className="flex items-center gap-3 rounded-xl border border-[var(--theme-border)] bg-[var(--theme-card)] px-3 py-2 text-sm">
|
||||||
key={recentSession.key}
|
|
||||||
className="flex items-center gap-3 rounded-xl border border-[var(--theme-border)] bg-[var(--theme-card)] px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-medium capitalize text-[var(--theme-text)]">{displayName}</span>
|
<span className="min-w-0 flex-1 truncate font-medium capitalize text-[var(--theme-text)]">{displayName}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1531,7 +1450,8 @@ export function Conductor() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-dashed border-[var(--theme-border)] px-4 py-6 text-center text-sm text-[var(--theme-muted)]">
|
<div className="rounded-xl border border-dashed border-[var(--theme-border)] px-4 py-6 text-center text-sm text-[var(--theme-muted)]">
|
||||||
No {activityFilter === 'all' ? '' : `${activityFilter} `}{hasMissionHistory ? 'missions' : 'sessions'} found
|
No {activityFilter === 'all' ? '' : `${activityFilter} `}
|
||||||
|
{hasMissionHistory ? 'missions' : 'sessions'} found
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -1605,11 +1525,7 @@ export function Conductor() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button type="submit" disabled={!goalDraft.trim() || conductor.isSending} className="rounded-full bg-[var(--theme-accent)] px-5 text-white hover:bg-[var(--theme-accent-strong)]">
|
||||||
type="submit"
|
|
||||||
disabled={!goalDraft.trim() || conductor.isSending}
|
|
||||||
className="rounded-full bg-[var(--theme-accent)] px-5 text-white hover:bg-[var(--theme-accent-strong)]"
|
|
||||||
>
|
|
||||||
{conductor.isSending ? 'Launching...' : 'Launch Mission'}
|
{conductor.isSending ? 'Launching...' : 'Launch Mission'}
|
||||||
<HugeiconsIcon icon={ArrowRight01Icon} size={16} strokeWidth={1.7} />
|
<HugeiconsIcon icon={ArrowRight01Icon} size={16} strokeWidth={1.7} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1736,10 +1652,7 @@ export function Conductor() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{directoryBrowserOpen ? (
|
{directoryBrowserOpen ? (
|
||||||
<div
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-[color-mix(in_srgb,var(--theme-bg)_55%,transparent)] px-4 py-6 backdrop-blur-md" onClick={closeDirectoryBrowser}>
|
||||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-[color-mix(in_srgb,var(--theme-bg)_55%,transparent)] px-4 py-6 backdrop-blur-md"
|
|
||||||
onClick={closeDirectoryBrowser}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-2xl rounded-3xl border border-[var(--theme-border2)] bg-[var(--theme-card)] p-5 shadow-[0_24px_80px_var(--theme-shadow)] sm:p-6"
|
className="w-full max-w-2xl rounded-3xl border border-[var(--theme-border2)] bg-[var(--theme-card)] p-5 shadow-[0_24px_80px_var(--theme-shadow)] sm:p-6"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
@@ -1785,9 +1698,7 @@ export function Conductor() {
|
|||||||
onClick={() => setDirectoryBrowserPath(crumb.path)}
|
onClick={() => setDirectoryBrowserPath(crumb.path)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md px-1.5 py-0.5 transition-colors',
|
'rounded-md px-1.5 py-0.5 transition-colors',
|
||||||
crumb.path === directoryBrowserPath
|
crumb.path === directoryBrowserPath ? 'bg-[var(--theme-accent-soft)] text-[var(--theme-accent-strong)]' : 'text-[var(--theme-text)] hover:bg-[var(--theme-card2)]',
|
||||||
? 'bg-[var(--theme-accent-soft)] text-[var(--theme-accent-strong)]'
|
|
||||||
: 'text-[var(--theme-text)] hover:bg-[var(--theme-card2)]',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
@@ -1806,9 +1717,7 @@ export function Conductor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{directoryBrowserError ? (
|
{directoryBrowserError ? (
|
||||||
<div className="rounded-2xl border border-[var(--theme-warning-border)] bg-[var(--theme-warning-soft)] px-4 py-3 text-sm text-[var(--theme-warning)]">
|
<div className="rounded-2xl border border-[var(--theme-warning-border)] bg-[var(--theme-warning-soft)] px-4 py-3 text-sm text-[var(--theme-warning)]">{directoryBrowserError}</div>
|
||||||
{directoryBrowserError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)]">
|
<div className="rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)]">
|
||||||
@@ -1842,9 +1751,7 @@ export function Conductor() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-4 py-10 text-center text-sm text-[var(--theme-muted)]">
|
<div className="px-4 py-10 text-center text-sm text-[var(--theme-muted)]">No folders found in this location.</div>
|
||||||
No folders found in this location.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1901,9 +1808,7 @@ export function Conductor() {
|
|||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--theme-accent)]">Mission Decomposition</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--theme-accent)]">Mission Decomposition</p>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{conductor.goal}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{conductor.goal}</h1>
|
||||||
<p className="text-sm text-[var(--theme-muted-2)]">
|
<p className="text-sm text-[var(--theme-muted-2)]">The agent is breaking the mission into workers. Once they spawn, this view flips into the active board.</p>
|
||||||
The agent is breaking the mission into workers. Once they spawn, this view flips into the active board.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] p-6 shadow-[0_24px_80px_var(--theme-shadow)]">
|
<section className="rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] p-6 shadow-[0_24px_80px_var(--theme-shadow)]">
|
||||||
@@ -1912,27 +1817,19 @@ export function Conductor() {
|
|||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Mission Planning</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Mission Planning</p>
|
||||||
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">Analyzing your request and preparing agents</p>
|
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">Analyzing your request and preparing agents</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full border border-sky-400/30 bg-sky-500/10 px-3 py-1 text-xs font-medium text-sky-300 animate-pulse">
|
<span className="rounded-full border border-sky-400/30 bg-sky-500/10 px-3 py-1 text-xs font-medium text-sky-300 animate-pulse">Working</span>
|
||||||
Working
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 min-h-[200px] overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
<div className="mt-4 min-h-[200px] overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
||||||
{conductor.planText ? (
|
{conductor.planText ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Markdown className="max-h-[500px] max-w-none overflow-auto text-sm text-[var(--theme-text)]">
|
<Markdown className="max-h-[500px] max-w-none overflow-auto text-sm text-[var(--theme-text)]">{conductor.planText.replace(/(.{20,}?)\1+/g, '$1')}</Markdown>
|
||||||
{conductor.planText.replace(/(.{20,}?)\1+/g, '$1')}
|
|
||||||
</Markdown>
|
|
||||||
<PlanningIndicator />
|
<PlanningIndicator />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<PlanningIndicator />
|
<PlanningIndicator />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{conductor.streamError && (
|
{conductor.streamError && <div className="mt-4 rounded-2xl border border-red-400/40 bg-red-500/10 px-4 py-3 text-sm text-red-600">{conductor.streamError}</div>}
|
||||||
<div className="mt-4 rounded-2xl border border-red-400/40 bg-red-500/10 px-4 py-3 text-sm text-red-600">
|
|
||||||
{conductor.streamError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{conductor.timeoutWarning && (
|
{conductor.timeoutWarning && (
|
||||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-2xl border border-amber-400/40 bg-amber-500/10 px-5 py-3">
|
<div className="mt-4 flex items-center justify-between gap-3 rounded-2xl border border-amber-400/40 bg-amber-500/10 px-5 py-3">
|
||||||
<p className="text-sm text-amber-700">⚠️ Planning is taking longer than expected...</p>
|
<p className="text-sm text-amber-700">⚠️ Planning is taking longer than expected...</p>
|
||||||
@@ -1947,14 +1844,9 @@ export function Conductor() {
|
|||||||
)}
|
)}
|
||||||
{conductor.tasks.length > 0 && (
|
{conductor.tasks.length > 0 && (
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Identified Tasks ({conductor.tasks.length})</p>
|
||||||
Identified Tasks ({conductor.tasks.length})
|
|
||||||
</p>
|
|
||||||
{conductor.tasks.map((task) => (
|
{conductor.tasks.map((task) => (
|
||||||
<div
|
<div key={task.id} className="flex items-center gap-2 rounded-lg border border-[var(--theme-border)] bg-[var(--theme-card2)] px-3 py-2 text-sm">
|
||||||
key={task.id}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-[var(--theme-border)] bg-[var(--theme-card2)] px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<span className="size-2 rounded-full bg-zinc-500" />
|
<span className="size-2 rounded-full bg-zinc-500" />
|
||||||
<span className="text-[var(--theme-text)]">{task.title}</span>
|
<span className="text-[var(--theme-text)]">{task.title}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1997,11 +1889,7 @@ export function Conductor() {
|
|||||||
>
|
>
|
||||||
Retry Mission
|
Retry Mission
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" onClick={handleNewMission} className="rounded-xl bg-[var(--theme-accent)] px-4 text-white hover:bg-[var(--theme-accent-strong)]">
|
||||||
type="button"
|
|
||||||
onClick={handleNewMission}
|
|
||||||
className="rounded-xl bg-[var(--theme-accent)] px-4 text-white hover:bg-[var(--theme-accent-strong)]"
|
|
||||||
>
|
|
||||||
New Mission
|
New Mission
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2016,7 +1904,8 @@ export function Conductor() {
|
|||||||
</p>
|
</p>
|
||||||
<h1 className="mt-2 text-xl font-semibold tracking-tight text-[var(--theme-text)] sm:text-2xl">{conductor.goal}</h1>
|
<h1 className="mt-2 text-xl font-semibold tracking-tight text-[var(--theme-text)] sm:text-2xl">{conductor.goal}</h1>
|
||||||
<p className="mt-2 text-xs text-[var(--theme-muted-2)]">
|
<p className="mt-2 text-xs text-[var(--theme-muted-2)]">
|
||||||
{completedWorkers}/{Math.max(totalWorkers, completedWorkers)} workers finished · {formatElapsedTime(conductor.missionStartedAt, conductor.completedAt ? new Date(conductor.completedAt).getTime() : now)} total elapsed
|
{completedWorkers}/{Math.max(totalWorkers, completedWorkers)} workers finished ·{' '}
|
||||||
|
{formatElapsedTime(conductor.missionStartedAt, conductor.completedAt ? new Date(conductor.completedAt).getTime() : now)} total elapsed
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -2029,11 +1918,7 @@ export function Conductor() {
|
|||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button type="button" onClick={handleNewMission} className="rounded-xl bg-[var(--theme-accent)] px-5 text-white hover:bg-[var(--theme-accent-strong)]">
|
||||||
type="button"
|
|
||||||
onClick={handleNewMission}
|
|
||||||
className="rounded-xl bg-[var(--theme-accent)] px-5 text-white hover:bg-[var(--theme-accent-strong)]"
|
|
||||||
>
|
|
||||||
New Mission
|
New Mission
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2045,9 +1930,7 @@ export function Conductor() {
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Output Preview</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Output Preview</p>
|
||||||
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">
|
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">{completePhaseProjectPath.split('/').pop() || 'index.html'}</p>
|
||||||
{completePhaseProjectPath.split('/').pop() || 'index.html'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<a
|
<a
|
||||||
@@ -2068,12 +1951,7 @@ export function Conductor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 overflow-auto rounded-2xl border border-[var(--theme-border)] bg-white">
|
<div className="mt-4 overflow-auto rounded-2xl border border-[var(--theme-border)] bg-white">
|
||||||
<iframe
|
<iframe src={previewUrl!} className="h-[clamp(280px,55vh,520px)] w-full" sandbox="allow-scripts allow-same-origin" title="Mission output preview" />
|
||||||
src={previewUrl!}
|
|
||||||
className="h-[clamp(280px,55vh,520px)] w-full"
|
|
||||||
sandbox="allow-scripts allow-same-origin"
|
|
||||||
title="Mission output preview"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : completePhaseProjectPath && previewState.loading && !conductor.streamError ? (
|
) : completePhaseProjectPath && previewState.loading && !conductor.streamError ? (
|
||||||
@@ -2086,38 +1964,41 @@ export function Conductor() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Worker output fallback — show when no iframe preview is available */}
|
{/* Worker output fallback — show when no iframe preview is available */}
|
||||||
{(!completePhaseProjectPath || previewState.unavailable) && (() => {
|
{(!completePhaseProjectPath || previewState.unavailable) &&
|
||||||
const outputSections = conductor.workers
|
(() => {
|
||||||
.map((worker, index) => {
|
const outputSections = conductor.workers
|
||||||
const output = (conductor.workerOutputs[worker.key] ?? getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)).trim()
|
.map((worker, index) => {
|
||||||
if (!output) return null
|
const output = (conductor.workerOutputs[worker.key] ?? getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)).trim()
|
||||||
const persona = getAgentPersona(index)
|
if (!output) return null
|
||||||
return { key: worker.key, persona, label: worker.label, output }
|
const persona = getAgentPersona(index)
|
||||||
})
|
return {
|
||||||
.filter((section): section is NonNullable<typeof section> => section !== null)
|
key: worker.key,
|
||||||
|
persona,
|
||||||
|
label: worker.label,
|
||||||
|
output,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((section): section is NonNullable<typeof section> => section !== null)
|
||||||
|
|
||||||
const fallbackText = outputSections.length > 0
|
const fallbackText =
|
||||||
? outputSections.map((s) => `### ${s.persona.emoji} ${s.persona.name} · ${s.label}\n\n${s.output}`).join('\n\n---\n\n')
|
outputSections.length > 0 ? outputSections.map((s) => `### ${s.persona.emoji} ${s.persona.name} · ${s.label}\n\n${s.output}`).join('\n\n---\n\n') : conductor.streamText.trim()
|
||||||
: conductor.streamText.trim()
|
|
||||||
|
|
||||||
if (!fallbackText) return null
|
if (!fallbackText) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] p-6 shadow-[0_24px_80px_var(--theme-shadow)]">
|
<section className="overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] p-6 shadow-[0_24px_80px_var(--theme-shadow)]">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Output</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Output</p>
|
||||||
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">
|
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">{completePhaseProjectPath ? `Preview unavailable for ${completePhaseOutputLabel}` : 'Agent work output'}</p>
|
||||||
{completePhaseProjectPath ? `Preview unavailable for ${completePhaseOutputLabel}` : 'Agent work output'}
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-4 overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
||||||
<div className="mt-4 overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
<Markdown className="max-h-[600px] max-w-none overflow-auto text-sm text-[var(--theme-text)]">{fallbackText}</Markdown>
|
||||||
<Markdown className="max-h-[600px] max-w-none overflow-auto text-sm text-[var(--theme-text)]">{fallbackText}</Markdown>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
)
|
||||||
)
|
})()}
|
||||||
})()}
|
|
||||||
|
|
||||||
{conductor.tasks.length > 1 && completedTaskOutputs.length > 0 && (
|
{conductor.tasks.length > 1 && completedTaskOutputs.length > 0 && (
|
||||||
<section className="overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] p-6 shadow-[0_24px_80px_var(--theme-shadow)]">
|
<section className="overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] p-6 shadow-[0_24px_80px_var(--theme-shadow)]">
|
||||||
@@ -2129,10 +2010,7 @@ export function Conductor() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{completedTaskOutputs.map((task) => (
|
{completedTaskOutputs.map((task) => (
|
||||||
<div
|
<div key={task.id} className="rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-4 py-4">
|
||||||
key={task.id}
|
|
||||||
className="rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-4 py-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -2166,12 +2044,12 @@ export function Conductor() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Agent Summary</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Agent Summary</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn(
|
<span
|
||||||
'rounded-full px-3 py-1 text-xs font-medium',
|
className={cn(
|
||||||
conductor.streamError
|
'rounded-full px-3 py-1 text-xs font-medium',
|
||||||
? 'border border-red-400/35 bg-red-500/10 text-red-300'
|
conductor.streamError ? 'border border-red-400/35 bg-red-500/10 text-red-300' : 'border border-emerald-400/35 bg-emerald-500/10 text-emerald-300',
|
||||||
: 'border border-emerald-400/35 bg-emerald-500/10 text-emerald-300',
|
)}
|
||||||
)}>
|
>
|
||||||
{conductor.streamError ? 'Stopped' : 'Complete'}
|
{conductor.streamError ? 'Stopped' : 'Complete'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2192,9 +2070,13 @@ export function Conductor() {
|
|||||||
return (
|
return (
|
||||||
<div key={worker.key} className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm">
|
<div key={worker.key} className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm">
|
||||||
<span className="size-2 rounded-full bg-emerald-400" />
|
<span className="size-2 rounded-full bg-emerald-400" />
|
||||||
<span className="font-medium text-[var(--theme-text)]">{persona.emoji} {persona.name}</span>
|
<span className="font-medium text-[var(--theme-text)]">
|
||||||
|
{persona.emoji} {persona.name}
|
||||||
|
</span>
|
||||||
<span className="text-[var(--theme-muted)]">{worker.label}</span>
|
<span className="text-[var(--theme-muted)]">{worker.label}</span>
|
||||||
<span className="ml-auto text-xs text-[var(--theme-muted)]">{shortModelName} · {worker.totalTokens.toLocaleString()} tok</span>
|
<span className="ml-auto text-xs text-[var(--theme-muted)]">
|
||||||
|
{shortModelName} · {worker.totalTokens.toLocaleString()} tok
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -2202,12 +2084,7 @@ export function Conductor() {
|
|||||||
)}
|
)}
|
||||||
{(totalTokens > 0 || completeMissionCostWorkers.length > 0) && (
|
{(totalTokens > 0 || completeMissionCostWorkers.length > 0) && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<MissionCostSection
|
<MissionCostSection totalTokens={totalTokens} workers={completeMissionCostWorkers} expanded={completeCostExpanded} onToggle={() => setCompleteCostExpanded((current) => !current)} />
|
||||||
totalTokens={totalTokens}
|
|
||||||
workers={completeMissionCostWorkers}
|
|
||||||
expanded={completeCostExpanded}
|
|
||||||
onToggle={() => setCompleteCostExpanded((current) => !current)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{conductor.streamText && completeSummary && (
|
{conductor.streamText && completeSummary && (
|
||||||
@@ -2219,7 +2096,6 @@ export function Conductor() {
|
|||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{continueModalOpen ? (
|
{continueModalOpen ? (
|
||||||
@@ -2307,7 +2183,9 @@ export function Conductor() {
|
|||||||
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-[var(--theme-muted)]">
|
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-[var(--theme-muted)]">
|
||||||
<span>{formatElapsedMilliseconds(conductor.isPaused ? conductor.pausedElapsedMs : conductor.missionElapsedMs)}</span>
|
<span>{formatElapsedMilliseconds(conductor.isPaused ? conductor.pausedElapsedMs : conductor.missionElapsedMs)}</span>
|
||||||
<span className="text-[var(--theme-border)]">·</span>
|
<span className="text-[var(--theme-border)]">·</span>
|
||||||
<span>{completedWorkers}/{Math.max(totalWorkers, 1)} complete</span>
|
<span>
|
||||||
|
{completedWorkers}/{Math.max(totalWorkers, 1)} complete
|
||||||
|
</span>
|
||||||
<span className="text-[var(--theme-border)]">·</span>
|
<span className="text-[var(--theme-border)]">·</span>
|
||||||
<span>{activeWorkerCount} active</span>
|
<span>{activeWorkerCount} active</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2381,15 +2259,7 @@ export function Conductor() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
<section className="h-[360px] overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] shadow-[0_24px_80px_var(--theme-shadow)]">
|
<section className="h-[360px] overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] shadow-[0_24px_80px_var(--theme-shadow)]">
|
||||||
<OfficeView
|
<OfficeView agentRows={officeAgentRows} missionRunning onViewOutput={() => {}} processType="parallel" companyName="Conductor Office" containerHeight={360} hideHeader />
|
||||||
agentRows={officeAgentRows}
|
|
||||||
missionRunning
|
|
||||||
onViewOutput={() => {}}
|
|
||||||
processType="parallel"
|
|
||||||
companyName="Conductor Office"
|
|
||||||
containerHeight={360}
|
|
||||||
hideHeader
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{conductor.tasks.length > 0 ? (
|
{conductor.tasks.length > 0 ? (
|
||||||
@@ -2400,14 +2270,7 @@ export function Conductor() {
|
|||||||
</h2>
|
</h2>
|
||||||
{conductor.tasks.map((task) => {
|
{conductor.tasks.map((task) => {
|
||||||
const isSelected = selectedTaskId === task.id
|
const isSelected = selectedTaskId === task.id
|
||||||
const statusDot =
|
const statusDot = task.status === 'complete' ? 'bg-emerald-400' : task.status === 'running' ? 'bg-sky-400 animate-pulse' : task.status === 'failed' ? 'bg-red-400' : 'bg-zinc-500'
|
||||||
task.status === 'complete'
|
|
||||||
? 'bg-emerald-400'
|
|
||||||
: task.status === 'running'
|
|
||||||
? 'bg-sky-400 animate-pulse'
|
|
||||||
: task.status === 'failed'
|
|
||||||
? 'bg-red-400'
|
|
||||||
: 'bg-zinc-500'
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={task.id}
|
key={task.id}
|
||||||
@@ -2415,9 +2278,7 @@ export function Conductor() {
|
|||||||
onClick={() => setSelectedTaskId(isSelected ? null : task.id)}
|
onClick={() => setSelectedTaskId(isSelected ? null : task.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full rounded-xl border px-3 py-2.5 text-left text-sm transition-colors',
|
'w-full rounded-xl border px-3 py-2.5 text-left text-sm transition-colors',
|
||||||
isSelected
|
isSelected ? 'border-[var(--theme-accent)] bg-[var(--theme-accent-soft)]' : 'border-[var(--theme-border)] bg-[var(--theme-card)] hover:border-[var(--theme-accent)]',
|
||||||
? 'border-[var(--theme-accent)] bg-[var(--theme-accent-soft)]'
|
|
||||||
: 'border-[var(--theme-border)] bg-[var(--theme-card)] hover:border-[var(--theme-accent)]',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -2437,27 +2298,20 @@ export function Conductor() {
|
|||||||
) : null}
|
) : null}
|
||||||
{(() => {
|
{(() => {
|
||||||
const selectedTask = selectedTaskId ? conductor.tasks.find((task) => task.id === selectedTaskId) : null
|
const selectedTask = selectedTaskId ? conductor.tasks.find((task) => task.id === selectedTaskId) : null
|
||||||
const displayWorkers = selectedTask?.workerKey
|
const displayWorkers = selectedTask?.workerKey ? conductor.workers.filter((worker) => worker.key === selectedTask.workerKey) : conductor.workers
|
||||||
? conductor.workers.filter((worker) => worker.key === selectedTask.workerKey)
|
|
||||||
: conductor.workers
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
{displayWorkers.map((worker, index) => {
|
{displayWorkers.map((worker, index) => {
|
||||||
return (
|
return <WorkerCard key={worker.key} worker={worker} index={index} conductor={conductor} now={now} />
|
||||||
<WorkerCard
|
|
||||||
key={worker.key}
|
|
||||||
worker={worker}
|
|
||||||
index={index}
|
|
||||||
conductor={conductor}
|
|
||||||
now={now}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
{displayWorkers.length === 0 && (
|
{displayWorkers.length === 0 && (
|
||||||
<div className="rounded-2xl border border-dashed border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-8 text-center text-sm text-[var(--theme-muted)] md:col-span-2">
|
<div className="rounded-2xl border border-dashed border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-8 text-center text-sm text-[var(--theme-muted)] md:col-span-2">
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="size-4 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
|
<div className="flex items-center justify-center gap-3">
|
||||||
<span>Spawning workers…</span>
|
<div className="size-4 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
|
||||||
|
<span>Spawning workers...</span>
|
||||||
|
</div>
|
||||||
|
{conductor.planText ? <p className="max-w-xl text-xs text-[var(--theme-muted-2)]">{conductor.planText}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2470,28 +2324,22 @@ export function Conductor() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
{conductor.workers.map((worker, index) => {
|
{conductor.workers.map((worker, index) => {
|
||||||
return (
|
return <WorkerCard key={worker.key} worker={worker} index={index} conductor={conductor} now={now} />
|
||||||
<WorkerCard
|
|
||||||
key={worker.key}
|
|
||||||
worker={worker}
|
|
||||||
index={index}
|
|
||||||
conductor={conductor}
|
|
||||||
now={now}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
{conductor.workers.length === 0 && (
|
{conductor.workers.length === 0 && (
|
||||||
<div className="rounded-2xl border border-dashed border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-8 text-center text-sm text-[var(--theme-muted)] md:col-span-2">
|
<div className="rounded-2xl border border-dashed border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-8 text-center text-sm text-[var(--theme-muted)] md:col-span-2">
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="size-4 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
|
<div className="flex items-center justify-center gap-3">
|
||||||
<span>Spawning workers…</span>
|
<div className="size-4 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
|
||||||
|
<span>Spawning workers...</span>
|
||||||
|
</div>
|
||||||
|
{conductor.planText ? <p className="max-w-xl text-xs text-[var(--theme-muted-2)]">{conductor.planText}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
import { fetchSessions, type GatewaySession } from '@/lib/gateway-api'
|
import type { Dispatch, SetStateAction } from 'react'
|
||||||
|
import type { GatewaySession } from '@/lib/gateway-api'
|
||||||
|
import { fetchSessions } from '@/lib/gateway-api'
|
||||||
|
|
||||||
type HistoryMessagePart = {
|
type HistoryMessagePart = {
|
||||||
type?: string
|
type?: string
|
||||||
@@ -17,6 +19,22 @@ type HistoryResponse = {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConductorMissionRecord = {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
status?: string
|
||||||
|
error?: string
|
||||||
|
session_id?: string | null
|
||||||
|
lines?: unknown
|
||||||
|
exit_code?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConductorMissionResponse = {
|
||||||
|
ok?: boolean
|
||||||
|
mission?: ConductorMissionRecord
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
type MissionPhase = 'idle' | 'decomposing' | 'running' | 'complete'
|
type MissionPhase = 'idle' | 'decomposing' | 'running' | 'complete'
|
||||||
|
|
||||||
export type ConductorSettings = {
|
export type ConductorSettings = {
|
||||||
@@ -38,6 +56,8 @@ const DEFAULT_CONDUCTOR_SETTINGS: ConductorSettings = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PersistedMission = {
|
type PersistedMission = {
|
||||||
|
missionId: string | null
|
||||||
|
missionJobId: string | null
|
||||||
goal: string
|
goal: string
|
||||||
phase: MissionPhase
|
phase: MissionPhase
|
||||||
missionStartedAt: string | null
|
missionStartedAt: string | null
|
||||||
@@ -57,11 +77,35 @@ type PersistedMission = {
|
|||||||
type StreamEvent =
|
type StreamEvent =
|
||||||
| { type: 'assistant'; text: string }
|
| { type: 'assistant'; text: string }
|
||||||
| { type: 'thinking'; text: string }
|
| { type: 'thinking'; text: string }
|
||||||
| { type: 'tool'; name?: string; phase?: string; data?: Record<string, unknown> }
|
| {
|
||||||
|
type: 'tool'
|
||||||
|
name?: string
|
||||||
|
phase?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
| { type: 'done'; state?: string; message?: string }
|
| { type: 'done'; state?: string; message?: string }
|
||||||
| { type: 'error'; message: string }
|
| { type: 'error'; message: string }
|
||||||
| { type: 'started'; runId?: string; sessionKey?: string }
|
| { type: 'started'; runId?: string; sessionKey?: string }
|
||||||
|
|
||||||
|
type ConductorSpawnResponse = {
|
||||||
|
ok?: boolean
|
||||||
|
mode?: 'dashboard' | 'portable'
|
||||||
|
prompt?: string | null
|
||||||
|
missionId?: string | null
|
||||||
|
sessionKey?: string | null
|
||||||
|
sessionKeyPrefix?: string | null
|
||||||
|
jobId?: string | null
|
||||||
|
jobName?: string | null
|
||||||
|
runId?: string | null
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PortableStreamResult = {
|
||||||
|
runId: string | null
|
||||||
|
sessionKey: string | null
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ConductorWorker = {
|
export type ConductorWorker = {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
@@ -122,14 +166,9 @@ function getAgentPersona(index: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function extractTasksFromPlan(planText: string): ConductorTask[] {
|
function extractTasksFromPlan(planText: string): ConductorTask[] {
|
||||||
const tasks: ConductorTask[] = []
|
const tasks: ConductorTask[] = []
|
||||||
const patterns = [
|
const patterns = [/^\s*(\d+)\.\s+(.+)$/gm, /^\s*#{1,3}\s+(?:Step\s+)?(\d+)[.:]\s*(.+)$/gm, /^\s*-\s+\*\*(?:Task\s+)?(\d+)[.:]\s*\*\*\s*(.+)$/gm]
|
||||||
/^\s*(\d+)\.\s+(.+)$/gm,
|
|
||||||
/^\s*#{1,3}\s+(?:Step\s+)?(\d+)[.:]\s*(.+)$/gm,
|
|
||||||
/^\s*-\s+\*\*(?:Task\s+)?(\d+)[.:]\s*\*\*\s*(.+)$/gm,
|
|
||||||
]
|
|
||||||
|
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
@@ -140,7 +179,13 @@ function extractTasksFromPlan(planText: string): ConductorTask[] {
|
|||||||
const id = `task-${num}`
|
const id = `task-${num}`
|
||||||
if (!seen.has(id) && title.length > 3 && title.length < 200) {
|
if (!seen.has(id) && title.length > 3 && title.length < 200) {
|
||||||
seen.add(id)
|
seen.add(id)
|
||||||
tasks.push({ id, title, status: 'pending', workerKey: null, output: null })
|
tasks.push({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
status: 'pending',
|
||||||
|
workerKey: null,
|
||||||
|
output: null,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,12 +223,61 @@ function toIso(value: unknown): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMatchText(value: string): string {
|
||||||
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionSearchText(session: GatewaySession): string {
|
||||||
|
return [
|
||||||
|
readString(session.label),
|
||||||
|
readString(session.title),
|
||||||
|
readString(session.derivedTitle),
|
||||||
|
readString(session.preview),
|
||||||
|
readString(session.kind),
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMissionNeedles(goal: string): string[] {
|
||||||
|
const words = normalizeMatchText(goal).split(' ').filter(Boolean)
|
||||||
|
const prefixes = [5, 8, 12]
|
||||||
|
.map((count) => words.slice(0, count).join(' ').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return [...new Set(prefixes)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionMatchesMissionContext(
|
||||||
|
session: GatewaySession,
|
||||||
|
missionStartMs: number,
|
||||||
|
missionNeedles: string[],
|
||||||
|
): boolean {
|
||||||
|
const createdAt = toIso(session.createdAt ?? session.startedAt ?? session.updatedAt)
|
||||||
|
if (!createdAt) return false
|
||||||
|
|
||||||
|
const createdMs = new Date(createdAt).getTime()
|
||||||
|
if (!Number.isFinite(createdMs) || createdMs < missionStartMs) return false
|
||||||
|
|
||||||
|
const totalTokens = readNumber(session.totalTokens) ?? readNumber(session.tokenCount) ?? 0
|
||||||
|
if (totalTokens <= 0) return false
|
||||||
|
|
||||||
|
const text = normalizeMatchText(getSessionSearchText(session))
|
||||||
|
if (!text) return false
|
||||||
|
if (text.includes('mission orchestrator')) return true
|
||||||
|
if (text.includes('dashboard-backed conductor')) return true
|
||||||
|
if (text.includes('conductor mission')) return true
|
||||||
|
|
||||||
|
return missionNeedles.some((needle) => text.includes(needle))
|
||||||
|
}
|
||||||
|
|
||||||
function loadPersistedMission(): PersistedMission | null {
|
function loadPersistedMission(): PersistedMission | null {
|
||||||
try {
|
try {
|
||||||
const raw = globalThis.localStorage?.getItem(ACTIVE_MISSION_STORAGE_KEY)
|
const raw = globalThis.localStorage?.getItem(ACTIVE_MISSION_STORAGE_KEY)
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||||
|
const missionId = readString(parsed.missionId)
|
||||||
|
const missionJobId = readString(parsed.missionJobId)
|
||||||
const goal = typeof parsed.goal === 'string' ? parsed.goal : null
|
const goal = typeof parsed.goal === 'string' ? parsed.goal : null
|
||||||
const phase = parsed.phase
|
const phase = parsed.phase
|
||||||
const streamText = typeof parsed.streamText === 'string' ? parsed.streamText : null
|
const streamText = typeof parsed.streamText === 'string' ? parsed.streamText : null
|
||||||
@@ -192,22 +286,13 @@ function loadPersistedMission(): PersistedMission | null {
|
|||||||
const workerLabels = Array.isArray(parsed.workerLabels) ? parsed.workerLabels.filter((value): value is string => typeof value === 'string') : null
|
const workerLabels = Array.isArray(parsed.workerLabels) ? parsed.workerLabels.filter((value): value is string => typeof value === 'string') : null
|
||||||
const workerOutputs =
|
const workerOutputs =
|
||||||
parsed.workerOutputs && typeof parsed.workerOutputs === 'object' && !Array.isArray(parsed.workerOutputs)
|
parsed.workerOutputs && typeof parsed.workerOutputs === 'object' && !Array.isArray(parsed.workerOutputs)
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(Object.entries(parsed.workerOutputs as Record<string, unknown>).filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string'))
|
||||||
Object.entries(parsed.workerOutputs as Record<string, unknown>).filter(
|
|
||||||
(entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: {}
|
: {}
|
||||||
const missionStartedAt =
|
const missionStartedAt = parsed.missionStartedAt === null || parsed.missionStartedAt === undefined ? null : toIso(parsed.missionStartedAt)
|
||||||
parsed.missionStartedAt === null || parsed.missionStartedAt === undefined ? null : toIso(parsed.missionStartedAt)
|
|
||||||
const isPaused = parsed.isPaused === true
|
const isPaused = parsed.isPaused === true
|
||||||
const pausedElapsedMs = typeof parsed.pausedElapsedMs === 'number' && Number.isFinite(parsed.pausedElapsedMs) ? Math.max(0, parsed.pausedElapsedMs) : 0
|
const pausedElapsedMs = typeof parsed.pausedElapsedMs === 'number' && Number.isFinite(parsed.pausedElapsedMs) ? Math.max(0, parsed.pausedElapsedMs) : 0
|
||||||
const accumulatedPausedMs =
|
const accumulatedPausedMs = typeof parsed.accumulatedPausedMs === 'number' && Number.isFinite(parsed.accumulatedPausedMs) ? Math.max(0, parsed.accumulatedPausedMs) : 0
|
||||||
typeof parsed.accumulatedPausedMs === 'number' && Number.isFinite(parsed.accumulatedPausedMs)
|
const pauseStartedAt = parsed.pauseStartedAt === null || parsed.pauseStartedAt === undefined ? null : toIso(parsed.pauseStartedAt)
|
||||||
? Math.max(0, parsed.accumulatedPausedMs)
|
|
||||||
: 0
|
|
||||||
const pauseStartedAt =
|
|
||||||
parsed.pauseStartedAt === null || parsed.pauseStartedAt === undefined ? null : toIso(parsed.pauseStartedAt)
|
|
||||||
const completedAt = parsed.completedAt === null || parsed.completedAt === undefined ? null : toIso(parsed.completedAt)
|
const completedAt = parsed.completedAt === null || parsed.completedAt === undefined ? null : toIso(parsed.completedAt)
|
||||||
const tasks = Array.isArray(parsed.tasks)
|
const tasks = Array.isArray(parsed.tasks)
|
||||||
? parsed.tasks
|
? parsed.tasks
|
||||||
@@ -217,11 +302,7 @@ function loadPersistedMission(): PersistedMission | null {
|
|||||||
const id = readString(record.id)
|
const id = readString(record.id)
|
||||||
const title = readString(record.title)
|
const title = readString(record.title)
|
||||||
const status = record.status
|
const status = record.status
|
||||||
if (
|
if (!id || !title || (status !== 'pending' && status !== 'running' && status !== 'complete' && status !== 'failed')) {
|
||||||
!id ||
|
|
||||||
!title ||
|
|
||||||
(status !== 'pending' && status !== 'running' && status !== 'complete' && status !== 'failed')
|
|
||||||
) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,30 +317,21 @@ function loadPersistedMission(): PersistedMission | null {
|
|||||||
.filter((task): task is ConductorTask => task !== null)
|
.filter((task): task is ConductorTask => task !== null)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
if (
|
if (!goal || (phase !== 'idle' && phase !== 'decomposing' && phase !== 'running' && phase !== 'complete') || streamText === null || planText === null || !workerKeys || !workerLabels) {
|
||||||
!goal ||
|
|
||||||
(phase !== 'idle' && phase !== 'decomposing' && phase !== 'running' && phase !== 'complete') ||
|
|
||||||
streamText === null ||
|
|
||||||
planText === null ||
|
|
||||||
!workerKeys ||
|
|
||||||
!workerLabels
|
|
||||||
) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// Never restore running/decomposing — if the browser closed mid-mission, it's dead.
|
|
||||||
// Only restore 'complete' (reviewable) or 'idle'.
|
|
||||||
const isStale = phase === 'running' || phase === 'decomposing'
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
goal: isStale ? '' : goal,
|
missionId,
|
||||||
phase: isStale ? 'idle' : phase,
|
missionJobId,
|
||||||
missionStartedAt: isStale ? null : missionStartedAt,
|
goal,
|
||||||
isPaused: isStale ? false : isPaused,
|
phase,
|
||||||
pausedElapsedMs: isStale ? 0 : pausedElapsedMs,
|
missionStartedAt,
|
||||||
accumulatedPausedMs: isStale ? 0 : accumulatedPausedMs,
|
isPaused,
|
||||||
pauseStartedAt: isStale ? null : pauseStartedAt,
|
pausedElapsedMs,
|
||||||
workerKeys: isStale ? [] : workerKeys,
|
accumulatedPausedMs,
|
||||||
workerLabels: isStale ? [] : workerLabels,
|
pauseStartedAt,
|
||||||
|
workerKeys,
|
||||||
|
workerLabels,
|
||||||
workerOutputs,
|
workerOutputs,
|
||||||
streamText,
|
streamText,
|
||||||
planText,
|
planText,
|
||||||
@@ -313,10 +385,7 @@ function loadMissionHistory(): MissionHistoryEntry[] {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const projectPath =
|
const projectPath = (typeof entry.projectPath === 'string' && entry.projectPath.trim()) || extractProjectPath(typeof entry.projectPath === 'string' ? entry.projectPath : '') || null
|
||||||
(typeof entry.projectPath === 'string' && entry.projectPath.trim()) ||
|
|
||||||
extractProjectPath(typeof entry.projectPath === 'string' ? entry.projectPath : '') ||
|
|
||||||
null
|
|
||||||
const outputText = typeof entry.outputText === 'string' ? entry.outputText : undefined
|
const outputText = typeof entry.outputText === 'string' ? entry.outputText : undefined
|
||||||
const streamText = typeof entry.streamText === 'string' ? entry.streamText : undefined
|
const streamText = typeof entry.streamText === 'string' ? entry.streamText : undefined
|
||||||
const outputPath =
|
const outputPath =
|
||||||
@@ -471,8 +540,6 @@ function toWorker(session: GatewaySession): ConductorWorker | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function extractHistoryMessageText(message: HistoryMessage | undefined): string {
|
function extractHistoryMessageText(message: HistoryMessage | undefined): string {
|
||||||
if (!message) return ''
|
if (!message) return ''
|
||||||
if (typeof message.content === 'string') return message.content
|
if (typeof message.content === 'string') return message.content
|
||||||
@@ -498,6 +565,43 @@ function getLastAssistantMessage(messages: HistoryMessage[] | undefined): string
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readMissionLines(mission: ConductorMissionRecord | null | undefined): string[] {
|
||||||
|
if (!Array.isArray(mission?.lines)) return []
|
||||||
|
return mission.lines.filter((line): line is string => typeof line === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSessionIdFromMission(mission: ConductorMissionRecord | null | undefined): string | null {
|
||||||
|
const direct = readString(mission?.session_id)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
const lines = readMissionLines(mission)
|
||||||
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||||
|
const match = lines[index].match(/\bsession_id:\s*([A-Za-z0-9_.:-]+)/)
|
||||||
|
if (match?.[1]) return match[1]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMissionLog(lines: string[]): string {
|
||||||
|
return lines
|
||||||
|
.map((line) => line.trimEnd())
|
||||||
|
.filter((line) => line.trim().length > 0)
|
||||||
|
.join('\n')
|
||||||
|
.slice(-10_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFailedMissionStatus(status: string | null): boolean {
|
||||||
|
return status === 'failed' || status === 'error' || status === 'errored' || status === 'cancelled' || status === 'canceled'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConductorMission(missionId: string): Promise<ConductorMissionRecord> {
|
||||||
|
const response = await fetch(`/api/conductor-spawn?missionId=${encodeURIComponent(missionId)}&lines=400`)
|
||||||
|
const payload = (await response.json().catch(() => ({}))) as ConductorMissionResponse
|
||||||
|
if (!response.ok || !payload.ok || !payload.mission) {
|
||||||
|
throw new Error(payload.error || `Failed to load conductor mission ${missionId}`)
|
||||||
|
}
|
||||||
|
return payload.mission
|
||||||
|
}
|
||||||
|
|
||||||
function extractProjectPath(text: string): string | null {
|
function extractProjectPath(text: string): string | null {
|
||||||
const structuredPatterns = [
|
const structuredPatterns = [
|
||||||
@@ -533,16 +637,8 @@ function extractProjectPath(text: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMissionOutputPath(
|
function buildMissionOutputPath(workers: ConductorWorker[], workerOutputs: Record<string, string>, tasks: ConductorTask[], streamText: string): string | null {
|
||||||
workers: ConductorWorker[],
|
const workerOutputTexts = [...Object.values(workerOutputs), ...workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined))].filter(Boolean)
|
||||||
workerOutputs: Record<string, string>,
|
|
||||||
tasks: ConductorTask[],
|
|
||||||
streamText: string,
|
|
||||||
): string | null {
|
|
||||||
const workerOutputTexts = [
|
|
||||||
...Object.values(workerOutputs),
|
|
||||||
...workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)),
|
|
||||||
].filter(Boolean)
|
|
||||||
|
|
||||||
for (const text of workerOutputTexts) {
|
for (const text of workerOutputTexts) {
|
||||||
const extractedPath = extractProjectPath(text)
|
const extractedPath = extractProjectPath(text)
|
||||||
@@ -564,7 +660,10 @@ function buildMissionOutputPath(
|
|||||||
function summarizeWorkers(workers: ConductorWorker[]): string[] {
|
function summarizeWorkers(workers: ConductorWorker[]): string[] {
|
||||||
return workers.map((worker) => {
|
return workers.map((worker) => {
|
||||||
const output = getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)
|
const output = getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)
|
||||||
const firstLine = output.split(/\n+/).map((line) => line.trim()).find(Boolean)
|
const firstLine = output
|
||||||
|
.split(/\n+/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean)
|
||||||
const statusLabel = worker.status === 'stale' ? 'failed' : worker.status
|
const statusLabel = worker.status === 'stale' ? 'failed' : worker.status
|
||||||
return `${worker.displayName}: ${firstLine ?? `${statusLabel} · ${worker.totalTokens.toLocaleString()} tok`}`
|
return `${worker.displayName}: ${firstLine ?? `${statusLabel} · ${worker.totalTokens.toLocaleString()} tok`}`
|
||||||
})
|
})
|
||||||
@@ -587,12 +686,7 @@ function buildCompleteSummary(params: {
|
|||||||
const seconds = totalSeconds % 60
|
const seconds = totalSeconds % 60
|
||||||
const duration = hours > 0 ? `${hours}h ${minutes}m ${seconds}s` : minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`
|
const duration = hours > 0 ? `${hours}h ${minutes}m ${seconds}s` : minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`
|
||||||
|
|
||||||
const lines = [
|
const lines = [streamError ? `❌ ${streamError}` : '✅ Mission completed successfully', '', `**Goal:** ${goal}`, `**Duration:** ${duration}`]
|
||||||
streamError ? `❌ ${streamError}` : '✅ Mission completed successfully',
|
|
||||||
'',
|
|
||||||
`**Goal:** ${goal}`,
|
|
||||||
`**Duration:** ${duration}`,
|
|
||||||
]
|
|
||||||
|
|
||||||
if (totalWorkers > 0) {
|
if (totalWorkers > 0) {
|
||||||
lines.push(`**Workers:** ${totalWorkers} ran · ${totalTokens.toLocaleString()} tokens`)
|
lines.push(`**Workers:** ${totalWorkers} ran · ${totalTokens.toLocaleString()} tokens`)
|
||||||
@@ -630,9 +724,167 @@ async function fetchWorkerOutput(sessionKey: string, limit = 5): Promise<string>
|
|||||||
return getLastAssistantMessage(payload.messages)
|
return getLastAssistantMessage(payload.messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendStreamEvent(
|
||||||
|
update: Dispatch<SetStateAction<Array<StreamEvent>>>,
|
||||||
|
event: StreamEvent,
|
||||||
|
): void {
|
||||||
|
update((current) => [...current.slice(-99), event])
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStreamText(event: string, payload: Record<string, unknown>, currentText: string): string | null {
|
||||||
|
if (event !== 'chunk' && event !== 'assistant') return null
|
||||||
|
const text =
|
||||||
|
readString(payload.delta) ??
|
||||||
|
readString(payload.text) ??
|
||||||
|
readString(payload.content) ??
|
||||||
|
readString(payload.chunk)
|
||||||
|
if (!text) return null
|
||||||
|
return payload.fullReplace === true || event === 'assistant' ? text : currentText + text
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDoneMessageText(payload: Record<string, unknown>): string {
|
||||||
|
const message = readRecord(payload.message)
|
||||||
|
return extractHistoryMessageText(message as HistoryMessage | undefined).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamPortableConductorMission(params: {
|
||||||
|
sessionKey: string
|
||||||
|
friendlyId: string
|
||||||
|
prompt: string
|
||||||
|
model?: string
|
||||||
|
signal: AbortSignal
|
||||||
|
onSessionResolved: (sessionKey: string, runId: string | null) => void
|
||||||
|
onText: (text: string) => void
|
||||||
|
onStreamEvent: (event: StreamEvent) => void
|
||||||
|
}): Promise<PortableStreamResult> {
|
||||||
|
const response = await fetch('/api/send-stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
friendlyId: params.friendlyId,
|
||||||
|
message: params.prompt,
|
||||||
|
history: [],
|
||||||
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
model: params.model || undefined,
|
||||||
|
locale: typeof window !== 'undefined' ? localStorage.getItem('hermes-workspace-locale') || 'en' : 'en',
|
||||||
|
}),
|
||||||
|
signal: params.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '')
|
||||||
|
throw new Error(text || `Conductor stream failed (${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionKey = response.headers.get('x-hermes-session-key')?.trim() || params.sessionKey
|
||||||
|
let runId: string | null = null
|
||||||
|
let accumulated = ''
|
||||||
|
let sawDone = false
|
||||||
|
|
||||||
|
params.onSessionResolved(sessionKey, runId)
|
||||||
|
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
if (!reader) throw new Error('Conductor stream did not include a response body')
|
||||||
|
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- reader.read() exits when done is true
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const blocks = buffer.split('\n\n')
|
||||||
|
buffer = blocks.pop() ?? ''
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (!block.trim()) continue
|
||||||
|
const lines = block.split('\n')
|
||||||
|
let event = ''
|
||||||
|
let data = ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) {
|
||||||
|
event = line.slice(7).trim()
|
||||||
|
} else if (line.startsWith('data: ')) {
|
||||||
|
data += line.slice(6)
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
data += line.slice(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event || !data) continue
|
||||||
|
|
||||||
|
let payload: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
payload = readRecord(JSON.parse(data)) ?? {}
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'started') {
|
||||||
|
runId = readString(payload.runId) ?? runId
|
||||||
|
sessionKey = readString(payload.sessionKey) ?? sessionKey
|
||||||
|
params.onSessionResolved(sessionKey, runId)
|
||||||
|
params.onStreamEvent({ type: 'started', runId: runId ?? undefined, sessionKey })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextText = readStreamText(event, payload, accumulated)
|
||||||
|
if (nextText !== null) {
|
||||||
|
accumulated = nextText
|
||||||
|
params.onText(accumulated)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'thinking') {
|
||||||
|
const text = readString(payload.text) ?? readString(payload.thinking)
|
||||||
|
if (text) params.onStreamEvent({ type: 'thinking', text })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'tool') {
|
||||||
|
const name = readString(payload.name) ?? undefined
|
||||||
|
const phase = readString(payload.phase) ?? undefined
|
||||||
|
params.onStreamEvent({ type: 'tool', name, phase, data: payload })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'done' || event === 'complete') {
|
||||||
|
sawDone = true
|
||||||
|
const state = readString(payload.state) ?? undefined
|
||||||
|
const message = readString(payload.errorMessage) ?? readString(payload.message) ?? undefined
|
||||||
|
const finalText = readDoneMessageText(payload)
|
||||||
|
if (!accumulated && finalText) {
|
||||||
|
accumulated = finalText
|
||||||
|
params.onText(accumulated)
|
||||||
|
}
|
||||||
|
params.onStreamEvent({ type: 'done', state, message })
|
||||||
|
if (state === 'error' && message) throw new Error(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'error') {
|
||||||
|
const message = readString(payload.message) ?? 'Conductor stream error'
|
||||||
|
params.onStreamEvent({ type: 'error', message })
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sawDone && !accumulated) {
|
||||||
|
throw new Error('Conductor stream closed without output')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { runId, sessionKey, text: accumulated }
|
||||||
|
}
|
||||||
|
|
||||||
export function useConductorGateway() {
|
export function useConductorGateway() {
|
||||||
const [initialMission] = useState<PersistedMission | null>(() => loadPersistedMission())
|
const [initialMission] = useState<PersistedMission | null>(() => loadPersistedMission())
|
||||||
|
const [missionId, setMissionId] = useState<string | null>(() => initialMission?.missionId ?? null)
|
||||||
|
const [missionJobId, setMissionJobId] = useState<string | null>(() => initialMission?.missionJobId ?? null)
|
||||||
const [phase, setPhase] = useState<MissionPhase>(() => initialMission?.phase ?? 'idle')
|
const [phase, setPhase] = useState<MissionPhase>(() => initialMission?.phase ?? 'idle')
|
||||||
const [goal, setGoal] = useState(() => initialMission?.goal ?? '')
|
const [goal, setGoal] = useState(() => initialMission?.goal ?? '')
|
||||||
const [orchestratorSessionKey, setOrchestratorSessionKey] = useState<string | null>(() => initialMission?.workerKeys[0] ?? null)
|
const [orchestratorSessionKey, setOrchestratorSessionKey] = useState<string | null>(() => initialMission?.workerKeys[0] ?? null)
|
||||||
@@ -659,6 +911,7 @@ export function useConductorGateway() {
|
|||||||
const historySavedRef = useRef(false)
|
const historySavedRef = useRef(false)
|
||||||
const lastActivityAtRef = useRef<number>(Date.now())
|
const lastActivityAtRef = useRef<number>(Date.now())
|
||||||
const lastWorkerSnapshotRef = useRef('')
|
const lastWorkerSnapshotRef = useRef('')
|
||||||
|
const portableStreamAbortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
const sessionsQuery = useQuery({
|
const sessionsQuery = useQuery({
|
||||||
queryKey: ['conductor', 'gateway', 'sessions'],
|
queryKey: ['conductor', 'gateway', 'sessions'],
|
||||||
@@ -666,6 +919,7 @@ export function useConductorGateway() {
|
|||||||
const payload = await fetchSessions()
|
const payload = await fetchSessions()
|
||||||
const sessions = Array.isArray(payload.sessions) ? payload.sessions : []
|
const sessions = Array.isArray(payload.sessions) ? payload.sessions : []
|
||||||
const missionStartMs = missionStartedAt ? new Date(missionStartedAt).getTime() : 0
|
const missionStartMs = missionStartedAt ? new Date(missionStartedAt).getTime() : 0
|
||||||
|
const missionNeedles = buildMissionNeedles(goal)
|
||||||
return sessions
|
return sessions
|
||||||
.filter((session) => {
|
.filter((session) => {
|
||||||
const label = readString(session.label) ?? ''
|
const label = readString(session.label) ?? ''
|
||||||
@@ -696,6 +950,10 @@ export function useConductorGateway() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (missionStartMs > 0 && sessionMatchesMissionContext(session, missionStartMs, missionNeedles)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
.map(toWorker)
|
.map(toWorker)
|
||||||
@@ -736,28 +994,70 @@ export function useConductorGateway() {
|
|||||||
refetchInterval: false,
|
refetchInterval: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const missionStatusQuery = useQuery({
|
||||||
|
queryKey: ['conductor', 'mission-status', missionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!missionId) return null
|
||||||
|
return fetchConductorMission(missionId)
|
||||||
|
},
|
||||||
|
enabled: Boolean(missionId) && phase !== 'idle',
|
||||||
|
refetchInterval: phase === 'decomposing' || phase === 'running' ? 2_500 : false,
|
||||||
|
})
|
||||||
|
|
||||||
const workers = sessionsQuery.data ?? []
|
const workers = sessionsQuery.data ?? []
|
||||||
const activeWorkers = useMemo(
|
const activeWorkers = useMemo(() => workers.filter((worker) => worker.status === 'running' || worker.status === 'idle'), [workers])
|
||||||
() => workers.filter((worker) => worker.status === 'running' || worker.status === 'idle'),
|
|
||||||
[workers],
|
|
||||||
)
|
|
||||||
const hasPersistedMission = initialMission !== null
|
const hasPersistedMission = initialMission !== null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mission = missionStatusQuery.data
|
||||||
|
if (!mission) return
|
||||||
|
|
||||||
|
const status = readString(mission.status)?.toLowerCase() ?? null
|
||||||
|
const realSessionKey = extractSessionIdFromMission(mission)
|
||||||
|
const lines = readMissionLines(mission)
|
||||||
|
const missionLog = formatMissionLog(lines)
|
||||||
|
|
||||||
|
if (realSessionKey) {
|
||||||
|
setOrchestratorSessionKey(realSessionKey)
|
||||||
|
setMissionWorkerKeys((current) => {
|
||||||
|
if (current.has(realSessionKey)) return current
|
||||||
|
const next = new Set(current)
|
||||||
|
next.add(realSessionKey)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setPlanText((current) => (current && !current.startsWith('Conductor mission') ? current : 'Orchestrator session attached. Tracking worker activity...'))
|
||||||
|
lastActivityAtRef.current = Date.now()
|
||||||
|
setTimeoutWarning(false)
|
||||||
|
} else if (phase === 'decomposing' || phase === 'running') {
|
||||||
|
setPlanText((current) => current || `Conductor mission ${status ?? 'running'}. Waiting for Hermes to report the session...`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missionLog) {
|
||||||
|
setStreamText((current) => (current === missionLog ? current : missionLog))
|
||||||
|
lastActivityAtRef.current = Date.now()
|
||||||
|
setTimeoutWarning(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFailedMissionStatus(status)) {
|
||||||
|
doneRef.current = true
|
||||||
|
setStreamError(mission.error || 'Conductor mission failed')
|
||||||
|
setCompletedAt((value) => value ?? new Date().toISOString())
|
||||||
|
setPhase('complete')
|
||||||
|
}
|
||||||
|
}, [missionStatusQuery.data, phase])
|
||||||
|
|
||||||
const getMissionElapsedMs = (referenceTime = Date.now()) => {
|
const getMissionElapsedMs = (referenceTime = Date.now()) => {
|
||||||
if (!missionStartedAt) return 0
|
if (!missionStartedAt) return 0
|
||||||
const startedMs = new Date(missionStartedAt).getTime()
|
const startedMs = new Date(missionStartedAt).getTime()
|
||||||
if (!Number.isFinite(startedMs)) return 0
|
if (!Number.isFinite(startedMs)) return 0
|
||||||
const pauseStartedMs = pauseStartedAt ? new Date(pauseStartedAt).getTime() : NaN
|
const pauseStartedMs = pauseStartedAt ? new Date(pauseStartedAt).getTime() : NaN
|
||||||
const inFlightPausedMs =
|
const inFlightPausedMs = isPaused && Number.isFinite(pauseStartedMs) ? Math.max(0, referenceTime - pauseStartedMs) : 0
|
||||||
isPaused && Number.isFinite(pauseStartedMs) ? Math.max(0, referenceTime - pauseStartedMs) : 0
|
|
||||||
return Math.max(0, referenceTime - startedMs - accumulatedPausedMs - inFlightPausedMs)
|
return Math.max(0, referenceTime - startedMs - accumulatedPausedMs - inFlightPausedMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (missionWorkerLabels.size === 0 || workers.length === 0) return
|
if (missionWorkerLabels.size === 0 || workers.length === 0) return
|
||||||
const matchedKeys = workers
|
const matchedKeys = workers.filter((worker) => missionWorkerLabels.has(worker.label)).map((worker) => worker.key)
|
||||||
.filter((worker) => missionWorkerLabels.has(worker.label))
|
|
||||||
.map((worker) => worker.key)
|
|
||||||
|
|
||||||
if (matchedKeys.length === 0) return
|
if (matchedKeys.length === 0) return
|
||||||
|
|
||||||
@@ -806,9 +1106,7 @@ export function useConductorGateway() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'running' && phase !== 'decomposing') return
|
if (phase !== 'running' && phase !== 'decomposing') return
|
||||||
|
|
||||||
const workerSnapshot = workers
|
const workerSnapshot = workers.map((worker) => `${worker.key}:${worker.updatedAt ?? ''}:${worker.totalTokens}:${worker.status}`).join('|')
|
||||||
.map((worker) => `${worker.key}:${worker.updatedAt ?? ''}:${worker.totalTokens}:${worker.status}`)
|
|
||||||
.join('|')
|
|
||||||
|
|
||||||
if (workerSnapshot && workerSnapshot !== lastWorkerSnapshotRef.current) {
|
if (workerSnapshot && workerSnapshot !== lastWorkerSnapshotRef.current) {
|
||||||
lastWorkerSnapshotRef.current = workerSnapshot
|
lastWorkerSnapshotRef.current = workerSnapshot
|
||||||
@@ -886,9 +1184,12 @@ export function useConductorGateway() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(
|
||||||
void fetchAll()
|
() => {
|
||||||
}, hasRunningWorkers ? 5_000 : 2_000)
|
void fetchAll()
|
||||||
|
},
|
||||||
|
hasRunningWorkers ? 5_000 : 2_000,
|
||||||
|
)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
@@ -916,29 +1217,27 @@ export function useConductorGateway() {
|
|||||||
const worker = workers[index]
|
const worker = workers[index]
|
||||||
if (!worker) return task
|
if (!worker) return task
|
||||||
const workerOutput = workerOutputs[worker.key] ?? null
|
const workerOutput = workerOutputs[worker.key] ?? null
|
||||||
const newStatus: ConductorTask['status'] =
|
const newStatus: ConductorTask['status'] = worker.status === 'complete' ? 'complete' : worker.status === 'stale' ? 'failed' : worker.status === 'running' ? 'running' : task.status
|
||||||
worker.status === 'complete'
|
|
||||||
? 'complete'
|
|
||||||
: worker.status === 'stale'
|
|
||||||
? 'failed'
|
|
||||||
: worker.status === 'running'
|
|
||||||
? 'running'
|
|
||||||
: task.status
|
|
||||||
if (task.workerKey === worker.key && task.status === newStatus && task.output === workerOutput) return task
|
if (task.workerKey === worker.key && task.status === newStatus && task.output === workerOutput) return task
|
||||||
return { ...task, workerKey: worker.key, status: newStatus, output: workerOutput }
|
return {
|
||||||
|
...task,
|
||||||
|
workerKey: worker.key,
|
||||||
|
status: newStatus,
|
||||||
|
output: workerOutput,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const changed = updated.some((task, index) => task !== current[index])
|
const changed = updated.some((task, index) => task !== current[index])
|
||||||
return changed ? updated : current
|
return changed ? updated : current
|
||||||
})
|
})
|
||||||
}, [workers, workerOutputs, tasks.length])
|
}, [workers, workerOutputs, tasks.length])
|
||||||
|
|
||||||
// Save/update history entry on complete — re-runs when workerOutputs arrive
|
// Save/update history entry on complete — re-runs when workerOutputs arrive
|
||||||
// so the entry gets enriched with actual worker content instead of empty text.
|
// so the entry gets enriched with actual worker content instead of empty text.
|
||||||
const historySaveCountRef = useRef(0)
|
const historySaveCountRef = useRef(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'complete' || !goal || !completedAt || !missionStartedAt) return
|
if (phase !== 'complete' || !goal || !completedAt || !missionStartedAt) return
|
||||||
|
|
||||||
const missionId = `mission-${new Date(missionStartedAt).getTime()}`
|
const missionHistoryId = `mission-${new Date(missionStartedAt).getTime()}`
|
||||||
const outputPath = buildMissionOutputPath(workers, workerOutputs, tasks, streamText)
|
const outputPath = buildMissionOutputPath(workers, workerOutputs, tasks, streamText)
|
||||||
const workerSummary = summarizeWorkers(workers)
|
const workerSummary = summarizeWorkers(workers)
|
||||||
const outputText = buildMissionOutputText(workers, workerOutputs, streamText)
|
const outputText = buildMissionOutputText(workers, workerOutputs, streamText)
|
||||||
@@ -963,7 +1262,7 @@ export function useConductorGateway() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const entry: MissionHistoryEntry = {
|
const entry: MissionHistoryEntry = {
|
||||||
id: missionId,
|
id: missionHistoryId,
|
||||||
goal,
|
goal,
|
||||||
startedAt: missionStartedAt,
|
startedAt: missionStartedAt,
|
||||||
completedAt,
|
completedAt,
|
||||||
@@ -987,13 +1286,11 @@ export function useConductorGateway() {
|
|||||||
if (historySaveCountRef.current === 0) {
|
if (historySaveCountRef.current === 0) {
|
||||||
historySavedRef.current = true
|
historySavedRef.current = true
|
||||||
setMissionHistory((current) => {
|
setMissionHistory((current) => {
|
||||||
if (current.some((e) => e.id === missionId)) return current
|
if (current.some((e) => e.id === missionHistoryId)) return current
|
||||||
return [entry, ...current].slice(0, MAX_HISTORY_ENTRIES)
|
return [entry, ...current].slice(0, MAX_HISTORY_ENTRIES)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setMissionHistory((current) =>
|
setMissionHistory((current) => current.map((e) => (e.id === missionHistoryId ? entry : e)))
|
||||||
current.map((e) => (e.id === missionId ? entry : e)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
historySaveCountRef.current += 1
|
historySaveCountRef.current += 1
|
||||||
}, [phase, goal, completedAt, missionStartedAt, workers, streamError, workerOutputs, tasks, streamText])
|
}, [phase, goal, completedAt, missionStartedAt, workers, streamError, workerOutputs, tasks, streamText])
|
||||||
@@ -1011,6 +1308,8 @@ export function useConductorGateway() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
persistMission({
|
persistMission({
|
||||||
|
missionId,
|
||||||
|
missionJobId,
|
||||||
goal,
|
goal,
|
||||||
phase,
|
phase,
|
||||||
missionStartedAt,
|
missionStartedAt,
|
||||||
@@ -1026,7 +1325,24 @@ export function useConductorGateway() {
|
|||||||
completedAt,
|
completedAt,
|
||||||
tasks,
|
tasks,
|
||||||
})
|
})
|
||||||
}, [phase, goal, missionStartedAt, isPaused, pausedElapsedMs, accumulatedPausedMs, pauseStartedAt, completedAt, missionWorkerKeys, missionWorkerLabels, workerOutputs, streamText, planText, tasks])
|
}, [
|
||||||
|
missionId,
|
||||||
|
missionJobId,
|
||||||
|
phase,
|
||||||
|
goal,
|
||||||
|
missionStartedAt,
|
||||||
|
isPaused,
|
||||||
|
pausedElapsedMs,
|
||||||
|
accumulatedPausedMs,
|
||||||
|
pauseStartedAt,
|
||||||
|
completedAt,
|
||||||
|
missionWorkerKeys,
|
||||||
|
missionWorkerLabels,
|
||||||
|
workerOutputs,
|
||||||
|
streamText,
|
||||||
|
planText,
|
||||||
|
tasks,
|
||||||
|
])
|
||||||
|
|
||||||
const dismissTimeoutWarning = () => {
|
const dismissTimeoutWarning = () => {
|
||||||
lastActivityAtRef.current = Date.now()
|
lastActivityAtRef.current = Date.now()
|
||||||
@@ -1035,7 +1351,11 @@ export function useConductorGateway() {
|
|||||||
|
|
||||||
const clearMissionState = () => {
|
const clearMissionState = () => {
|
||||||
doneRef.current = false
|
doneRef.current = false
|
||||||
|
portableStreamAbortRef.current?.abort()
|
||||||
|
portableStreamAbortRef.current = null
|
||||||
clearPersistedMission()
|
clearPersistedMission()
|
||||||
|
setMissionId(null)
|
||||||
|
setMissionJobId(null)
|
||||||
setPhase('idle')
|
setPhase('idle')
|
||||||
setGoal('')
|
setGoal('')
|
||||||
setOrchestratorSessionKey(null)
|
setOrchestratorSessionKey(null)
|
||||||
@@ -1070,6 +1390,8 @@ export function useConductorGateway() {
|
|||||||
lastWorkerSnapshotRef.current = ''
|
lastWorkerSnapshotRef.current = ''
|
||||||
setTimeoutWarning(false)
|
setTimeoutWarning(false)
|
||||||
setGoal(trimmed)
|
setGoal(trimmed)
|
||||||
|
setMissionId(null)
|
||||||
|
setMissionJobId(null)
|
||||||
setOrchestratorSessionKey(null)
|
setOrchestratorSessionKey(null)
|
||||||
setStreamText('')
|
setStreamText('')
|
||||||
setPlanText('')
|
setPlanText('')
|
||||||
@@ -1087,10 +1409,29 @@ export function useConductorGateway() {
|
|||||||
setSelectedHistoryEntry(null)
|
setSelectedHistoryEntry(null)
|
||||||
seenToolCallRef.current = false
|
seenToolCallRef.current = false
|
||||||
historySavedRef.current = false
|
historySavedRef.current = false
|
||||||
setMissionStartedAt(new Date().toISOString())
|
const startedAt = new Date().toISOString()
|
||||||
|
setMissionStartedAt(startedAt)
|
||||||
setPhase('decomposing')
|
setPhase('decomposing')
|
||||||
|
persistMission({
|
||||||
|
missionId: null,
|
||||||
|
missionJobId: null,
|
||||||
|
goal: trimmed,
|
||||||
|
phase: 'decomposing',
|
||||||
|
missionStartedAt: startedAt,
|
||||||
|
isPaused: false,
|
||||||
|
pausedElapsedMs: 0,
|
||||||
|
accumulatedPausedMs: 0,
|
||||||
|
pauseStartedAt: null,
|
||||||
|
workerKeys: [],
|
||||||
|
workerLabels: [],
|
||||||
|
workerOutputs: {},
|
||||||
|
streamText: '',
|
||||||
|
planText: '',
|
||||||
|
completedAt: null,
|
||||||
|
tasks: [],
|
||||||
|
})
|
||||||
|
|
||||||
// Spawn a dedicated orchestrator session via the server
|
// Spawn a Conductor mission via the server.
|
||||||
const response = await fetch('/api/conductor-spawn', {
|
const response = await fetch('/api/conductor-spawn', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -1102,41 +1443,109 @@ export function useConductorGateway() {
|
|||||||
throw new Error(text || `Spawn failed (${response.status})`)
|
throw new Error(text || `Spawn failed (${response.status})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = (await response.json()) as {
|
const result = (await response.json()) as ConductorSpawnResponse
|
||||||
ok?: boolean
|
if (!result.ok) {
|
||||||
sessionKey?: string
|
|
||||||
sessionKeyPrefix?: string
|
|
||||||
jobId?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
if (!result.ok || !result.sessionKey) {
|
|
||||||
throw new Error(result.error ?? 'Failed to spawn orchestrator')
|
throw new Error(result.error ?? 'Failed to spawn orchestrator')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude runs cron jobs in sessions keyed `cron_<jobId>_<timestamp>`.
|
if (result.mode === 'portable' || result.prompt) {
|
||||||
// The session doesn't exist yet at spawn time — the cron loop creates
|
const prompt = typeof result.prompt === 'string' ? result.prompt : ''
|
||||||
// it within ~5s. Poll /api/sessions until we find one matching the
|
if (!prompt.trim()) throw new Error('Portable conductor response did not include a prompt')
|
||||||
// prefix, then track it as the orchestrator session.
|
|
||||||
const orchestratorKey = result.sessionKey
|
|
||||||
const prefix = result.sessionKeyPrefix
|
|
||||||
setOrchestratorSessionKey(orchestratorKey)
|
|
||||||
setMissionWorkerKeys((current) => {
|
|
||||||
if (current.has(orchestratorKey)) return current
|
|
||||||
const next = new Set(current)
|
|
||||||
next.add(orchestratorKey)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
if (prefix) {
|
const portableSessionKey = result.sessionKey?.trim() || result.jobName?.trim() || `conductor-${Date.now()}`
|
||||||
|
const portableFriendlyId = result.jobName?.trim() || portableSessionKey
|
||||||
|
setMissionId(null)
|
||||||
|
setMissionJobId(null)
|
||||||
|
setOrchestratorSessionKey(portableSessionKey)
|
||||||
|
setMissionWorkerKeys((current) => {
|
||||||
|
if (current.has(portableSessionKey)) return current
|
||||||
|
const next = new Set(current)
|
||||||
|
next.add(portableSessionKey)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setPlanText('Conductor portable mission launched. Streaming orchestrator output...')
|
||||||
|
setPhase('running')
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
portableStreamAbortRef.current = abortController
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streamResult = await streamPortableConductorMission({
|
||||||
|
sessionKey: portableSessionKey,
|
||||||
|
friendlyId: portableFriendlyId,
|
||||||
|
prompt,
|
||||||
|
model: settings.orchestratorModel || undefined,
|
||||||
|
signal: abortController.signal,
|
||||||
|
onSessionResolved: (resolvedSessionKey) => {
|
||||||
|
setOrchestratorSessionKey(resolvedSessionKey)
|
||||||
|
setMissionWorkerKeys((current) => {
|
||||||
|
if (current.has(resolvedSessionKey)) return current
|
||||||
|
const next = new Set(current)
|
||||||
|
next.add(resolvedSessionKey)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
lastActivityAtRef.current = Date.now()
|
||||||
|
setTimeoutWarning(false)
|
||||||
|
},
|
||||||
|
onText: (text) => {
|
||||||
|
setStreamText(text)
|
||||||
|
setPlanText(text)
|
||||||
|
lastActivityAtRef.current = Date.now()
|
||||||
|
setTimeoutWarning(false)
|
||||||
|
},
|
||||||
|
onStreamEvent: (event) => {
|
||||||
|
appendStreamEvent(setStreamEvents, event)
|
||||||
|
lastActivityAtRef.current = Date.now()
|
||||||
|
setTimeoutWarning(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (streamResult.text.trim()) {
|
||||||
|
setStreamText(streamResult.text)
|
||||||
|
setPlanText(streamResult.text)
|
||||||
|
}
|
||||||
|
doneRef.current = true
|
||||||
|
setCompletedAt((value) => value ?? new Date().toISOString())
|
||||||
|
setPhase('complete')
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') return
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
if (portableStreamAbortRef.current === abortController) {
|
||||||
|
portableStreamAbortRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.sessionKey && !result.sessionKeyPrefix && !result.missionId && !result.jobId) {
|
||||||
|
throw new Error(result.error ?? 'Failed to spawn orchestrator')
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMissionId = result.missionId ?? null
|
||||||
|
setMissionId(nextMissionId)
|
||||||
|
setMissionJobId(result.jobId ?? null)
|
||||||
|
|
||||||
|
const orchestratorKey = result.sessionKey ?? null
|
||||||
|
const prefix = result.sessionKeyPrefix
|
||||||
|
if (orchestratorKey) {
|
||||||
|
setOrchestratorSessionKey(orchestratorKey)
|
||||||
|
setMissionWorkerKeys((current) => {
|
||||||
|
if (current.has(orchestratorKey)) return current
|
||||||
|
const next = new Set(current)
|
||||||
|
next.add(orchestratorKey)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefix && orchestratorKey) {
|
||||||
// Async: resolve the placeholder to the real session key once it exists.
|
// Async: resolve the placeholder to the real session key once it exists.
|
||||||
const resolveOrchestrator = async () => {
|
const resolveOrchestrator = async () => {
|
||||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||||
try {
|
try {
|
||||||
const sessionPayload = await fetchSessions()
|
const sessionPayload = await fetchSessions()
|
||||||
const sessions = Array.isArray(sessionPayload.sessions)
|
const sessions = Array.isArray(sessionPayload.sessions) ? sessionPayload.sessions : []
|
||||||
? sessionPayload.sessions
|
|
||||||
: []
|
|
||||||
const match = sessions.find((session) => {
|
const match = sessions.find((session) => {
|
||||||
const key = typeof session.key === 'string' ? session.key : ''
|
const key = typeof session.key === 'string' ? session.key : ''
|
||||||
return key.startsWith(prefix)
|
return key.startsWith(prefix)
|
||||||
@@ -1160,7 +1569,11 @@ export function useConductorGateway() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transition to running — the orchestrator is alive, workers will appear via polling
|
// Transition to running — the orchestrator is alive, workers will appear via polling
|
||||||
setPlanText(`Orchestrator spawned. Decomposing mission and spawning workers...`)
|
setPlanText(
|
||||||
|
nextMissionId
|
||||||
|
? 'Conductor mission launched. Waiting for Hermes session and worker activity...'
|
||||||
|
: 'Orchestrator spawned. Decomposing mission and spawning workers...',
|
||||||
|
)
|
||||||
setPhase('running')
|
setPhase('running')
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -1203,8 +1616,7 @@ export function useConductorGateway() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pauseStartedMs = pauseStartedAt ? new Date(pauseStartedAt).getTime() : NaN
|
const pauseStartedMs = pauseStartedAt ? new Date(pauseStartedAt).getTime() : NaN
|
||||||
const additionalPausedMs =
|
const additionalPausedMs = Number.isFinite(pauseStartedMs) ? Math.max(0, now - pauseStartedMs) : 0
|
||||||
Number.isFinite(pauseStartedMs) ? Math.max(0, now - pauseStartedMs) : 0
|
|
||||||
setAccumulatedPausedMs((current) => current + additionalPausedMs)
|
setAccumulatedPausedMs((current) => current + additionalPausedMs)
|
||||||
setPauseStartedAt(null)
|
setPauseStartedAt(null)
|
||||||
setIsPaused(false)
|
setIsPaused(false)
|
||||||
@@ -1213,13 +1625,16 @@ export function useConductorGateway() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const stopMission = async () => {
|
const stopMission = async () => {
|
||||||
|
portableStreamAbortRef.current?.abort()
|
||||||
|
portableStreamAbortRef.current = null
|
||||||
const sessionKeys = [...new Set([...missionWorkerKeys, ...workers.map((worker) => worker.key)])]
|
const sessionKeys = [...new Set([...missionWorkerKeys, ...workers.map((worker) => worker.key)])]
|
||||||
|
const missionIds = missionId ? [missionId] : []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch('/api/conductor-stop', {
|
await fetch('/api/conductor-stop', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionKeys }),
|
body: JSON.stringify({ sessionKeys, missionIds }),
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// Best effort cleanup.
|
// Best effort cleanup.
|
||||||
@@ -1238,7 +1653,10 @@ export function useConductorGateway() {
|
|||||||
const currentGoal = goal
|
const currentGoal = goal
|
||||||
resetMission()
|
resetMission()
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
await sendMission.mutateAsync({ nextGoal: currentGoal, settings: conductorSettings })
|
await sendMission.mutateAsync({
|
||||||
|
nextGoal: currentGoal,
|
||||||
|
settings: conductorSettings,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user