From 0e01f5d4b315218f5826c50f29f73905f0d882c5 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Wed, 4 Mar 2026 21:59:20 +0700 Subject: [PATCH 1/3] fix: add OpenClaw 3.2 compatibility for spawn and gateway health --- .env.example | 5 ++ README.md | 2 + src/app/api/gateways/health/route.ts | 30 +++++++++++ src/app/api/spawn/route.ts | 50 ++++++++++++++++--- src/components/panels/multi-gateway-panel.tsx | 35 ++++++++++++- src/lib/websocket.ts | 3 +- 6 files changed, 115 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index f58878d..9da5f96 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,9 @@ OPENCLAW_GATEWAY_HOST=127.0.0.1 OPENCLAW_GATEWAY_PORT=18789 # Optional: token used by server-side gateway calls OPENCLAW_GATEWAY_TOKEN= +# Tools profile used when Mission Control spawns sessions via sessions_spawn. +# OpenClaw 2026.3.2+ defaults to "messaging" if omitted. +OPENCLAW_TOOLS_PROFILE=coding # Frontend env vars (NEXT_PUBLIC_ prefix = available in browser) NEXT_PUBLIC_GATEWAY_HOST= @@ -61,6 +64,8 @@ NEXT_PUBLIC_GATEWAY_PORT=18789 NEXT_PUBLIC_GATEWAY_PROTOCOL= NEXT_PUBLIC_GATEWAY_URL= # NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token +# Gateway client id used in websocket handshake (role=operator UI client). +NEXT_PUBLIC_GATEWAY_CLIENT_ID=control-ui # === Data Paths (all optional, defaults to .data/ in project root) === # MISSION_CONTROL_DATA_DIR=.data diff --git a/README.md b/README.md index ca9cec1..5b3cdeb 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,9 @@ See [`.env.example`](.env.example) for the complete list. Key variables: | `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) | | `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) | | `OPENCLAW_GATEWAY_TOKEN` | No | Server-side gateway auth token | +| `OPENCLAW_TOOLS_PROFILE` | No | Tools profile for `sessions_spawn` (recommended: `coding`) | | `NEXT_PUBLIC_GATEWAY_TOKEN` | No | Browser-side gateway auth token (must use `NEXT_PUBLIC_` prefix) | +| `NEXT_PUBLIC_GATEWAY_CLIENT_ID` | No | Gateway UI client ID for websocket handshake (default: `control-ui`) | | `OPENCLAW_MEMORY_DIR` | No | Memory browser root (see note below) | | `MC_CLAUDE_HOME` | No | Path to `~/.claude` directory (default: `~/.claude`) | | `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing | diff --git a/src/app/api/gateways/health/route.ts b/src/app/api/gateways/health/route.ts index 6297183..1025b36 100644 --- a/src/app/api/gateways/health/route.ts +++ b/src/app/api/gateways/health/route.ts @@ -19,9 +19,33 @@ interface HealthResult { latency: number | null agents: string[] sessions_count: number + gateway_version?: string | null + compatibility_warning?: string error?: string } +function parseGatewayVersion(res: Response): string | null { + const direct = res.headers.get('x-openclaw-version') || res.headers.get('x-clawdbot-version') + if (direct) return direct.trim() + const server = res.headers.get('server') || '' + const m = server.match(/(\d{4}\.\d+\.\d+)/) + return m?.[1] || null +} + +function hasOpenClaw32ToolsProfileRisk(version: string | null): boolean { + if (!version) return false + const m = version.match(/^(\d{4})\.(\d+)\.(\d+)/) + if (!m) return false + const year = Number(m[1]) + const major = Number(m[2]) + const minor = Number(m[3]) + if (year > 2026) return true + if (year < 2026) return false + if (major > 3) return true + if (major < 3) return false + return minor >= 2 +} + function isBlockedUrl(urlStr: string): boolean { try { const url = new URL(urlStr) @@ -77,6 +101,10 @@ export async function POST(request: NextRequest) { const latency = Date.now() - start const status = res.ok ? "online" : "error" + const gatewayVersion = parseGatewayVersion(res) + const compatibilityWarning = hasOpenClaw32ToolsProfileRisk(gatewayVersion) + ? 'OpenClaw 2026.3.2+ defaults tools.profile=messaging; Mission Control should enforce coding profile when spawning.' + : undefined updateOnlineStmt.run(status, latency, gw.id) @@ -87,6 +115,8 @@ export async function POST(request: NextRequest) { latency, agents: [], sessions_count: 0, + gateway_version: gatewayVersion, + compatibility_warning: compatibilityWarning, }) } catch (err: any) { updateOfflineStmt.run("offline", gw.id) diff --git a/src/app/api/spawn/route.ts b/src/app/api/spawn/route.ts index 1119d12..cfe2951 100644 --- a/src/app/api/spawn/route.ts +++ b/src/app/api/spawn/route.ts @@ -8,6 +8,15 @@ import { heavyLimiter } from '@/lib/rate-limit' import { logger } from '@/lib/logger' import { validateBody, spawnAgentSchema } from '@/lib/validation' +function getPreferredToolsProfile(): string { + return String(process.env.OPENCLAW_TOOLS_PROFILE || 'coding').trim() || 'coding' +} + +async function runSpawnWithCompatibility(spawnPayload: Record) { + const commandArg = `sessions_spawn(${JSON.stringify(spawnPayload)})` + return runClawdbot(['-c', commandArg], { timeoutMs: 10000 }) +} + export async function POST(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) @@ -31,15 +40,38 @@ export async function POST(request: NextRequest) { task, model, label, - runTimeoutSeconds: timeout + runTimeoutSeconds: timeout, + tools: { + profile: getPreferredToolsProfile(), + }, } - const commandArg = `sessions_spawn(${JSON.stringify(spawnPayload)})` try { - // Execute the spawn command - const { stdout, stderr } = await runClawdbot(['-c', commandArg], { - timeoutMs: 10000 - }) + // Execute the spawn command (OpenClaw 2026.3.2+ defaults tools.profile to messaging). + let stdout = '' + let stderr = '' + let compatibilityFallbackUsed = false + try { + const result = await runSpawnWithCompatibility(spawnPayload) + stdout = result.stdout + stderr = result.stderr + } catch (firstError: any) { + const rawErr = String(firstError?.stderr || firstError?.message || '').toLowerCase() + const likelySchemaMismatch = + rawErr.includes('unknown field') || + rawErr.includes('unknown key') || + rawErr.includes('invalid argument') || + rawErr.includes('tools') || + rawErr.includes('profile') + if (!likelySchemaMismatch) throw firstError + + const fallbackPayload = { ...spawnPayload } + delete (fallbackPayload as any).tools + const fallback = await runSpawnWithCompatibility(fallbackPayload) + stdout = fallback.stdout + stderr = fallback.stderr + compatibilityFallbackUsed = true + } // Parse the response to extract session info let sessionInfo = null @@ -63,7 +95,11 @@ export async function POST(request: NextRequest) { timeoutSeconds: timeout, createdAt: Date.now(), stdout: stdout.trim(), - stderr: stderr.trim() + stderr: stderr.trim(), + compatibility: { + toolsProfile: getPreferredToolsProfile(), + fallbackUsed: compatibilityFallbackUsed, + }, }) } catch (execError: any) { diff --git a/src/components/panels/multi-gateway-panel.tsx b/src/components/panels/multi-gateway-panel.tsx index 9218663..b3a9bfa 100644 --- a/src/components/panels/multi-gateway-panel.tsx +++ b/src/components/panels/multi-gateway-panel.tsx @@ -35,12 +35,23 @@ interface DirectConnection { agent_role: string } +interface GatewayHealthProbe { + id: number + name: string + status: 'online' | 'offline' | 'error' + latency: number | null + gateway_version?: string | null + compatibility_warning?: string + error?: string +} + export function MultiGatewayPanel() { const [gateways, setGateways] = useState([]) const [directConnections, setDirectConnections] = useState([]) const [loading, setLoading] = useState(true) const [showAdd, setShowAdd] = useState(false) const [probing, setProbing] = useState(null) + const [healthByGatewayId, setHealthByGatewayId] = useState>(new Map()) const { connection } = useMissionControl() const { connect } = useWebSocket() @@ -89,7 +100,14 @@ export function MultiGatewayPanel() { const probeAll = async () => { try { - await fetch("/api/gateways/health", { method: "POST" }) + const res = await fetch("/api/gateways/health", { method: "POST" }) + const data = await res.json().catch(() => ({})) + const rows = Array.isArray(data?.results) ? data.results as GatewayHealthProbe[] : [] + const mapped = new Map() + for (const row of rows) { + if (typeof row?.id === 'number') mapped.set(row.id, row) + } + setHealthByGatewayId(mapped) } catch { /* ignore */ } fetchGateways() } @@ -172,6 +190,7 @@ export function MultiGatewayPanel() { setPrimary(gw)} @@ -250,8 +269,9 @@ export function MultiGatewayPanel() { ) } -function GatewayCard({ gateway, isProbing, isCurrentlyConnected, onSetPrimary, onDelete, onConnect, onProbe }: { +function GatewayCard({ gateway, health, isProbing, isCurrentlyConnected, onSetPrimary, onDelete, onConnect, onProbe }: { gateway: Gateway + health?: GatewayHealthProbe isProbing: boolean isCurrentlyConnected: boolean onSetPrimary: () => void @@ -269,6 +289,7 @@ function GatewayCard({ gateway, isProbing, isCurrentlyConnected, onSetPrimary, o const lastSeen = gateway.last_seen ? new Date(gateway.last_seen * 1000).toLocaleString() : 'Never probed' + const compatibilityWarning = health?.compatibility_warning return (
Latency: {gateway.latency}ms} Last: {lastSeen}
+ {health?.gateway_version && ( +
+ Gateway version: {health.gateway_version} +
+ )} + {compatibilityWarning && ( +
+ {compatibilityWarning} +
+ )}
+ )) - )} + )} )} @@ -493,13 +529,17 @@ export function CronManagementPanel() {
No scheduled jobs for this day.
) : (
- {dayJobs.map((job) => ( -
-
{job.name}
+ {dayJobs.map((row) => ( +
+ ))}
)} @@ -509,21 +549,25 @@ export function CronManagementPanel() { {calendarView === 'week' && (
{jobsByWeekDay.map(({ date, jobs }) => ( -
+ ))}
)} @@ -535,15 +579,16 @@ export function CronManagementPanel() { return (
setSelectedCalendarDate(startOfDay(date))} + className={`border border-border rounded-lg p-2 min-h-24 cursor-pointer ${inCurrentMonth ? 'bg-transparent' : 'bg-secondary/30'} ${isSameDay(date, selectedCalendarDate) ? 'border-primary/40 bg-primary/10' : 'hover:bg-secondary/50'}`} >
{date.getDate()}
- {jobs.slice(0, 2).map((job) => ( -
- {job.name} + {jobs.slice(0, 2).map((row) => ( +
+ {row.job.name}
))} {jobs.length > 2 &&
+{jobs.length - 2}
} @@ -553,6 +598,35 @@ export function CronManagementPanel() { })}
)} + + {calendarView !== 'agenda' && ( +
+
+

+ {selectedCalendarDate.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' })} +

+ {selectedDayJobs.length} jobs +
+ {selectedDayJobs.length === 0 ? ( +
No jobs scheduled on this date.
+ ) : ( +
+ {selectedDayJobs.map((row) => ( + + ))} +
+ )} +
+ )}
diff --git a/src/components/panels/memory-browser-panel.tsx b/src/components/panels/memory-browser-panel.tsx index 7eeba6e..453526e 100644 --- a/src/components/panels/memory-browser-panel.tsx +++ b/src/components/panels/memory-browser-panel.tsx @@ -353,6 +353,9 @@ export function MemoryBrowserPanel() { ? 'Browse and manage local knowledge files and memory' : 'Explore knowledge files and memory structure'}

+

+ This page shows all workspace memory files. The agent profile Memory tab only edits that single agent's working memory. +

{/* Tab Navigation */}
diff --git a/src/components/panels/super-admin-panel.tsx b/src/components/panels/super-admin-panel.tsx index 0851a71..81ae747 100644 --- a/src/components/panels/super-admin-panel.tsx +++ b/src/components/panels/super-admin-panel.tsx @@ -461,6 +461,9 @@ export function SuperAdminPanel() { {createExpanded && (
+
+ Add a new workspace/client instance here. Fill the form below and click Create + Queue. +
{gatewayLoadError && (
Gateway list unavailable: {gatewayLoadError}. Using fallback owner value. diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts index c25b97f..0a67b63 100644 --- a/src/lib/__tests__/auth.test.ts +++ b/src/lib/__tests__/auth.test.ts @@ -105,4 +105,23 @@ describe('requireRole', () => { ) expect(result.user).toBeDefined() }) + + it('accepts Authorization Bearer API key', () => { + const result = requireRole( + makeRequest({ authorization: 'Bearer test-api-key-secret' }), + 'admin', + ) + expect(result.user).toBeDefined() + expect(result.user!.username).toBe('api') + }) + + it('rejects API key auth when API_KEY is not configured', () => { + process.env = { ...originalEnv, API_KEY: '' } + const result = requireRole( + makeRequest({ 'x-api-key': 'test-api-key-secret' }), + 'viewer', + ) + expect(result.status).toBe(401) + expect(result.user).toBeUndefined() + }) }) diff --git a/src/lib/__tests__/cron-occurrences.test.ts b/src/lib/__tests__/cron-occurrences.test.ts new file mode 100644 index 0000000..08cf2cd --- /dev/null +++ b/src/lib/__tests__/cron-occurrences.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences' + +describe('buildDayKey', () => { + it('formats YYYY-MM-DD in local time', () => { + const date = new Date(2026, 2, 4, 9, 15, 0, 0) + expect(buildDayKey(date)).toBe('2026-03-04') + }) +}) + +describe('getCronOccurrences', () => { + it('expands daily schedule across range', () => { + const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime() + const end = new Date(2026, 2, 5, 0, 0, 0, 0).getTime() + const rows = getCronOccurrences('0 0 * * *', start, end) + expect(rows).toHaveLength(4) + expect(rows.map((r) => r.dayKey)).toEqual([ + '2026-03-01', + '2026-03-02', + '2026-03-03', + '2026-03-04', + ]) + }) + + it('supports step values', () => { + const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime() + const end = new Date(2026, 2, 1, 3, 0, 0, 0).getTime() + const rows = getCronOccurrences('*/30 * * * *', start, end) + expect(rows).toHaveLength(6) + }) + + it('ignores OpenClaw timezone suffix in display schedule', () => { + const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime() + const end = new Date(2026, 2, 2, 0, 0, 0, 0).getTime() + const rows = getCronOccurrences('0 6 * * * (UTC)', start, end) + expect(rows).toHaveLength(1) + }) + + it('returns empty list for invalid cron', () => { + const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime() + const end = new Date(2026, 2, 2, 0, 0, 0, 0).getTime() + expect(getCronOccurrences('invalid', start, end)).toEqual([]) + }) +}) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 04f853e..e1517e7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -277,8 +277,9 @@ export function getUserFromRequest(request: Request): User | null { } // Check API key - return synthetic user - const apiKey = request.headers.get('x-api-key') - if (apiKey && safeCompare(apiKey, process.env.API_KEY || '')) { + const configuredApiKey = (process.env.API_KEY || '').trim() + const apiKey = extractApiKeyFromHeaders(request.headers) + if (configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) { return { id: 0, username: 'api', @@ -294,6 +295,24 @@ export function getUserFromRequest(request: Request): User | null { return null } +function extractApiKeyFromHeaders(headers: Headers): string | null { + const direct = (headers.get('x-api-key') || '').trim() + if (direct) return direct + + const authorization = (headers.get('authorization') || '').trim() + if (!authorization) return null + + const [scheme, ...rest] = authorization.split(/\s+/) + if (!scheme || rest.length === 0) return null + + const normalized = scheme.toLowerCase() + if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') { + return rest.join(' ').trim() || null + } + + return null +} + /** * Role hierarchy levels for access control. * viewer < operator < admin diff --git a/src/lib/cron-occurrences.ts b/src/lib/cron-occurrences.ts new file mode 100644 index 0000000..88472fc --- /dev/null +++ b/src/lib/cron-occurrences.ts @@ -0,0 +1,139 @@ +export interface CronOccurrence { + atMs: number + dayKey: string +} + +interface ParsedField { + any: boolean + matches: (value: number) => boolean +} + +interface ParsedCron { + minute: ParsedField + hour: ParsedField + dayOfMonth: ParsedField + month: ParsedField + dayOfWeek: ParsedField +} + +function normalizeCronExpression(raw: string): string { + const trimmed = raw.trim() + const tzSuffixMatch = trimmed.match(/^(.*)\s+\([^)]+\)$/) + return (tzSuffixMatch?.[1] || trimmed).trim() +} + +function parseToken(token: string, min: number, max: number): { any: boolean; values: Set } { + const valueSet = new Set() + const trimmed = token.trim() + if (trimmed === '*') { + for (let i = min; i <= max; i += 1) valueSet.add(i) + return { any: true, values: valueSet } + } + + for (const part of trimmed.split(',')) { + const section = part.trim() + if (!section) continue + + const [rangePart, stepPart] = section.split('/') + const step = stepPart ? Number(stepPart) : 1 + if (!Number.isFinite(step) || step <= 0) continue + + if (rangePart === '*') { + for (let i = min; i <= max; i += step) valueSet.add(i) + continue + } + + if (rangePart.includes('-')) { + const [fromRaw, toRaw] = rangePart.split('-') + const from = Number(fromRaw) + const to = Number(toRaw) + if (!Number.isFinite(from) || !Number.isFinite(to)) continue + const start = Math.max(min, Math.min(max, from)) + const end = Math.max(min, Math.min(max, to)) + for (let i = start; i <= end; i += step) valueSet.add(i) + continue + } + + const single = Number(rangePart) + if (!Number.isFinite(single)) continue + if (single >= min && single <= max) valueSet.add(single) + } + + return { any: false, values: valueSet } +} + +function parseField(token: string, min: number, max: number): ParsedField { + const parsed = parseToken(token, min, max) + return { + any: parsed.any, + matches: (value: number) => parsed.values.has(value), + } +} + +function parseCron(raw: string): ParsedCron | null { + const normalized = normalizeCronExpression(raw) + const parts = normalized.split(/\s+/).filter(Boolean) + if (parts.length !== 5) return null + + return { + minute: parseField(parts[0], 0, 59), + hour: parseField(parts[1], 0, 23), + dayOfMonth: parseField(parts[2], 1, 31), + month: parseField(parts[3], 1, 12), + dayOfWeek: parseField(parts[4], 0, 6), + } +} + +function matchesDay(parsed: ParsedCron, date: Date): boolean { + const dayOfMonthMatches = parsed.dayOfMonth.matches(date.getDate()) + const dayOfWeekMatches = parsed.dayOfWeek.matches(date.getDay()) + + if (parsed.dayOfMonth.any && parsed.dayOfWeek.any) return true + if (parsed.dayOfMonth.any) return dayOfWeekMatches + if (parsed.dayOfWeek.any) return dayOfMonthMatches + return dayOfMonthMatches || dayOfWeekMatches +} + +export function buildDayKey(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function getCronOccurrences( + schedule: string, + rangeStartMs: number, + rangeEndMs: number, + max = 1000 +): CronOccurrence[] { + if (!schedule || !Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs)) return [] + if (rangeEndMs <= rangeStartMs || max <= 0) return [] + + const parsed = parseCron(schedule) + if (!parsed) return [] + + const occurrences: CronOccurrence[] = [] + const cursor = new Date(rangeStartMs) + cursor.setSeconds(0, 0) + if (cursor.getTime() < rangeStartMs) { + cursor.setMinutes(cursor.getMinutes() + 1, 0, 0) + } + + while (cursor.getTime() < rangeEndMs && occurrences.length < max) { + if ( + parsed.month.matches(cursor.getMonth() + 1) && + matchesDay(parsed, cursor) && + parsed.hour.matches(cursor.getHours()) && + parsed.minute.matches(cursor.getMinutes()) + ) { + occurrences.push({ + atMs: cursor.getTime(), + dayKey: buildDayKey(cursor), + }) + } + cursor.setMinutes(cursor.getMinutes() + 1, 0, 0) + } + + return occurrences +} diff --git a/src/proxy.ts b/src/proxy.ts index 44d14d9..dd051f3 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -52,6 +52,22 @@ function applySecurityHeaders(response: NextResponse): NextResponse { return response } +function extractApiKeyFromRequest(request: NextRequest): string { + const direct = (request.headers.get('x-api-key') || '').trim() + if (direct) return direct + + const authorization = (request.headers.get('authorization') || '').trim() + if (!authorization) return '' + + const [scheme, ...rest] = authorization.split(/\s+/) + if (!scheme || rest.length === 0) return '' + const normalized = scheme.toLowerCase() + if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') { + return rest.join(' ').trim() + } + return '' +} + export function proxy(request: NextRequest) { // Network access control. // In production: default-deny unless explicitly allowed. @@ -63,7 +79,8 @@ export function proxy(request: NextRequest) { .map((s) => s.trim()) .filter(Boolean) - const isAllowedHost = allowAnyHost || allowedPatterns.some((p) => hostMatches(p, hostName)) + const enforceAllowlist = !allowAnyHost && allowedPatterns.length > 0 + const isAllowedHost = !enforceAllowlist || allowedPatterns.some((p) => hostMatches(p, hostName)) if (!isAllowedHost) { return new NextResponse('Forbidden', { status: 403 }) @@ -97,8 +114,10 @@ export function proxy(request: NextRequest) { // API routes: accept session cookie OR API key if (pathname.startsWith('/api/')) { - const apiKey = request.headers.get('x-api-key') - if (sessionToken || (apiKey && safeCompare(apiKey, process.env.API_KEY || ''))) { + const configuredApiKey = (process.env.API_KEY || '').trim() + const apiKey = extractApiKeyFromRequest(request) + const hasValidApiKey = Boolean(configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) + if (sessionToken || hasValidApiKey) { return applySecurityHeaders(NextResponse.next()) } From e8229cd290e595ad7633dc8c9e837bf5011927f9 Mon Sep 17 00:00:00 2001 From: nyk <93952610+0xNyk@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:26:06 +0700 Subject: [PATCH 3/3] feat: add URL-based task deep links and agent task links --- src/components/panels/agent-detail-tabs.tsx | 6 ++- src/components/panels/task-board-panel.tsx | 60 +++++++++++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index 1714fc9..ea42600 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect } from 'react' +import Link from 'next/link' interface Agent { id: number @@ -665,7 +666,10 @@ export function TasksTab({ agent }: { agent: Agent }) {
-
{task.title}
+ + {task.title} + +
Task #{task.id}
{task.description && (

{task.description}

)} diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index 355b6ce..1dda867 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useRef } from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useMissionControl } from '@/store' import { useSmartPoll } from '@/lib/use-smart-poll' import { useFocusTrap } from '@/lib/use-focus-trap' @@ -67,6 +68,9 @@ const priorityColors: Record = { export function TaskBoardPanel() { const { tasks: storeTasks, setTasks: storeSetTasks, selectedTask, setSelectedTask } = useMissionControl() + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() const [agents, setAgents] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -75,6 +79,23 @@ export function TaskBoardPanel() { const [showCreateModal, setShowCreateModal] = useState(false) const [editingTask, setEditingTask] = useState(null) const dragCounter = useRef(0) + const selectedTaskIdFromUrl = Number.parseInt(searchParams.get('taskId') || '', 10) + + const updateTaskUrl = useCallback((taskId: number | null, mode: 'push' | 'replace' = 'push') => { + const params = new URLSearchParams(searchParams.toString()) + if (typeof taskId === 'number' && Number.isFinite(taskId)) { + params.set('taskId', String(taskId)) + } else { + params.delete('taskId') + } + const query = params.toString() + const href = query ? `${pathname}?${query}` : pathname + if (mode === 'replace') { + router.replace(href) + return + } + router.push(href) + }, [pathname, router, searchParams]) // Augment store tasks with aegisApproved flag (computed, not stored) const tasks: Task[] = storeTasks.map(t => ({ @@ -136,6 +157,26 @@ export function TaskBoardPanel() { fetchData() }, [fetchData]) + useEffect(() => { + if (!Number.isFinite(selectedTaskIdFromUrl)) { + if (selectedTask) setSelectedTask(null) + return + } + + const match = tasks.find((task) => task.id === selectedTaskIdFromUrl) + if (match) { + if (selectedTask?.id !== match.id) { + setSelectedTask(match) + } + return + } + + if (!loading) { + setError(`Task #${selectedTaskIdFromUrl} not found in current workspace`) + setSelectedTask(null) + } + }, [loading, selectedTask, selectedTaskIdFromUrl, setSelectedTask, tasks]) + // Poll as SSE fallback — pauses when SSE is delivering events useSmartPoll(fetchData, 30000, { pauseWhenSseConnected: true }) @@ -342,8 +383,17 @@ export function TaskBoardPanel() { tabIndex={0} aria-label={`${task.title}, ${task.priority} priority, ${task.status}`} onDragStart={(e) => handleDragStart(e, task)} - onClick={() => setSelectedTask(task)} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedTask(task) } }} + onClick={() => { + setSelectedTask(task) + updateTaskUrl(task.id) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setSelectedTask(task) + updateTaskUrl(task.id) + } + }} className={`bg-surface-1 rounded-lg p-3 cursor-pointer hover:bg-surface-2 transition-smooth border-l-4 ${priorityColors[task.priority]} ${ draggedTask?.id === task.id ? 'opacity-50' : '' }`} @@ -440,11 +490,15 @@ export function TaskBoardPanel() { setSelectedTask(null)} + onClose={() => { + setSelectedTask(null) + updateTaskUrl(null) + }} onUpdate={fetchData} onEdit={(taskToEdit) => { setEditingTask(taskToEdit) setSelectedTask(null) + updateTaskUrl(null, 'replace') }} /> )}