Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions interface/src/api/client-typed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import createClient from "openapi-fetch";
import { getAuthHeaders } from "./client";
import type { paths } from "./schema";

let baseUrl = "";
Expand All @@ -14,11 +15,6 @@ function getClient() {
});
}

function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem("spacebot_auth_token");
return token ? { Authorization: `Bearer ${token}` } : {};
}

// Re-export the typed client for direct use
export { getClient };
export type { paths };
248 changes: 158 additions & 90 deletions interface/src/api/client.ts

Large diffs are not rendered by default.

230 changes: 230 additions & 0 deletions interface/src/components/TaskAttempts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import {useEffect, useRef, useState} from "react";
import {useQuery, useQueryClient} from "@tanstack/react-query";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {
faCheck,
faChevronDown,
faChevronRight,
faCircleHalfStroke,
faHourglassEnd,
faPlug,
faSpinner,
faStop,
faTriangleExclamation,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
import {Badge} from "@spacedrive/primitives";
import {api, type TaskAttempt, type TaskAttemptOutcome} from "@/api/client";
import {useLiveContext} from "@/hooks/useLiveContext";

type BadgeVariant = "info" | "success" | "warning" | "error" | "default";

const OUTCOME_LABEL: Record<TaskAttemptOutcome, string> = {
succeeded: "Succeeded",
partial: "Partial",
blocked: "Blocked",
failed: "Failed",
cancelled: "Cancelled",
timed_out: "Timed out",
interrupted: "Interrupted",
};

const OUTCOME_ICON: Record<TaskAttemptOutcome, typeof faCheck> = {
succeeded: faCheck,
partial: faCircleHalfStroke,
blocked: faTriangleExclamation,
failed: faXmark,
cancelled: faStop,
timed_out: faHourglassEnd,
interrupted: faPlug,
};

const OUTCOME_VARIANT: Record<TaskAttemptOutcome, BadgeVariant> = {
succeeded: "success",
partial: "info",
blocked: "warning",
failed: "error",
cancelled: "default",
timed_out: "warning",
interrupted: "default",
};

function formatTimestamp(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}

/** Wall-clock duration, or how long a live run has been going. */
function formatDuration(startedAt: string, endedAt?: string | null): string | null {
const start = new Date(startedAt).getTime();
const end = endedAt ? new Date(endedAt).getTime() : Date.now();
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;

const seconds = Math.round((end - start) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}

/**
* The run's own output, fetched only when asked for.
*
* The attempt row records how the run ended; the worker holds what it actually
* produced, and that is often long enough to bury everything else.
*/
function AttemptOutput({agentId, workerId}: {agentId: string; workerId: string}) {
const [expanded, setExpanded] = useState(false);

const {data, isLoading, error} = useQuery({
queryKey: ["worker-detail", agentId, workerId],
queryFn: () => api.workerDetail(agentId, workerId),
enabled: expanded,
staleTime: 60_000,
});

return (
<div className="mt-1.5">
<button
type="button"
onClick={() => setExpanded((open) => !open)}
aria-expanded={expanded}
className="inline-flex items-center gap-1.5 text-[11px] text-ink-dull hover:text-ink"
>
<FontAwesomeIcon
icon={expanded ? faChevronDown : faChevronRight}
className="text-[9px]"
/>
<span>Worker output</span>
</button>

{expanded && (
<div className="mt-1.5 rounded border border-app-line/60 bg-app-box/40 p-2">
{isLoading ? (
<span className="text-[11px] text-ink-faint">Loading worker output…</span>
) : error ? (
<span className="text-[11px] text-red-400">
Worker run is no longer available.
</span>
) : (
<pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-ink-dull">
{data?.result?.trim() || "This worker recorded no output."}
</pre>
)}
</div>
)}
</div>
);
}

function AttemptRow({attempt, agentId}: {attempt: TaskAttempt; agentId?: string}) {
const live = !attempt.ended_at;
const outcome = attempt.outcome ?? null;
const duration = formatDuration(attempt.started_at, attempt.ended_at);

return (
<li className="border-b border-app-line/40 py-2.5 last:border-b-0">
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] text-ink-faint">#{attempt.attempt}</span>

{live ? (
<Badge variant="info" size="sm">
<FontAwesomeIcon icon={faSpinner} className="animate-spin text-[10px]" />
<span>Running</span>
</Badge>
) : outcome ? (
<Badge variant={OUTCOME_VARIANT[outcome]} size="sm">
<FontAwesomeIcon icon={OUTCOME_ICON[outcome]} className="text-[10px]" />
<span>{OUTCOME_LABEL[outcome]}</span>
</Badge>
) : (
<Badge variant="default" size="sm">
<span>Ended without an outcome</span>
</Badge>
)}

<span className="font-mono text-[10px] text-ink-faint">
{attempt.worker_id.slice(0, 8)}
</span>
<span className="text-[10px] text-ink-faint">
{formatTimestamp(attempt.started_at)}
{duration ? ` · ${duration}` : ""}
</span>
{attempt.channel_id && (
<span className="text-[10px] text-ink-faint">via {attempt.channel_id}</span>
)}
</div>

{attempt.outcome_summary && (
<p className="whitespace-pre-wrap break-words text-xs leading-relaxed text-ink-dull">
{attempt.outcome_summary}
</p>
)}

{agentId && <AttemptOutput agentId={agentId} workerId={attempt.worker_id} />}
</li>
);
}

/**
* Every worker run attempted against this task.
*
* The task row names only the run executing now, so without this a task that
* failed twice before succeeding looks identical to one that worked first time.
*/
export function TaskAttempts({
taskNumber,
agentId,
}: {
taskNumber: number;
agentId?: string;
}) {
const queryClient = useQueryClient();
const {workerEventVersion} = useLiveContext();
const queryKey = ["task-attempts", taskNumber];

// A run starting or finishing arrives over SSE.
const previousVersion = useRef(workerEventVersion);
useEffect(() => {
if (workerEventVersion !== previousVersion.current) {
previousVersion.current = workerEventVersion;
void queryClient.invalidateQueries({queryKey});
}
}, [workerEventVersion, queryClient, taskNumber]);

const {data, isLoading, error} = useQuery({
queryKey,
queryFn: () => api.listTaskAttempts(taskNumber),
});

const attempts = data?.attempts ?? [];

return (
<div className="border-t border-app-line/40 px-4 py-3">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-ink-dull">
Runs{attempts.length > 0 ? ` (${attempts.length})` : ""}
</h3>

{isLoading ? (
<p className="text-xs text-ink-faint">Loading runs…</p>
) : error ? (
<p className="text-xs text-red-400">Failed to load the run history.</p>
) : attempts.length === 0 ? (
<p className="text-xs text-ink-faint">
Not worked yet. Every worker run against this task is recorded here.
</p>
) : (
<>
{data?.summary && (
<p className="mb-2 text-[11px] text-ink-faint">{data.summary}</p>
)}
<ul>
{attempts.map((attempt) => (
<AttemptRow key={attempt.id} attempt={attempt} agentId={agentId} />
))}
</ul>
</>
)}
</div>
);
}
24 changes: 13 additions & 11 deletions interface/src/components/portal/PortalTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,28 +340,30 @@ export function PortalTimeline({
refetchInterval: 2000,
});

const conversationWorkers = (workersQuery.data?.workers ?? []).filter(
(w) => w.channel_id === conversationId,
// The workers query is a page of the agent's most recent workers, not this
// conversation's full set, so it cannot decide which rows exist. It only
// enriches the rows the timeline already carries; `renderTimelineItem`
// falls back to `synthesizeWorker` for any worker outside the page.
const conversationWorkers = useMemo(
() =>
(workersQuery.data?.workers ?? []).filter(
(worker) => worker.channel_id === conversationId,
),
[workersQuery.data, conversationId],
);
const workerIds = new Set(conversationWorkers.map((w) => w.id));

const visibleItems = timeline.filter((item) => {
if (item.type !== "worker_run") return true;
return workerIds.has(item.id);
});

const rows: TimelineRow[] = useMemo(() => {
const list: TimelineRow[] = visibleItems.map((item) => ({
const list: TimelineRow[] = timeline.map((item) => ({
kind: "item",
item,
}));
if (conversationCreatedAt && visibleItems.length > 0) {
if (conversationCreatedAt && timeline.length > 0) {
list.unshift({kind: "conversation_start", createdAt: conversationCreatedAt});
}
if (isTyping) list.push({kind: "typing"});
list.push({kind: "spacer"});
return list;
}, [conversationCreatedAt, visibleItems, isTyping]);
}, [conversationCreatedAt, timeline, isTyping]);

useEffect(() => {
if (sendCount === 0) return;
Expand Down
5 changes: 5 additions & 0 deletions interface/src/routes/AgentTasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
taskListTitle,
TaskMetadataBadges,
} from "@/components/TaskUtils";
import {TaskAttempts} from "@/components/TaskAttempts";
import {TaskComments} from "@/components/TaskComments";
import {TaskHistory} from "@/components/TaskHistory";

Expand Down Expand Up @@ -240,6 +241,10 @@ export function AgentTasks({agentId}: {agentId: string}) {
<GithubSection
metadata={(activeTask as unknown as TaskItem).metadata}
/>
<TaskAttempts
taskNumber={activeTask.task_number}
agentId={agentId}
/>
<TaskComments
taskNumber={activeTask.task_number}
agentId={agentId}
Expand Down
3 changes: 2 additions & 1 deletion interface/src/routes/AgentWorkers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {motion} from "framer-motion";
import {Markdown} from "@/components/Markdown";
import {
api,
apiFetch,
type WorkerRunInfo,
type WorkerDetailResponse,
type TranscriptStep,
Expand Down Expand Up @@ -650,7 +651,7 @@ function OpenCodeDirectLink({
if (initialDirectory) return;
// Fetch directory from the OpenCode session API as fallback.
const controller = new AbortController();
fetch(`/api/opencode/${port}/session/${sessionId}`, {
apiFetch(`/api/opencode/${port}/session/${sessionId}`, {
signal: controller.signal,
})
.then((r) => (r.ok ? r.json() : null))
Expand Down
10 changes: 8 additions & 2 deletions interface/src/routes/ChannelDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,13 @@ export function ChannelDetail({
// paint, and the timeline keeps growing after the first render, so hold the
// bottom across a few frames each time. Once the reader scrolls up, their
// position is left alone.
//
// The channel counts as opened on the first pin, not after the frame loop
// finishes: rowCount changes on nearly every commit while history streams,
// and the cleanup cancels the pending frame each time, so a loop that only
// records itself at the end never gets there. Leaving it unrecorded holds
// `opening` true, which skips the distance check below and drags the reader
// back to the bottom on every update.
useEffect(() => {
if (rowCount === 0) return;
const opening = openedChannelRef.current !== channelId;
Expand All @@ -427,11 +434,10 @@ export function ChannelDetail({
let attempts = 0;
const pinToEnd = () => {
chatRef.current?.scrollToEnd({behavior: "auto"});
openedChannelRef.current = channelId;
attempts += 1;
if (attempts < 12) {
frame = requestAnimationFrame(pinToEnd);
} else {
openedChannelRef.current = channelId;
}
};
frame = requestAnimationFrame(pinToEnd);
Expand Down
7 changes: 7 additions & 0 deletions interface/src/routes/GlobalTasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
taskListTitle,
TaskMetadataBadges,
} from "@/components/TaskUtils";
import {TaskAttempts} from "@/components/TaskAttempts";
import {TaskComments} from "@/components/TaskComments";
import {TaskHistory} from "@/components/TaskHistory";

Expand Down Expand Up @@ -328,6 +329,12 @@ export function GlobalTasks() {
<GithubSection
metadata={(activeTask as unknown as TaskItem).metadata}
/>
<TaskAttempts
taskNumber={activeTask.task_number}
agentId={
activeTask.assigned_agent_id ?? activeTask.owner_agent_id
}
/>
<TaskComments
taskNumber={activeTask.task_number}
agentId={
Expand Down
Loading
Loading