diff --git a/.gitignore b/.gitignore index 8d0c02629..0106236c1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ coverage/ *.patch *-context.md packages/web/screenshots/ + +# Development symlinks (created per-worktree, not committed) +.claude +packages/web/agent-orchestrator.yaml diff --git a/agent-orchestrator.yaml b/agent-orchestrator.yaml new file mode 100644 index 000000000..5dbaebc83 --- /dev/null +++ b/agent-orchestrator.yaml @@ -0,0 +1,29 @@ +# Agent Orchestrator — self-hosting config (dog-fooding) + +dataDir: ~/.ao-sessions +worktreeDir: ~/.worktrees/ao +port: 3000 + +defaults: + runtime: tmux + agent: claude-code + workspace: worktree + notifiers: [desktop] + +projects: + ao: + name: Agent Orchestrator + repo: ComposioHQ/agent-orchestrator + path: ~/agent-orchestrator + defaultBranch: main + sessionPrefix: ao + scm: + plugin: github + tracker: + plugin: linear + teamId: "2a6e9b1b-19cd-4e30-b5bd-7b34dc491c7e" + symlinks: [.claude] + postCreate: + - "pnpm install" + agentConfig: + permissions: skip diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index d3df6b0b7..5aea02235 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -11,6 +11,8 @@ * Reference: scripts/claude-ao-session, scripts/send-to-session */ +import { statSync } from "node:fs"; +import { join } from "node:path"; import type { SessionManager, Session, @@ -92,7 +94,12 @@ function validateStatus(raw: string | undefined): SessionStatus { } /** Reconstruct a Session object from raw metadata key=value pairs. */ -function metadataToSession(sessionId: SessionId, meta: Record): Session { +function metadataToSession( + sessionId: SessionId, + meta: Record, + createdAt?: Date, + modifiedAt?: Date, +): Session { return { id: sessionId, projectId: meta["project"] ?? "", @@ -122,8 +129,8 @@ function metadataToSession(sessionId: SessionId, meta: Record): ? safeJsonParse(meta["runtimeHandle"]) : null, agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } : null, - createdAt: meta["createdAt"] ? new Date(meta["createdAt"]) : new Date(), - lastActivityAt: new Date(), + createdAt: meta["createdAt"] ? new Date(meta["createdAt"]) : (createdAt ?? new Date()), + lastActivityAt: modifiedAt ?? new Date(), metadata: meta, }; } @@ -358,7 +365,19 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { // Filter by project if specified if (projectId && raw["project"] !== projectId) continue; - const session = metadataToSession(sid, raw); + // Get file timestamps for createdAt/lastActivityAt + let createdAt: Date | undefined; + let modifiedAt: Date | undefined; + try { + const metaPath = join(config.dataDir, sid); + const stats = statSync(metaPath); + createdAt = stats.birthtime; + modifiedAt = stats.mtime; + } catch { + // If stat fails, timestamps will fall back to current time + } + + const session = metadataToSession(sid, raw, createdAt, modifiedAt); // Check if runtime is still alive if (session.runtimeHandle) { @@ -388,7 +407,41 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { async function get(sessionId: SessionId): Promise { const raw = readMetadataRaw(config.dataDir, sessionId); if (!raw) return null; - return metadataToSession(sessionId, raw); + + // Get file timestamps for createdAt/lastActivityAt + let createdAt: Date | undefined; + let modifiedAt: Date | undefined; + try { + const metaPath = join(config.dataDir, sessionId); + const stats = statSync(metaPath); + createdAt = stats.birthtime; + modifiedAt = stats.mtime; + } catch { + // If stat fails, timestamps will fall back to current time + } + + const session = metadataToSession(sessionId, raw, createdAt, modifiedAt); + + // Check if runtime is still alive (same as list() method) + if (session.runtimeHandle) { + const project = config.projects[session.projectId]; + if (project) { + const plugins = resolvePlugins(project); + if (plugins.runtime) { + try { + const alive = await plugins.runtime.isAlive(session.runtimeHandle); + if (!alive) { + session.status = "killed"; + session.activity = "exited"; + } + } catch { + // Can't check — assume still alive + } + } + } + } + + return session; } async function kill(sessionId: SessionId): Promise { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index dab1cd513..d170bbed0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -282,6 +282,9 @@ export interface Tracker { /** Generate a URL for the issue */ issueUrl(identifier: string, project: ProjectConfig): string; + /** Extract a human-readable label from an issue URL (e.g., "INT-1327", "#42") */ + issueLabel?(url: string, project: ProjectConfig): string; + /** Generate a git branch name for the issue */ branchName(identifier: string, project: ProjectConfig): string; diff --git a/packages/plugins/runtime-tmux/src/index.ts b/packages/plugins/runtime-tmux/src/index.ts index b75b807ab..cbadf3c4f 100644 --- a/packages/plugins/runtime-tmux/src/index.ts +++ b/packages/plugins/runtime-tmux/src/index.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { setTimeout as sleep } from "node:timers/promises"; import { randomUUID } from "node:crypto"; import { writeFileSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -120,6 +121,9 @@ export function create(): Runtime { await tmux("send-keys", "-t", handle.id, "-l", message); } + // Small delay to let tmux process the pasted text before pressing Enter. + // Without this, Enter can arrive before the text is fully rendered. + await sleep(300); await tmux("send-keys", "-t", handle.id, "Enter"); }, diff --git a/packages/plugins/tracker-github/src/index.ts b/packages/plugins/tracker-github/src/index.ts index da19278fa..abd081a8e 100644 --- a/packages/plugins/tracker-github/src/index.ts +++ b/packages/plugins/tracker-github/src/index.ts @@ -114,6 +114,19 @@ function createGitHubTracker(): Tracker { return `https://github.com/${project.repo}/issues/${num}`; }, + issueLabel(url: string, _project: ProjectConfig): string { + // Extract issue number from GitHub URL + // Example: https://github.com/owner/repo/issues/42 → "#42" + const match = url.match(/\/issues\/(\d+)/); + if (match) { + return `#${match[1]}`; + } + // Fallback: return the last segment of the URL + const parts = url.split("/"); + const lastPart = parts[parts.length - 1]; + return lastPart ? `#${lastPart}` : url; + }, + branchName(identifier: string, _project: ProjectConfig): string { const num = identifier.replace(/^#/, ""); return `feat/issue-${num}`; diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index 3cbba0924..3c8d0dd80 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -329,6 +329,20 @@ function createLinearTracker(query: GraphQLTransport): Tracker { return `https://linear.app/issue/${identifier}`; }, + issueLabel(url: string, _project: ProjectConfig): string { + // Extract identifier from Linear URL + // Examples: + // https://linear.app/composio/issue/INT-1327 + // https://linear.app/issue/INT-1327 + const match = url.match(/\/issue\/([A-Z]+-\d+)/); + if (match) { + return match[1]; + } + // Fallback: return the last segment of the URL + const parts = url.split("/"); + return parts[parts.length - 1] || url; + }, + branchName(identifier: string, _project: ProjectConfig): string { // Linear convention: feat/INT-1330 return `feat/${identifier}`; diff --git a/packages/web/package.json b/packages/web/package.json index 55e0fd16f..d101c48c8 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -5,7 +5,9 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev", + "dev": "concurrently \"npm:dev:next\" \"npm:dev:terminal\"", + "dev:next": "next dev", + "dev:terminal": "tsx watch src/server/terminal-websocket.ts", "build": "next build", "start": "next start", "typecheck": "tsc --noEmit", @@ -21,6 +23,7 @@ "@agent-orchestrator/plugin-runtime-tmux": "workspace:*", "@agent-orchestrator/plugin-scm-github": "workspace:*", "@agent-orchestrator/plugin-tracker-github": "workspace:*", + "@agent-orchestrator/plugin-tracker-linear": "workspace:*", "@agent-orchestrator/plugin-workspace-worktree": "workspace:*", "next": "^15.1.0", "react": "^19.0.0", @@ -33,6 +36,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", + "concurrently": "^9.2.1", "jsdom": "^25.0.0", "playwright": "^1.49.0", "tailwindcss": "^4.0.0", diff --git a/packages/web/src/__tests__/helpers.ts b/packages/web/src/__tests__/helpers.ts index 2ede98d75..358b62f10 100644 --- a/packages/web/src/__tests__/helpers.ts +++ b/packages/web/src/__tests__/helpers.ts @@ -8,7 +8,9 @@ export function makeSession(overrides: Partial = {}): Dashboar status: "working", activity: "active", branch: "feat/test", - issueId: "INT-100", + issueId: "https://linear.app/test/issue/INT-100", + issueUrl: "https://linear.app/test/issue/INT-100", + issueLabel: "INT-100", summary: "Test session", createdAt: new Date().toISOString(), lastActivityAt: new Date().toISOString(), diff --git a/packages/web/src/app/api/sessions/[id]/message/route.ts b/packages/web/src/app/api/sessions/[id]/message/route.ts new file mode 100644 index 000000000..6392c1c7b --- /dev/null +++ b/packages/web/src/app/api/sessions/[id]/message/route.ts @@ -0,0 +1,97 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { getServices } from "@/lib/services"; +import { stripControlChars, validateIdentifier, validateString } from "@/lib/validation"; +import type { Runtime } from "@agent-orchestrator/core"; + +const MAX_MESSAGE_LENGTH = 10_000; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + + // Validate session ID to prevent injection + const idErr = validateIdentifier(id, "id"); + if (idErr) { + return NextResponse.json({ error: idErr }, { status: 400 }); + } + + // Parse JSON with explicit error handling + let body: Record | null; + try { + body = await request.json() as Record; + } catch { + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 }, + ); + } + + // Validate message is a non-empty string within length limit + const messageErr = validateString(body?.message, "message", MAX_MESSAGE_LENGTH); + if (messageErr) { + return NextResponse.json({ error: messageErr }, { status: 400 }); + } + + // Type guard: ensure message is actually a string + const rawMessage = body?.message; + if (typeof rawMessage !== "string") { + return NextResponse.json( + { error: "message must be a string" }, + { status: 400 }, + ); + } + + // Strip control characters to prevent injection when passed to shell-based runtimes + const message = stripControlChars(rawMessage); + + // Re-validate after stripping — a control-char-only message becomes empty + if (message.trim().length === 0) { + return NextResponse.json( + { error: "message must not be empty after sanitization" }, + { status: 400 }, + ); + } + + const { sessionManager, registry } = await getServices(); + const session = await sessionManager.get(id); + + if (!session) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + + if (!session.runtimeHandle) { + return NextResponse.json({ error: "Session has no runtime handle" }, { status: 400 }); + } + + // Get the runtime plugin that was used to create this session + // Use the runtime from the session handle, not from current project config + const runtimeName = session.runtimeHandle.runtimeName; + const runtime = registry.get("runtime", runtimeName); + if (!runtime) { + return NextResponse.json({ error: `Runtime plugin '${runtimeName}' not found` }, { status: 500 }); + } + + try { + // Use the Runtime plugin's sendMessage method which handles sanitization + // and uses the correct runtime handle + await runtime.sendMessage(session.runtimeHandle, message); + return NextResponse.json({ success: true }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error("Failed to send message:", errorMsg); + return NextResponse.json( + { error: `Failed to send message: ${errorMsg}` }, + { status: 500 }, + ); + } + } catch (error) { + console.error("Failed to send message:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/sessions/[id]/route.ts b/packages/web/src/app/api/sessions/[id]/route.ts new file mode 100644 index 000000000..72093f150 --- /dev/null +++ b/packages/web/src/app/api/sessions/[id]/route.ts @@ -0,0 +1,53 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { getServices, getSCM, getTracker } from "@/lib/services"; +import { sessionToDashboard, enrichSessionPR, enrichSessionIssue } from "@/lib/serialize"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const { config, registry, sessionManager } = await getServices(); + + const coreSession = await sessionManager.get(id); + if (!coreSession) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + + const dashboardSession = sessionToDashboard(coreSession); + + // Get project config for enrichments + let project = config.projects[coreSession.projectId]; + if (!project) { + const entry = Object.entries(config.projects).find(([, p]) => + coreSession.id.startsWith(p.sessionPrefix), + ); + if (entry) project = entry[1]; + } + + // Enrich issue label using tracker plugin + if (dashboardSession.issueUrl && project) { + const tracker = getTracker(registry, project); + if (tracker) { + enrichSessionIssue(dashboardSession, tracker, project); + } + } + + // Enrich PR with live data from SCM + if (coreSession.pr && project) { + const scm = getSCM(registry, project); + if (scm) { + await enrichSessionPR(dashboardSession, scm, coreSession.pr); + } + } + + return NextResponse.json(dashboardSession); + } catch (error) { + console.error("Failed to fetch session:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 8a5d70b0a..e9b559934 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,7 +1,7 @@ import { Dashboard } from "@/components/Dashboard"; import type { DashboardSession } from "@/lib/types"; -import { getServices, getSCM } from "@/lib/services"; -import { sessionToDashboard, enrichSessionPR, computeStats } from "@/lib/serialize"; +import { getServices, getSCM, getTracker } from "@/lib/services"; +import { sessionToDashboard, enrichSessionPR, enrichSessionIssue, computeStats } from "@/lib/serialize"; export const dynamic = "force-dynamic"; @@ -12,6 +12,25 @@ export default async function Home() { const coreSessions = await sessionManager.list(); sessions = coreSessions.map(sessionToDashboard); + // Enrich issue labels using tracker plugin (synchronous) + coreSessions.forEach((core, i) => { + if (!sessions[i].issueUrl) return; + let project = config.projects[core.projectId]; + if (!project) { + const entry = Object.entries(config.projects).find(([, p]) => + core.id.startsWith(p.sessionPrefix), + ); + if (entry) project = entry[1]; + } + if (!project) { + const firstKey = Object.keys(config.projects)[0]; + if (firstKey) project = config.projects[firstKey]; + } + const tracker = getTracker(registry, project); + if (!tracker || !project) return; + enrichSessionIssue(sessions[i], tracker, project); + }); + // Enrich sessions that have PRs with live SCM data const enrichPromises = coreSessions.map((core, i) => { if (!core.pr) return Promise.resolve(); diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index 997683f63..64cf650e9 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -1,25 +1,69 @@ -import { notFound } from "next/navigation"; -import { getServices } from "@/lib/services"; -import { sessionToDashboard } from "@/lib/serialize"; +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useParams } from "next/navigation"; import { SessionDetail } from "@/components/SessionDetail"; +import type { DashboardSession } from "@/lib/types"; -interface Props { - params: Promise<{ id: string }>; -} +export default function SessionPage() { + const params = useParams(); + const id = params.id as string; -export default async function SessionPage({ params }: Props) { - const { id } = await params; + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - const { sessionManager } = await getServices().catch(() => { - notFound(); - // notFound() throws, so this never runs, but TS needs the return type - return null as never; - }); + // Fetch session data (memoized to avoid recreating on every render) + const fetchSession = useCallback(async () => { + try { + const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`); + if (res.status === 404) { + setError("Session not found"); + setLoading(false); + return; + } + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const data = await res.json() as DashboardSession; + setSession(data); + setError(null); + } catch (err) { + console.error("Failed to fetch session:", err); + setError("Failed to load session"); + } finally { + setLoading(false); + } + }, [id]); - const coreSession = await sessionManager.get(id); - if (!coreSession) { - notFound(); + // Initial fetch + useEffect(() => { + fetchSession(); + }, [fetchSession]); + + // Poll for updates every 5 seconds + useEffect(() => { + const interval = setInterval(fetchSession, 5000); + return () => clearInterval(interval); + }, [fetchSession]); + + if (loading) { + return ( +
+
Loading session...
+
+ ); } - return ; + if (error || !session) { + return ( +
+
+ {error || "Session not found"} +
+
+ ); + } + + return ; } diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index 6f4bab433..ea0c85018 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -101,13 +101,22 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses restore session )} - e.stopPropagation()} + {/* Meta row: branch + PR pills */} @@ -183,9 +192,16 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses )} - {session.issueId && ( + {session.issueUrl && ( - {session.issueId} + + {session.issueLabel || session.issueUrl} + )} diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 70df53e54..4da8b1df0 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -49,6 +49,24 @@ function humanizeStatus(status: string): string { .replace(/\b\w/g, (c) => c.toUpperCase()); } +/** Converts attention level to human-readable label. */ +function humanizeLevel(level: string): string { + switch (level) { + case "merge": + return "Ready to Merge"; + case "respond": + return "Needs Response"; + case "review": + return "Pending Review"; + case "pending": + return "Pending"; + case "working": + return "Working"; + default: + return level; + } +} + /** Converts ISO date string to relative time like "3h ago", "2m ago". Client-side only. */ function relativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); @@ -62,6 +80,19 @@ function relativeTime(iso: string): string { return `${days}d ago`; } +/** Clean up Bugbot comment body - extract title and description, remove HTML junk */ +function cleanBugbotComment(body: string): { title: string; description: string } { + // Extract title (first ### heading) + const titleMatch = body.match(/###\s+(.+?)(?:\n|$)/); + const title = titleMatch ? titleMatch[1].replace(/\*\*/g, "").trim() : "Comment"; + + // Extract description between DESCRIPTION START/END comments + const descMatch = body.match(/\s*([\s\S]*?)\s*/); + const description = descMatch ? descMatch[1].trim() : body.split("\n")[0] || "No description"; + + return { title, description }; +} + /** Builds a GitHub branch URL from PR owner/repo/branch. */ function buildGitHubBranchUrl(pr: DashboardPR): string { return `https://github.com/${pr.owner}/${pr.repo}/tree/${pr.branch}`; @@ -74,6 +105,33 @@ function buildGitHubRepoUrl(pr: DashboardPR): string { // ── Main Component ─────────────────────────────────────────────────── +/** Ask the agent to fix a specific review comment */ +async function askAgentToFix( + sessionId: string, + comment: { url: string; path: string; body: string }, +) { + try { + const { title, description } = cleanBugbotComment(comment.body); + const message = `Please address this review comment:\n\nFile: ${comment.path}\nComment: ${title}\nDescription: ${description}\n\nComment URL: ${comment.url}\n\nAfter fixing, mark the comment as resolved at ${comment.url}`; + + // TODO: Implement API endpoint to send message to agent session + const res = await fetch(`/api/sessions/${sessionId}/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message }), + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + alert("Message sent to agent"); + } catch (err) { + console.error("Failed to send message to agent:", err); + alert("Failed to send message to agent"); + } +} + export function SessionDetail({ session }: SessionDetailProps) { const pr = session.pr; const level = getAttentionLevel(session); @@ -112,13 +170,13 @@ export function SessionDetail({ session }: SessionDetailProps) { {activity.label} - {level} + {humanizeLevel(level)} @@ -127,26 +185,44 @@ export function SessionDetail({ session }: SessionDetailProps) {

{session.summary}

)} - {/* Meta chips: project · branch · issue */} + {/* Meta chips: PR · branch · issue */}
- {pr ? ( - - {session.projectId} - - ) : ( - - {session.projectId} - + {session.projectId && ( + <> + {pr ? ( + + {session.projectId} + + ) : ( + + {session.projectId} + + )} + · + + )} + + {pr && ( + <> + + #{pr.number} + + · + )} {session.branch && ( <> - · {pr ? ( )} + · )} - {session.issueId && ( - <> - · - - {session.issueId} - - + {session.issueUrl && ( + + {session.issueLabel || session.issueUrl} + )}
@@ -183,7 +262,7 @@ export function SessionDetail({ session }: SessionDetailProps) { {/* ── PR Card ────────────────────────────────────────────── */} - {pr && } + {pr && } {/* ── Terminal ───────────────────────────────────────────── */}
@@ -237,7 +316,7 @@ function ClientTimestamps({ // ── PR Card ────────────────────────────────────────────────────────── -function PRCard({ pr }: { pr: DashboardPR }) { +function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { const allGreen = pr.mergeability.mergeable && pr.mergeability.ciPassing && @@ -281,14 +360,6 @@ function PRCard({ pr }: { pr: DashboardPR }) { )} - {pr.state === "open" && ( - <> - · - - · - - - )}
@@ -317,39 +388,52 @@ function PRCard({ pr }: { pr: DashboardPR }) {

Unresolved Comments ({pr.unresolvedThreads})

-
- {pr.unresolvedComments.map((c) => ( -
-
- - {c.author} - - on - - {c.path} - - - view → - -
-

- {c.body} -

-
- ))} +
+ {pr.unresolvedComments.map((c) => { + const { title, description } = cleanBugbotComment(c.body); + return ( +
+ + + + + + {title} + + · {c.author} + e.stopPropagation()} + className="ml-auto text-[10px] text-[var(--color-accent-blue)] hover:underline" + > + view → + + +
+
+ {c.path} +
+

+ {description} +

+ +
+
+ ); + })}
)} @@ -365,10 +449,13 @@ function IssuesList({ pr }: { pr: DashboardPR }) { if (pr.ciStatus === "failing") { const failCount = pr.ciChecks.filter((c) => c.status === "failed").length; + const text = failCount > 0 + ? `CI failing \u2014 ${failCount} check${failCount !== 1 ? "s" : ""} failed` + : "CI failing"; issues.push({ icon: "\u2717", color: "var(--color-accent-red)", - text: `CI failing \u2014 ${failCount} check${failCount !== 1 ? "s" : ""} failed`, + text, }); } else if (pr.ciStatus === "pending") { issues.push({ @@ -441,40 +528,3 @@ function IssuesList({ pr }: { pr: DashboardPR }) { ); } -// ── Inline status pills ────────────────────────────────────────────── - -function CIStatusInline({ status, failedCount }: { status: string; failedCount: number }) { - if (status === "passing") { - return {"\u2713"} CI passing; - } - if (status === "failing") { - return ( - - {"\u2717"} {failedCount} check{failedCount !== 1 ? "s" : ""} failing - - ); - } - if (status === "pending") { - return {"\u25CF"} CI pending; - } - return null; -} - -function ReviewStatusInline({ decision }: { decision: string }) { - if (decision === "approved") { - return {"\u2713"} Approved; - } - if (decision === "changes_requested") { - return ( - {"\u2717"} Changes requested - ); - } - if (decision === "pending") { - return ( - - {"\u23F3"} Pending review - - ); - } - return No review; -} diff --git a/packages/web/src/components/Terminal.tsx b/packages/web/src/components/Terminal.tsx index 49f58e1cc..e0f2f6d00 100644 --- a/packages/web/src/components/Terminal.tsx +++ b/packages/web/src/components/Terminal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { cn } from "@/lib/cn"; interface TerminalProps { @@ -8,11 +8,34 @@ interface TerminalProps { } /** - * Terminal embed placeholder. - * Future: integrate xterm.js via the terminal-web plugin. + * Terminal embed using ttyd (iframe). + * ttyd handles xterm.js, WebSocket, ANSI rendering, resize, input — everything. + * We just request a ttyd URL from our terminal server and embed it. */ export function Terminal({ sessionId }: TerminalProps) { const [fullscreen, setFullscreen] = useState(false); + const [terminalUrl, setTerminalUrl] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const port = process.env.NEXT_PUBLIC_TERMINAL_PORT ?? "3001"; + // Use current hostname instead of hardcoded localhost + const protocol = window.location.protocol; + const hostname = window.location.hostname; + // URL-encode sessionId to prevent special characters from breaking the URL + fetch(`${protocol}//${hostname}:${port}/terminal?session=${encodeURIComponent(sessionId)}`) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json() as Promise<{ url: string }>; + }) + .then((data) => { + setTerminalUrl(data.url); + }) + .catch((err) => { + console.error("[Terminal] Failed to get terminal URL:", err); + setError("Failed to connect to terminal server"); + }); + }, [sessionId]); return (
-
-
-
-
-
+
{sessionId} + + {terminalUrl ? "Connected" : error ?? "Connecting..."} +
-
+ {terminalUrl ? ( +