Files
fusion/packages/dashboard/app/components/AgentLogViewer.tsx
gsxdsm 31e49b684a TAKING default-workflow-hooks.ts + executor.ts + live-agent-count.ts + 6 dashboard files: reopen semantics by role, and the census's blind spot in both directions (13 sites) (#2628)
Batched conversion of every lifecycle-column guard I hold, plus the
three the census could not see. **Six files to zero, repo-wide 60 → 49
by a comment-stripped unanchored sweep.** Each conversion has an
isolated revert proof and a paired negative case, and the one code move
is a separate commit from the behavior changes.

## Per-file before → after

Counts from a comment-stripped, unanchored `(===|!==) ["']triage["']`
sweep over `packages/*/src` + `plugins/*/src`, excluding tests.

| file | before | after | note |
|---|---:|---:|---|
| `core/default-workflow-hooks.ts` | 4 | **0** | |
| `core/task-store/moves.ts` | 5 | **4** | only the flag-ON mirror
converted; the flag-OFF inline block is the parity reference and stays |
| `engine/executor.ts` | 3 | **0** | **absent from the 45-guard list** —
see below |
| `core/live-agent-count.ts` | 2 | **0** | duplication removed; answer
deliberately unchanged |
| `engine/replan-target.ts` | 2 | **0** | both were comment prose, not
guards |
| `core/agent-prompts.ts` | 3 | **0** | ROLE comparisons, never column
guards |
| `engine/usage-limit-detector.ts` | 2 | **0** | ROLE comparisons |
| `dashboard/app/components/DocumentsView.tsx` | 1 | **0** | real column
guard |
| `dashboard/app/components/TaskChatTab.tsx` | 2 | **0** | ROLE |
| `dashboard/app/components/AgentLogViewer.tsx` | 1 | **0** | ROLE |
| `dashboard/app/components/effective-model-resolution.ts` | 1 | **0** |
ROLE |
| `dashboard/app/hooks/useTasks.ts` | 1 | **0** | ROLE |
| `dashboard/…/command-center/MissionControlPanel.tsx` | 1 | 1 | alias
table, marked `DELIBERATE-LITERAL` with its reason |

## The census errs in BOTH directions

This is the finding I would most like carried into the remaining work.

- It **flagged 10 sites that were never column guards.** `role ===
"triage"` / `agentType === "triage"` compare an **AGENT ROLE**. The
planner *lane* is named `triage` and keeps that name — U11 removed the
*column*. Worse than noise: the obvious "finish the migration" edit is
to rename the role, and that silently empties the planner's prompt
template and mis-binds its model markers. `PLANNER_AGENT_ROLE` now names
it, so the two vocabularies are distinguishable by grep and a rename
fails loudly (revert proof: 4 tests, two of them pre-existing).
- It **missed 3 real guards in `executor.ts`**, because the pattern
matches `column`/`toColumn`/`fromColumn` and those locals are named
`from` and `originColumn`. A census keyed on variable names will keep
missing guards wherever a local was named for its role in the function.

## Two real defects, not tidying

**1. A renamed board could merge with its re-review never run.**
`default-workflow-hooks.ts` is named for the default workflow, but the
store runs it on the flag-ON path for *every* workflow — the trait
registry resolves hooks by trait id, not by workflow. Its reopen
predicates listed the default lineage's column names, so on a renamed
board **no reopen effect fired at all**. One of them clears
`workflowStepResults`, which `getTaskMergeBlocker` reads: a card bounced
out of review carried its old `passed` result back in, and that
satisfies the merge gate. Same regression the graph-owned-crossing
carve-out exists to prevent, arriving through the other door. (Two
smaller ones rode along: failure state never cleared on a renamed
reopen, and an operator dragging a card back to the queue never parked
it, so the scheduler re-dispatched what they had just pulled back.)

**I forgot the carve-out on my first pass, and that was worse than not
converting.** A role-resolved clear plus a *name*-matched exemption
means a renamed board takes the clear and never the exemption,
destroying the remediation input the graph had just written. My own
paired negative test caught it.

**2. The last-resort recovery for completed-but-stranded work did not
exist off the default lineage.** In `recoverCompletedTask`,
`promotedFromPlannerColumn` was false on a renamed board, so finished
work resting in the planning lane was never promoted — the code fell
through to `handoffTaskToReview` straight from the planning column, and
role adjacency has no planning → review edge, so the handoff was
rejected and the card stayed stuck with its work complete. I converted
the promotion **target** too: resolving the lane and then moving to a
literal `in-progress` is the half-conversion I have already been burned
by twice this program, where the guard starts admitting cards and the
move then sends them to a column the board does not declare.

