murashit_codex-panel/src/features/chat/ui/work-items.tsx
2026-05-31 12:50:34 +09:00

235 lines
9.4 KiB
TypeScript

import { useLayoutEffect, useState, type ReactNode } from "preact/compat";
import { activeAgentRunSummary } from "../display/agent";
import { executionState } from "../display/state";
import type {
AgentDisplayItem,
AgentRunSummary,
AgentRunSummaryAgent,
DisplayItem,
TaskProgressDisplayItem,
ToolDisplayItem,
} from "../display/types";
import { agentActivityMetaLabel, agentMessagePreview, agentRunSummaryLabel, taskStatusMarker } from "../display/labels";
import { activeTurnId, type ChatTurnLifecycleState } from "../chat-state";
import { createWorkMessageClassName } from "./work-message";
import { shortThreadId, truncate } from "../../../utils";
const AGENT_ROW_MESSAGE_PREVIEW_LIMIT = 120;
const AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT = 96;
type ReasoningDisplayItem = ToolDisplayItem & { kind: "reasoning" };
export type WorkItemDisplayItem = TaskProgressDisplayItem | AgentDisplayItem | ReasoningDisplayItem;
export interface WorkItemContext {
turnLifecycle: ChatTurnLifecycleState;
displayItems: readonly DisplayItem[];
openDetails: ReadonlySet<string>;
onDetailsToggle?: (key: string, open: boolean) => void;
}
export function workItemsActiveTurnId(context: Pick<WorkItemContext, "turnLifecycle">): string | null {
return activeTurnId(context);
}
export function activeAgentRunSummaryBlock(context: WorkItemContext): AgentRunSummary | null {
return activeAgentRunSummary(context.displayItems, workItemsActiveTurnId(context));
}
export function agentRunSummaryNode(summary: AgentRunSummary): ReactNode {
return <AgentRunSummaryItem summary={summary} />;
}
export function workItemNode(item: WorkItemDisplayItem, context: WorkItemContext): ReactNode {
if (item.kind === "taskProgress") return <TaskProgressItem item={item} />;
if (item.kind === "agent") return <AgentItem item={item} context={context} />;
return <ReasoningItem item={item} context={context} />;
}
function AgentRunSummaryItem({ summary }: { summary: AgentRunSummary }): ReactNode {
return (
<WorkMessage label="agents" className="codex-panel__agent-summary" state={summary.failed > 0 ? "failed" : "running"}>
<div className="codex-panel__tool-summary">{agentRunSummaryLabel(summary)}</div>
<AgentSummaryRows summary={summary} />
</WorkMessage>
);
}
function TaskProgressItem({ item }: { item: TaskProgressDisplayItem }): ReactNode {
return (
<WorkMessage label="tasks" className="codex-panel__task-progress" state={executionState(item)}>
{item.explanation ? <div className="codex-panel__tool-summary">{item.explanation}</div> : null}
{item.steps.length === 0 ? (
<div className="codex-panel__tool-summary">Plan updated</div>
) : (
<ul className="codex-panel__task-list">
{item.steps.map((step) => (
<li key={`${step.status}\n${step.step}`} className={`codex-panel__task-step codex-panel__task-step--${step.status}`}>
<span className="codex-panel__task-marker">{taskStatusMarker(step.status)}</span>
<span className="codex-panel__task-text">{step.step}</span>
</li>
))}
</ul>
)}
</WorkMessage>
);
}
function AgentItem({ item, context }: { item: AgentDisplayItem; context: WorkItemContext }): ReactNode {
const detailsKey = `${item.id}:agent-details`;
const [detailsOpen, setDetailsOpen] = useState(context.openDetails.has(detailsKey));
useLayoutEffect(() => {
setDetailsOpen(context.openDetails.has(detailsKey));
}, [context.openDetails, detailsKey]);
return (
<WorkMessage label="agent" className={`codex-panel__agent-activity${detailsOpen ? " is-open" : ""}`} state={executionState(item)}>
<div className="codex-panel__tool-summary codex-panel__agent-activity-summary">{agentSummaryText(item)}</div>
<details
className="codex-panel__output codex-panel__agent-details"
open={detailsOpen}
onToggle={(event) => {
const nextOpen = event.currentTarget.open;
setDetailsOpen(nextOpen);
context.onDetailsToggle?.(detailsKey, nextOpen);
}}
>
<summary>Details</summary>
<dl className="codex-panel__meta-grid">
<MetaPair name="tool" value={agentActivityMetaLabel(item.tool)} />
<MetaPair name="status" value={item.status} />
<MetaPair name="sender" value={item.senderThreadId} />
{item.receiverThreadIds.length > 0 ? <MetaPair name="target" value={item.receiverThreadIds.join(", ")} /> : null}
{item.model ? <MetaPair name="model" value={item.model} /> : null}
{item.reasoningEffort ? <MetaPair name="effort" value={item.reasoningEffort} /> : null}
</dl>
{item.prompt ? (
<section className="codex-panel__agent-detail-section">
<div className="codex-panel__output-title">Prompt</div>
<pre>{item.prompt}</pre>
</section>
) : null}
{item.agents.length > 0 ? (
<ul className="codex-panel__agent-list">
{item.agents.map((agent) => (
<li key={agent.threadId} className="codex-panel__agent-row">
<span className="codex-panel__agent-thread">{shortThreadId(agent.threadId)}</span>
<span className="codex-panel__agent-status">{agentStatusLabel(agent.status, agent.message)}</span>
</li>
))}
</ul>
) : null}
{item.agents.map((agent) =>
agent.message && isLongAgentMessage(agent.message) ? (
<section key={agent.threadId} className="codex-panel__agent-detail-section">
<div className="codex-panel__output-title">Agent output {shortThreadId(agent.threadId)}</div>
<pre>{agent.message}</pre>
</section>
) : null,
)}
</details>
</WorkMessage>
);
}
function ReasoningItem({ item, context }: { item: ReasoningDisplayItem; context: WorkItemContext }): ReactNode {
const active = isReasoningActive(item, context);
return (
<div className={`codex-panel__reasoning${active ? " is-active" : ""}`}>
<div className="codex-panel__reasoning-role">{active ? "reasoning" : "thought"}</div>
<div className="codex-panel__reasoning-content">
<span>{item.text || (active ? "Reasoning" : "Thought")}</span>
{active ? (
<span className="codex-panel__reasoning-dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
) : null}
</div>
</div>
);
}
function WorkMessage({
label,
className,
state,
children,
}: {
label: string;
className: string;
state: ReturnType<typeof executionState>;
children: ReactNode;
}): ReactNode {
const classes = [createWorkMessageClassName(className), state ? `codex-panel__execution codex-panel__execution--${state}` : ""]
.filter(Boolean)
.join(" ");
return (
<div className={classes}>
<div className="codex-panel__message-role">{label}</div>
{children}
</div>
);
}
function MetaPair({ name, value }: { name: string; value: string }): ReactNode {
return (
<>
<dt>{name}</dt>
<dd>{value}</dd>
</>
);
}
function AgentSummaryRows({ summary }: { summary: AgentRunSummary }): ReactNode {
if (summary.agents.length === 0 && summary.additionalAgents === 0) return null;
return (
<ul className="codex-panel__agent-list codex-panel__agent-list--summary">
{summary.agents.map((agent) => (
<li key={agent.threadId} className="codex-panel__agent-row">
<span className="codex-panel__agent-thread">{shortThreadId(agent.threadId)}</span>
<span className="codex-panel__agent-status">{agentSummaryStatusLabel(agent)}</span>
</li>
))}
{summary.additionalAgents > 0 ? (
<li className="codex-panel__agent-row codex-panel__agent-row--more">
<span className="codex-panel__agent-thread" />
<span className="codex-panel__agent-status">+{String(summary.additionalAgents)} more</span>
</li>
) : null}
</ul>
);
}
function agentSummaryText(item: AgentDisplayItem): string {
const target = item.receiverThreadIds.length === 0 ? "" : ` ${item.receiverThreadIds.map(shortThreadId).join(", ")}`;
const promptPreview = agentPromptPreview(item.prompt);
return `${agentActivityMetaLabel(item.tool)}${target}${promptPreview ? `: ${promptPreview}` : ""} (${item.status})`;
}
function agentPromptPreview(prompt: string | null): string | null {
if (!prompt) return null;
const normalized = prompt.trim().replace(/\s+/g, " ");
return normalized ? truncate(normalized, AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT) : null;
}
function agentStatusLabel(status: string, message: string | null): string {
const preview = agentMessagePreview(message, AGENT_ROW_MESSAGE_PREVIEW_LIMIT);
return preview ? `${status}: ${preview}` : status;
}
function agentSummaryStatusLabel(agent: AgentRunSummaryAgent): string {
return agent.messagePreview ? `${agent.status}: ${agent.messagePreview}` : agent.status;
}
function isLongAgentMessage(message: string): boolean {
return message.length > AGENT_ROW_MESSAGE_PREVIEW_LIMIT || message.includes("\n");
}
function isReasoningActive(item: ReasoningDisplayItem, context: WorkItemContext): boolean {
const activeTurn = workItemsActiveTurnId(context);
if (!activeTurn || item.turnId !== activeTurn) return false;
if (executionState(item) === "completed") return false;
const latestActiveTurnItem = [...context.displayItems].reverse().find((candidate) => candidate.turnId === activeTurn);
return latestActiveTurnItem?.id === item.id;
}