## E2E evidence

`renamed-board-reopen.pg.test.ts` drives a **real PostgreSQL store** and
a real `moveTask` on a workflow whose columns carry the standard traits
under non-default names. The unit tests cannot show this: if `moves.ts`
passed `undefined`, every unit case still passes via the no-basis
fallback while the real board keeps the old behavior. **Proof it is
load-bearing: forcing `moveLifecycleColumns` to `undefined` fails 2 of
3.** The executor suite covers both the split-role and the MERGED
post-U11 shape.

## Revert proofs, isolated per site

| change reverted | result |
|---|---|
| reopen predicate → literal names | 4 of 10 fail |
| reopen field clears → literal names | 2 of 10 fail |
| `userPaused` hold lane → literal `todo` | 1 of 10 fail |
| graph carve-out → literal names | 1 of 10 fail |
| store passes `undefined` lifecycle columns | 2 of 3 fail (real PG) |
| `promotedFromPlannerColumn` → literals | 3 of 7 fail |
| two-hop condition → `=== "triage"` | 1 of 7 fails |
| promotion target → `"in-progress"` | 3 of 7 fail |
| `isPlannerColumnFor` → literals | 1 of 7 fails |
| live-agent-count: one arm dropped | 2 of 11 fail |
| DocumentsView: trait branch removed | 3 of 7 fail |
| planner role renamed to `"planner"` | 4 fail (2 pre-existing) |

Every conversion is paired with a negative case (a forward move, a
not-a-planner-lane card, a default-lineage card, a renamed column with
no traits), so neither "always fire" nor "never fire" can pass for
"resolve the role".

## Deliberately NOT converted, with reasons

- **`moves.ts` flag-OFF inline block (4).** That branch *is* the legacy
path, kept verbatim so the two can be parity-checked. Converting it
erases the reference implementation.
- **`live-agent-count.ts`'s no-flags fallback.** Reachable, and there is
nothing to resolve from — `enrich…FromFlags` exists for callers with
board flags rather than an IR, so a column missing from that map is the
renamed case. "Not intake" is as much a guess as "todo is intake", and
Running/Waiting are complements, so a card matching neither arm is
reported as neither and the footer's queued total under-reports it. The
real fix is at the caller; four new cases pin that flags override the
legacy answer **in both directions**. What did change is the
duplication: two hand-written copies of one rule now call one named
function.
- **`MissionControlPanel`'s `FUNNEL_STAGES`.** An alias table of column
*names* where `triage` sits beside `signal` and `backlog`. Command
Center aggregates across projects, so there is no single workflow to
resolve traits from — the honest conversion is a data change, not a
predicate change.
- **`DocumentsView` with no traits.** Same no-basis rule; the documents
list is full of historical columns absent from the current board. A case
asserts a renamed column with no traits still reads as "working",
documenting the gap rather than hiding it.

## Fixture findings

Each cost a red run that looked like the code under test:

- a `merge-blocker` column needs a reachable merge-class node, or
`parseWorkflowIr` rejects the workflow;
- a back-edge must be `kind: "rework"`, and a rework edge is legal only
**into** a node with `config.reworkRegion: true`;
- a workflow gets role-level transitions only when it declares wip +
review + complete + **archived** plus a planning lane — without the
archived column, adjacency falls back to order-derived neighbours and
`checking -> queued` is not a legal move at all;
- `recoverCompletedTask` only *reaches* the promotion seam when nothing
is left to gate; without passed `plan-review`/`code-review` rows it
re-enters the workflow graph and returns first, so a naive fixture
silently tests the wrong branch and every assertion reads "no moves
happened" for an unrelated reason.

## Verification

- `pnpm test:gate` **71/71**
- new suites: 10/10 reopen-semantics, 3/3 renamed-board-reopen (real
PG), 7/7 executor-planner-lanes, 7/7 documents-status-dot, 4/4
planner-role-is-not-a-column
- neighbours: 132 + 10 + 482 (gate shards), 350/351 engine
planning/replan suites, 64/64 agent-prompts, 51/51 usage-limit-detector,
11/11 live-agent-count, 11/11 dashboard hook/log suites
- the single engine failure (`executor-fast-mode-workflows.test.ts` ›
"raw fast mode still invokes non-executable review seam nodes")
**reproduces with my changes stashed** — pre-existing on `origin/main`
- typechecks clean for core, engine, and dashboard-app
(`tsconfig.app.json`; `tsconfig.json` checks nothing under `app/`);
`pnpm lint` clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:39:14 -07:00

798 lines
30 KiB
TypeScript

import type { AgentLogEntry } from "@fusion/core";
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:50: these are AGENT ROLE comparisons, not
// column guards — the planner LANE keeps the name `triage`; U11 removed only the COLUMN.
import { PLANNER_AGENT_ROLE } from "@fusion/core";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { ProviderIcon } from "./ProviderIcon";
import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, useId, type ReactElement } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react";
import "./AgentLogViewer.css";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
const MARKDOWN_TOGGLE_STORAGE_KEY = "fn-agent-log-markdown";
const TOOL_OUTPUT_TOGGLE_STORAGE_KEY = "fn-agent-log-tool-output";
function readBooleanPref(key: string, defaultValue: boolean): boolean {
if (typeof window === "undefined") return defaultValue;
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return defaultValue;
return raw === "true";
} catch {
return defaultValue;
}
}
function writeBooleanPref(key: string, value: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, value ? "true" : "false");
} catch {
// ignore storage failures (quota, private mode, etc.)
}
}
/*
FNXC:AgentLogTimestamps 2026-06-17-17:34:
FN-6601 centralizes timestamp bucket math but AgentLog keeps its existing translation keys and future timestamps continue to render as "just now".
*/
function formatTimestamp(iso: string, t: TFunction<"app">): string {
const bucket = getRelativeTimeBucket(iso);
if (!bucket) {
const date = new Date(iso);
return Number.isFinite(date.getTime()) ? t("agentLog.timeJustNow", "just now") : date.toLocaleDateString();
}
switch (bucket.bucket) {
case "just-now":
return t("agentLog.timeJustNow", "just now");
case "minutes":
return t("agentLog.timeMinutesAgo", "{{count}}m ago", { count: bucket.count });
case "hours":
return t("agentLog.timeHoursAgo", "{{count}}h ago", { count: bucket.count });
case "days":
return t("agentLog.timeDaysAgo", "{{count}}d ago", { count: bucket.count });
case "weeks":
case "older":
return bucket.date.toLocaleDateString();
}
}
export const markdownComponents: Components = {
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
code: ({ children, ...props }) => {
const text = typeof children === "string" ? children : React.Children.toArray(children).join("");
const linkedChildren = linkifyFilePaths(text);
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") {
return <code {...props}>{children}</code>;
}
return <code {...props}>{linkedChildren}</code>;
},
pre: ({ children, ...props }) => (
<pre
{...props}
style={{
overflowX: "auto",
maxWidth: "100%",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{children}
</pre>
),
table: ({ children, ...props }) => (
<table
{...props}
style={{
display: "block",
overflowX: "auto",
maxWidth: "100%",
}}
>
{children}
</table>
),
};
const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
function getAgentDisplayName(agent: string, t: TFunction<"app">): string {
if (agent === PLANNER_AGENT_ROLE) return t("agentLog.agentNameTriage", "Plan");
return agent;
}
function isNearBottom(container: HTMLDivElement): boolean {
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX;
}
export function formatAgentLogDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
}
export function formatAgentLogTimingLabels(entry: Pick<AgentLogEntry, "durationMs" | "timeToFirstTokenMs">, t: TFunction<"app">): string[] {
const labels: string[] = [];
if (typeof entry.timeToFirstTokenMs === "number" && Number.isFinite(entry.timeToFirstTokenMs)) {
labels.push(t("agentLog.timeToFirstToken", "TTFT {{duration}}", { duration: formatAgentLogDuration(entry.timeToFirstTokenMs) }));
}
if (typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs)) {
labels.push(t("agentLog.duration", "Duration {{duration}}", { duration: formatAgentLogDuration(entry.durationMs) }));
}
return labels;
}
function AgentLogTimingLabels({ entry }: { entry: AgentLogEntry }): ReactElement | null {
const { t } = useTranslation("app");
const labels = formatAgentLogTimingLabels(entry, t as TFunction<"app">);
if (labels.length === 0) return null;
return (
<span className="agent-log-timing-labels" data-testid="agent-log-timing-labels" aria-label={labels.join(", ")}>
{labels.map((label) => (
<span key={label} className="agent-log-timing-label">{label}</span>
))}
</span>
);
}
function getEntrySignature(entry: AgentLogEntry): string {
return [
entry.taskId,
entry.timestamp,
entry.agent ?? "",
entry.type,
entry.text,
entry.detail ?? "",
entry.durationMs ?? "",
entry.timeToFirstTokenMs ?? "",
].join("|");
}
function buildEntryRenderKeys(entries: AgentLogEntry[]): string[] {
const countsBySignature = new Map<string, number>();
return entries.map((entry) => {
const signature = getEntrySignature(entry);
const occurrence = countsBySignature.get(signature) ?? 0;
countsBySignature.set(signature, occurrence + 1);
return `${signature}|${occurrence}`;
});
}
function isToolLikeType(type: AgentLogEntry["type"]): boolean {
return type === "tool" || type === "tool_result" || type === "tool_error";
}
interface CollapsibleToolDetailProps {
detail: string;
type?: "tool" | "tool_result" | "tool_error";
}
function CollapsibleToolDetail({ detail }: CollapsibleToolDetailProps): ReactElement {
const { t } = useTranslation("app");
const [expanded, setExpanded] = useState(false);
const contentId = useId();
const lineCount = detail.split("\n").length;
const toggleLabel = expanded
? t("agentLog.hideOutput", "Hide output")
: t("agentLog.showOutput", `Show output${lineCount > 1 ? ` (${lineCount} lines)` : ""}`);
return (
<div className="agent-log-tool-detail-wrapper">
<button
type="button"
className="agent-log-tool-detail-toggle"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={contentId}
data-testid="tool-detail-toggle"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{toggleLabel}</span>
</button>
<div
id={contentId}
className={expanded ? "agent-log-tool-detail-content" : "agent-log-tool-detail-content agent-log-tool-detail-content--collapsed"}
data-testid="tool-detail-content"
>
<pre className="agent-log-tool-detail">{linkifyFilePaths(detail)}</pre>
</div>
</div>
);
}
function shouldShowBadge(entry: AgentLogEntry, previousEntry?: AgentLogEntry): boolean {
if (!entry.agent) return false;
if (isToolLikeType(entry.type)) return true;
return !previousEntry || previousEntry.agent !== entry.agent || previousEntry.type !== entry.type;
}
interface RenderEntry {
entry: AgentLogEntry;
hiddenToolBoundaryId: number;
}
type AgentLogRenderGroup =
| {
kind: "single";
entry: AgentLogEntry;
key: string;
showBadge: boolean;
}
| {
kind: "text" | "thinking";
entries: AgentLogEntry[];
key: string;
showBadge: boolean;
};
function buildRenderGroups(renderEntries: RenderEntry[], entryKeys: string[]): AgentLogRenderGroup[] {
const groups: AgentLogRenderGroup[] = [];
for (let i = 0; i < renderEntries.length; i += 1) {
const { entry, hiddenToolBoundaryId } = renderEntries[i];
const rowKey = entryKeys[i] ?? `${getEntrySignature(entry)}|fallback`;
const previousRenderEntry = i > 0 ? renderEntries[i - 1] : undefined;
const previousEntry = previousRenderEntry?.entry;
const showBadge = shouldShowBadge(entry, previousEntry)
|| (previousRenderEntry !== undefined && previousRenderEntry.hiddenToolBoundaryId !== hiddenToolBoundaryId);
if (entry.type === "text" || entry.type === "thinking") {
const groupedEntries: AgentLogEntry[] = [entry];
let j = i + 1;
while (j < renderEntries.length) {
const next = renderEntries[j];
const nextEntry = next.entry;
if (
nextEntry.type !== entry.type
|| nextEntry.agent !== entry.agent
|| next.hiddenToolBoundaryId !== hiddenToolBoundaryId
) {
break;
}
groupedEntries.push(nextEntry);
j += 1;
}
const endKey = entryKeys[j - 1] ?? `${getEntrySignature(renderEntries[j - 1].entry)}|fallback`;
groups.push({
kind: entry.type,
entries: groupedEntries,
key: `${rowKey}->${endKey}`,
showBadge,
});
i = j - 1;
continue;
}
groups.push({
kind: "single",
entry,
key: rowKey,
showBadge,
});
}
return groups;
}
interface ModelInfo {
provider?: string;
modelId?: string;
}
interface AgentLogViewerProps {
entries: AgentLogEntry[];
loading: boolean;
executorModel?: ModelInfo | null;
validatorModel?: ModelInfo | null;
planningModel?: ModelInfo | null;
/** Whether more entries exist beyond what's currently loaded */
hasMore?: boolean;
/** Callback to load older entries */
onLoadMore?: () => void;
/** Whether a load more request is in progress */
loadingMore?: boolean;
/** Total number of entries (when known) for "Showing X of Y" summary */
totalCount?: number | null;
}
/**
* Renders agent log entries in a scrollable, monospace container.
*
* Features:
* - Displays entries in chronological order (oldest first, newest last)
* - Coalesces consecutive same-agent `text`/`thinking` chunks into continuous groups
* - Auto-scrolls to keep latest entries visible when streaming
* - Supports toggling between markdown-formatted and plain-text rendering
* - "Load More" button to fetch older entries when pagination is enabled
* - Shows "Showing X of Y entries" summary when totalCount is provided
*
* @param entries - Array of log entries (in chronological order, oldest first)
* @param loading - Whether initial load is in progress
* @param hasMore - Whether more older entries exist beyond the current page
* @param onLoadMore - Callback to load older entries
* @param loadingMore - Whether a load more request is in progress
* @param totalCount - Total number of entries (when known) for summary display
*/
export function AgentLogViewer({
entries,
loading,
executorModel,
validatorModel,
planningModel,
hasMore = false,
onLoadMore,
loadingMore = false,
totalCount = null,
}: AgentLogViewerProps) {
const { t } = useTranslation("app");
const containerRef = useRef<HTMLDivElement>(null);
const previousEntryCountRef = useRef<number>(0);
const previousScrollHeightRef = useRef<number>(0);
const previousOldestEntryKeyRef = useRef<string | null>(null);
const previousNewestEntryKeyRef = useRef<string | null>(null);
const [renderMarkdown, setRenderMarkdown] = useState(() =>
readBooleanPref(MARKDOWN_TOGGLE_STORAGE_KEY, true),
);
const [showToolOutput, setShowToolOutput] = useState(() =>
readBooleanPref(TOOL_OUTPUT_TOGGLE_STORAGE_KEY, true),
);
const [isFullscreen, setIsFullscreen] = useState(false);
const [modelHeaderExpanded, setModelHeaderExpanded] = useState(false);
const [isFollowing, setIsFollowing] = useState(true);
const isFollowingRef = useRef(true);
const setFollowing = useCallback((following: boolean) => {
isFollowingRef.current = following;
setIsFollowing(following);
}, []);
useEffect(() => {
writeBooleanPref(MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown);
}, [renderMarkdown]);
useEffect(() => {
writeBooleanPref(TOOL_OUTPUT_TOGGLE_STORAGE_KEY, showToolOutput);
}, [showToolOutput]);
const renderEntries = useMemo(() => {
if (showToolOutput) {
return entries.map((entry) => ({ entry, hiddenToolBoundaryId: 0 }));
}
const filtered: RenderEntry[] = [];
let hiddenToolBoundaryId = 0;
for (const entry of entries) {
if (isToolLikeType(entry.type)) {
hiddenToolBoundaryId += 1;
continue;
}
filtered.push({ entry, hiddenToolBoundaryId });
}
return filtered;
}, [entries, showToolOutput]);
const visibleEntries = useMemo(
() => renderEntries.map((renderEntry) => renderEntry.entry),
[renderEntries],
);
const chronologicalEntryKeys = useMemo(
() => buildEntryRenderKeys(visibleEntries),
[visibleEntries],
);
const renderGroups = useMemo(
() => buildRenderGroups(renderEntries, chronologicalEntryKeys),
[renderEntries, chronologicalEntryKeys],
);
/*
FNXC:AgentLog 2026-07-18-14:09:
FN-8339 makes log following an explicit pinned-bottom contract: append and in-place stream growth may move the viewport only while it is pinned. Observe both viewport layout and DOM growth, and keep the current value in a ref because observer callbacks can run before React commits the scroll-state render; a real scroll-away must synchronously unsnap those callbacks.
*/
// Keep live-follow pinned to the bottom when new streamed entries append.
// When older history is prepended (load more), preserve viewport position.
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const newEntryCount = entries.length;
const previousCount = previousEntryCountRef.current;
const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight;
const oldestEntryKey = chronologicalEntryKeys[0] ?? null;
const newestEntryKey = chronologicalEntryKeys[chronologicalEntryKeys.length - 1] ?? null;
const oldestEntryChanged = previousOldestEntryKeyRef.current !== oldestEntryKey;
const newestEntryChanged = previousNewestEntryKeyRef.current !== newestEntryKey;
if (newEntryCount > previousCount) {
if (previousCount === 0) {
container.scrollTop = container.scrollHeight;
} else {
const wasNearBottom = isFollowingRef.current;
const appendedLiveEntry = newestEntryChanged && !oldestEntryChanged;
const prependedOlderEntries = oldestEntryChanged && !newestEntryChanged;
if (appendedLiveEntry && wasNearBottom) {
container.scrollTop = container.scrollHeight;
}
if (prependedOlderEntries) {
const heightDelta = container.scrollHeight - previousScrollHeight;
if (heightDelta > 0) {
container.scrollTop += heightDelta;
}
}
}
}
if (newEntryCount !== previousCount) {
setFollowing(isNearBottom(container));
}
previousEntryCountRef.current = newEntryCount;
previousScrollHeightRef.current = container.scrollHeight;
previousOldestEntryKeyRef.current = oldestEntryKey;
previousNewestEntryKeyRef.current = newestEntryKey;
}, [entries, chronologicalEntryKeys, setFollowing]);
const handleScroll = useCallback(() => {
const container = containerRef.current;
if (!container) return;
setFollowing(isNearBottom(container));
}, [setFollowing]);
const scrollToLive = useCallback(() => {
const container = containerRef.current;
if (!container) return;
container.scrollTop = container.scrollHeight;
setFollowing(true);
}, [setFollowing]);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const followTail = () => {
if (!isFollowingRef.current) {
return;
}
container.scrollTop = container.scrollHeight;
};
const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(followTail);
resizeObserver?.observe(container);
const mutationObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(followTail);
mutationObserver?.observe(container, { childList: true, characterData: true, subtree: true });
return () => {
resizeObserver?.disconnect();
mutationObserver?.disconnect();
};
}, []);
// Escape key handler to exit fullscreen mode
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) {
setIsFullscreen(false);
}
}, [isFullscreen]);
useEffect(() => {
if (isFullscreen) {
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}
}, [isFullscreen, handleKeyDown]);
const hasExecutorOverride = executorModel?.provider && executorModel?.modelId;
const hasValidatorOverride = validatorModel?.provider && validatorModel?.modelId;
const hasPlanningOverride = planningModel?.provider && planningModel?.modelId;
const fullscreenToggle = (
<button
className="agent-log-mode-toggle"
onClick={() => setIsFullscreen((prev) => !prev)}
aria-label={isFullscreen ? t("agentLog.exitFullscreen", "Exit full screen") : t("agentLog.expandFullscreen", "Expand agent log to full screen")}
data-testid="agent-log-fullscreen-toggle"
title={isFullscreen ? t("agentLog.exitFullscreen", "Exit full screen") : t("agentLog.expandFullscreen", "Expand agent log to full screen")}
>
{isFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
);
const modelProviders = useMemo(() => {
const providers: Array<{ role: string; provider: string; modelId?: string }> = [];
if (hasExecutorOverride) {
providers.push({
role: "Executor",
provider: executorModel!.provider!,
modelId: executorModel!.modelId,
});
}
if (hasValidatorOverride) {
providers.push({
role: "Reviewer",
provider: validatorModel!.provider!,
modelId: validatorModel!.modelId,
});
}
if (hasPlanningOverride) {
providers.push({
role: "Planning",
provider: planningModel!.provider!,
modelId: planningModel!.modelId,
});
}
return providers;
}, [
hasExecutorOverride,
executorModel,
hasValidatorOverride,
validatorModel,
hasPlanningOverride,
planningModel,
]);
if (loading && entries.length === 0) {
return (
<div className={`agent-log-viewer${isFullscreen ? " agent-log-viewer--fullscreen" : ""}`} data-testid="agent-log-viewer">
{/* FNXC:TaskDetailActivity 2026-07-01-00:00: Activity → Raw owns one fullscreen affordance through AgentLogViewer even while logs are loading, because TaskDetailModal intentionally omits its Activity-level expand button on Raw to avoid duplicate controls. */}
<div className="agent-log-empty-header">
<div className="agent-log-model-header-toggle">{fullscreenToggle}</div>
</div>
<div className="agent-log-loading" role="status" aria-live="polite">{t("agentLog.loading", "Loading agent logs…")}</div>
</div>
);
}
if (entries.length === 0) {
return (
<div className={`agent-log-viewer${isFullscreen ? " agent-log-viewer--fullscreen" : ""}`} data-testid="agent-log-viewer">
{/* FNXC:TaskDetailActivity 2026-07-01-00:00: Empty Raw logs still expose the single AgentLogViewer fullscreen button so Raw never needs the duplicate Activity expand toggle. */}
<div className="agent-log-empty-header">
<div className="agent-log-model-header-toggle">{fullscreenToggle}</div>
</div>
<div className="agent-log-empty">{t("agentLog.empty", "No agent output yet.")}</div>
</div>
);
}
return (
<div
className={`agent-log-viewer agent-log-viewer--streaming${isFullscreen ? " agent-log-viewer--fullscreen" : ""}`}
data-testid="agent-log-viewer"
>
{/* Model info header */}
<div className="agent-log-model-header" data-testid="agent-log-model-header">
<div className="agent-log-model-icons">
{modelProviders.map((modelProvider) => (
<ProviderIcon
key={`${modelProvider.role}-${modelProvider.provider}-${modelProvider.modelId ?? "default"}`}
provider={modelProvider.provider}
size="sm"
/>
))}
<button
className="agent-log-model-expand-btn"
onClick={() => setModelHeaderExpanded((prev) => !prev)}
aria-label={modelHeaderExpanded ? t("agentLog.collapseModelDetails", "Collapse model details") : t("agentLog.expandModelDetails", "Expand model details")}
aria-expanded={modelHeaderExpanded}
aria-controls="agent-log-model-details"
data-testid="agent-log-model-expand"
>
{modelHeaderExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
</div>
{/* Markdown render toggle */}
<div className="agent-log-model-header-toggle">
<button
className="agent-log-mode-toggle"
onClick={() => setRenderMarkdown((prev) => !prev)}
aria-label={renderMarkdown ? t("agentLog.switchPlainText", "Switch to plain text mode") : t("agentLog.switchMarkdown", "Switch to markdown mode")}
aria-pressed={renderMarkdown}
data-testid="agent-log-mode-toggle"
title={renderMarkdown ? t("agentLog.showRawText", "Show raw text") : t("agentLog.showFormattedMarkdown", "Show formatted markdown")}
>
{renderMarkdown ? t("agentLog.markdown", "Markdown") : t("agentLog.plain", "Plain")}
</button>
<button
className="agent-log-mode-toggle"
onClick={() => setShowToolOutput((prev) => !prev)}
aria-label={showToolOutput ? t("agentLog.hideToolOutput", "Hide tool output") : t("agentLog.showToolOutput", "Show tool output")}
aria-pressed={showToolOutput}
data-testid="agent-log-tool-output-toggle"
title={showToolOutput ? t("agentLog.hideToolCallsResults", "Hide tool calls and results") : t("agentLog.showToolCallsResults", "Show tool calls and results")}
>
{showToolOutput ? t("agentLog.toolsOn", "Tools: On") : t("agentLog.toolsOff", "Tools: Off")}
</button>
{fullscreenToggle}
</div>
{modelHeaderExpanded && (
<div id="agent-log-model-details" className="agent-log-model-details">
<div className="agent-log-model-group">
<span className="agent-log-model-label">{t("agentLog.executor", "Executor")}:</span>
{hasExecutorOverride ? (
<span className="agent-log-model-value">
<ProviderIcon provider={executorModel.provider!} size="sm" />
<span>{executorModel.provider}/{executorModel.modelId}</span>
</span>
) : (
<span className="model-badge-default">{t("agentLog.usingDefault", "Using default")}</span>
)}
</div>
<div className="agent-log-model-group">
<span className="agent-log-model-label">{t("agentLog.reviewer", "Reviewer")}:</span>
{hasValidatorOverride ? (
<span className="agent-log-model-value">
<ProviderIcon provider={validatorModel.provider!} size="sm" />
<span>{validatorModel.provider}/{validatorModel.modelId}</span>
</span>
) : (
<span className="model-badge-default">{t("agentLog.usingDefault", "Using default")}</span>
)}
</div>
<div className="agent-log-model-group">
<span className="agent-log-model-label">{t("agentLog.planning", "Planning")}:</span>
{hasPlanningOverride ? (
<span className="agent-log-model-value">
<ProviderIcon provider={planningModel.provider!} size="sm" />
<span>{planningModel.provider}/{planningModel.modelId}</span>
</span>
) : (
<span className="model-badge-default">{t("agentLog.usingDefault", "Using default")}</span>
)}
</div>
</div>
)}
</div>
<div
ref={containerRef}
className="agent-log-viewer-scroll"
onScroll={handleScroll}
>
{/* Pagination summary */}
{totalCount !== null && (
<div className="agent-log-summary" data-testid="agent-log-summary">
{t("agentLog.showing", "Showing {{visible}} of {{total}} entries", { visible: visibleEntries.length, total: totalCount })}
{!showToolOutput && entries.length !== visibleEntries.length
? ` (${t("agentLog.toolEntriesHidden", "{{count}} tool entries hidden", { count: entries.length - visibleEntries.length })})`
: ""}
</div>
)}
{hasMore && onLoadMore && (
<div className="agent-log-load-more" data-testid="agent-log-load-more">
<button
className="agent-log-mode-toggle"
onClick={onLoadMore}
disabled={loadingMore}
data-testid="agent-log-load-more-button"
>
{loadingMore ? (
<>
<Loader2 size={14} className="animate-spin" />
{t("agentLog.loadingMore", "Loading…")}
</>
) : (
t("agentLog.loadMore", "Load More")
)}
</button>
</div>
)}
{renderGroups.map((group) => {
const firstEntry = group.kind === "single" ? group.entry : group.entries[0];
const timestampSpan = group.showBadge ? (
<span className="agent-log-timestamp" data-testid="agent-log-timestamp">
{formatTimestamp(firstEntry.timestamp, t as TFunction<"app">)}
</span>
) : null;
const agentBadge = group.showBadge ? (
<span className="agent-log-badge-row">
<span className="agent-log-agent-badge">[{getAgentDisplayName(firstEntry.agent!, t as TFunction<"app">)}]</span>
{timestampSpan}
</span>
) : null;
if (group.kind === "single") {
const { entry } = group;
if (entry.type === "tool") {
return (
<div key={group.key} className="agent-log-tool">
{agentBadge}
<div className="agent-log-tool-title">⚡ {entry.text}<AgentLogTimingLabels entry={entry} /></div>
{entry.detail ? <CollapsibleToolDetail detail={entry.detail} type="tool" /> : null}
</div>
);
}
if (entry.type === "tool_result") {
return (
<div key={group.key} className="agent-log-tool-result">
{agentBadge}
<div className="agent-log-tool-title">✓ {entry.text}<AgentLogTimingLabels entry={entry} /></div>
{entry.detail ? <CollapsibleToolDetail detail={entry.detail} type="tool_result" /> : null}
</div>
);
}
if (entry.type === "tool_error") {
return (
<div key={group.key} className="agent-log-tool-error">
{agentBadge}
<div className="agent-log-tool-title">✗ {entry.text}<AgentLogTimingLabels entry={entry} /></div>
{entry.detail ? <CollapsibleToolDetail detail={entry.detail} type="tool_error" /> : null}
</div>
);
}
}
const groupedText = group.kind === "single"
? firstEntry.text
: group.entries.map((entry) => entry.text).join("");
if (group.kind === "thinking") {
return (
<div key={group.key} className="agent-log-thinking">
{agentBadge}
<AgentLogTimingLabels entry={firstEntry} />
{renderMarkdown ? (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{groupedText}
</ReactMarkdown>
</div>
) : (
<pre className="agent-log-plain-block">{linkifyFilePaths(groupedText)}</pre>
)}
</div>
);
}
return (
<div key={group.key} className="agent-log-text">
{agentBadge}
<AgentLogTimingLabels entry={firstEntry} />
{renderMarkdown ? (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{groupedText}
</ReactMarkdown>
</div>
) : (
<pre className="agent-log-plain-block">{linkifyFilePaths(groupedText)}</pre>
)}
</div>
);
})}
{!isFollowing && (
<button
type="button"
className="agent-log-return-to-live"
onClick={scrollToLive}
data-testid="agent-log-return-to-live"
>
<ChevronDown size={12} />
<span>{t("agentLog.live", "Live")}</span>
</button>
)}
</div>
</div>
);
}