Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import type { TaskActivityRecord } from "@/lib/api";

import { formatOperatorActivityText } from "./issue-activity-display-utils";

export function createActivityCopyText(activity: TaskActivityRecord): string {
const lines = [`${activity.actorId} ${activity.title}`];
if (activity.status?.trim()) {
lines.push(`Status: ${activity.status}`);
}
const body = activity.body.trim();
const body = formatOperatorActivityText(activity.body);
if (body) {
lines.push("", body);
}
const stepLines = activity.steps?.flatMap((step) => {
const summary = `${step.stepNumber}. ${step.action} [${step.status}]`;
const detail = step.detail?.trim();
const detail = step.detail ? formatOperatorActivityText(step.detail) : "";
return detail ? [summary, ` ${detail}`] : [summary];
});
if (stepLines?.length) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
const LABELED_SAFE_FIELDS = ["result", "thinking", "planning"] as const;
const UNLABELED_SAFE_FIELDS = ["text", "message", "detail", "summary"] as const;
const MAX_FORMAT_DEPTH = 2;
const RAW_JSON_FIELD_LINE =
/^"?(?:command|payload|detail|arguments|args|input|parameters|recipient_name|tool_name|toolName|schema)"?\s*:/;

export function formatOperatorActivityText(rawText: string): string {
return formatText(rawText, 0);
}

function formatText(rawText: string, depth: number): string {
const trimmed = rawText.trim();
if (!trimmed) return "";
const structuredText = formatStructuredValue(parseJsonValue(trimmed), depth);
if (structuredText !== null) return structuredText;
return formatMixedLines(trimmed.split(/\r?\n/), depth);
}

function formatMixedLines(lines: string[], depth: number): string {
const output: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const block = readJsonBlock(lines, index);
if (block) {
const text = formatJsonBlock(block.text, depth);
if (text) output.push(text);
index = block.endIndex;
continue;
}
const text = formatLine(lines[index] ?? "", depth);
if (text) output.push(text);
}
return output.join("\n");
}

function readJsonBlock(
lines: string[],
startIndex: number,
): { text: string; endIndex: number } | null {
const firstLine = lines[startIndex]?.trim() ?? "";
if (!firstLine.startsWith("{") && !firstLine.startsWith("[")) return null;
const blockLines: string[] = [];
for (let index = startIndex; index < lines.length; index += 1) {
const line = lines[index] ?? "";
blockLines.push(line);
if (parseJsonValue(blockLines.join("\n").trim()) !== null) {
return { text: blockLines.join("\n"), endIndex: index };
}
}
return null;
}

function formatJsonBlock(text: string, depth: number): string {
const value = parseJsonValue(text.trim());
const structuredText = formatStructuredValue(value, depth);
if (structuredText !== null) return structuredText;
return hasRawJsonDumpField(text) ? "" : text.trim();
}

function formatLine(line: string, depth: number): string {
const trimmed = line.trim();
if (!trimmed || isRawJsonDumpLine(trimmed)) return "";
const structuredText = formatStructuredValue(parseJsonValue(trimmed), depth);
return structuredText ?? trimmed;
}

function formatStructuredValue(value: unknown, depth: number): string | null {
if (value === null) return null;
if (Array.isArray(value)) {
const lines = value
.map((item) => formatStructuredValue(item, depth))
.filter((line): line is string => Boolean(line));
return lines.length > 0 ? lines.join("\n") : "";
}
if (!isRecord(value)) return null;
const lines = [
...LABELED_SAFE_FIELDS.flatMap((field) =>
formatSafeField(value, field, depth, labelForField(field)),
),
...UNLABELED_SAFE_FIELDS.flatMap((field) =>
formatSafeField(value, field, depth),
),
];
const item = value.item;
if (isRecord(item)) {
const itemText = formatStructuredValue(item, depth);
if (itemText) lines.push(itemText);
}
return uniqueLines(lines).join("\n");
}

function formatSafeField(
record: Record<string, unknown>,
field: string,
depth: number,
label?: string,
): string[] {
const value = record[field];
if (typeof value !== "string") return [];
const text =
depth >= MAX_FORMAT_DEPTH ? value.trim() : formatText(value, depth + 1);
if (!text) return [];
return [label ? `${label}: ${text}` : text];
}

function parseJsonValue(text: string): unknown {
if (!looksLikeJson(text)) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}

function looksLikeJson(text: string): boolean {
return (
(text.startsWith("{") && text.endsWith("}")) ||
(text.startsWith("[") && text.endsWith("]"))
);
}

function isRawJsonDumpLine(text: string): boolean {
return (
text === "{" ||
text === "}" ||
text === "[" ||
text === "]" ||
text === "}," ||
text === "]," ||
RAW_JSON_FIELD_LINE.test(text)
);
}

function hasRawJsonDumpField(text: string): boolean {
return text
.split(/\r?\n/)
.some((line) => RAW_JSON_FIELD_LINE.test(line.trim()));
}

function labelForField(field: string): string {
return field.charAt(0).toUpperCase() + field.slice(1);
}

function uniqueLines(lines: string[]): string[] {
const seen = new Set<string>();
return lines.filter((line) => {
if (seen.has(line)) return false;
seen.add(line);
return true;
});
}

function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
20 changes: 15 additions & 5 deletions packages/web/src/components/issues-board/issue-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { isApiRequestError } from "@/lib/api";
import { useTaskActivityQuery } from "@/lib/api/task-activity-query";

import { IssueActivityCardMenu } from "./issue-activity-card-menu";
import { formatOperatorActivityText } from "./issue-activity-display-utils";
import { ActivityRichText } from "./issue-activity-rich-text";
import {
createActivityDisclosureState,
Expand Down Expand Up @@ -96,7 +97,8 @@ function ActivityItem({
}: {
activity: TaskActivityRecord;
}): ReactElement {
const hasCard = Boolean(activity.body.trim()) || activity.steps?.length;
const body = formatOperatorActivityText(activity.body);
const hasCard = Boolean(body) || activity.steps?.length;
if (hasCard) {
return <ActivityCard activity={activity} />;
}
Expand All @@ -121,6 +123,7 @@ function ActivityCard({
}: {
activity: TaskActivityRecord;
}): ReactElement {
const body = formatOperatorActivityText(activity.body);
return (
<article className="rounded-lg border border-border bg-card p-5">
<header className="mb-5 flex items-center justify-between gap-3">
Expand All @@ -140,7 +143,7 @@ function ActivityCard({
</div>
<IssueActivityCardMenu activity={activity} />
</header>
{activity.body.trim() ? <ActivityRichText body={activity.body} /> : null}
{body ? <ActivityRichText body={body} /> : null}
{activity.steps?.length ? <ActivitySteps activity={activity} /> : null}
</article>
);
Expand All @@ -164,15 +167,22 @@ function ActivitySteps({
</Typography>
<Typography variant="muted">{step.status}</Typography>
</div>
{step.detail ? (
<Typography variant="description">{step.detail}</Typography>
) : null}
{step.detail ? <ActivityStepDetail detail={step.detail} /> : null}
</div>
))}
</div>
);
}

function ActivityStepDetail({
detail,
}: {
detail: string;
}): ReactElement | null {
const text = formatOperatorActivityText(detail);
return text ? <Typography variant="description">{text}</Typography> : null;
}

function ActivityIcon({
activity,
isLarge = false,
Expand Down
7 changes: 5 additions & 2 deletions packages/web/src/components/issues-board/issue-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Typography } from "@/components/ui/typography";
import type { ProjectBoardTaskRecord } from "@/lib/api";
import { cn } from "@/lib/utils";

import { formatOperatorActivityText } from "./issue-activity-display-utils";
import { getPriorityLabel, isAgentTask } from "./issues-board-utils";

interface IssueCardProps {
Expand Down Expand Up @@ -98,6 +99,8 @@ export function IssueCard({
onOpenContextMenu(task, { x: event.clientX, y: event.clientY });
}

const content = formatOperatorActivityText(task.content);

return (
<button
className={cn(
Expand Down Expand Up @@ -129,12 +132,12 @@ export function IssueCard({
<Typography className="line-clamp-2" variant="cardTitle">
{task.title}
</Typography>
{task.content.trim() ? (
{content ? (
<Typography
className="mb-2 mt-1.5 line-clamp-2 leading-5"
variant="muted"
>
{task.content}
{content}
</Typography>
) : null}
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-400">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Typography } from "@/components/ui/typography";
import type { ProjectBoardTaskRecord, TokenUsageRecord } from "@/lib/api";
import { useBoardTaskQuery, useTokenUsageQuery } from "@/lib/api/queries";

import { formatOperatorActivityText } from "./issue-activity-display-utils";
import { ExternalLinkValue, formatDateTime } from "./issue-detail-editor-utils";
import {
MetricRow,
Expand Down Expand Up @@ -96,15 +97,16 @@ function renderPanelContent(
}

function PanelHeader({ task }: TaskPanelProps): ReactElement {
const content = formatOperatorActivityText(task.content);
return (
<SheetHeader className="pr-8">
<Typography variant="eyebrow">{task.taskKey}</Typography>
<SheetTitle className="break-words text-xl leading-7">
{task.title}
</SheetTitle>
{task.content.trim() ? (
{content ? (
<Typography className="whitespace-pre-wrap leading-6 text-zinc-400">
{task.content}
{content}
</Typography>
) : null}
</SheetHeader>
Expand Down
54 changes: 54 additions & 0 deletions packages/web/tests/issue-activity-copy-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,60 @@ describe("issue activity copy utilities", () => {
].join("\n"),
);
});

it("copies only readable activity text from structured command output", () => {
const copyText = createActivityCopyText(
activity({
body: JSON.stringify({
command: "codex exec --prompt secret",
payload: { prompt: "internal prompt" },
result: "The task is complete.",
thinking: "Keep the operator summary concise.",
}),
steps: [
{
id: "step-1",
stepNumber: 1,
action: "implementation",
status: "success",
detail: JSON.stringify({
command: "bun test --filter secret",
payload: { argv: ["bun", "test"] },
}),
recordedAt: "2026-05-13T00:02:30.000Z",
},
{
id: "step-2",
stepNumber: 2,
action: "review",
status: "success",
detail: JSON.stringify({
planning: "Follow-up is not needed.",
}),
recordedAt: "2026-05-13T00:03:30.000Z",
},
],
}),
);

expect(copyText).toBe(
[
"devos recorded execution output",
"Status: success",
"",
"Result: The task is complete.",
"Thinking: Keep the operator summary concise.",
"",
"Steps:",
"1. implementation [success]",
"2. review [success]",
" Planning: Follow-up is not needed.",
].join("\n"),
);
expect(copyText).not.toContain("codex exec");
expect(copyText).not.toContain("payload");
expect(copyText).not.toContain("{");
});
});

function activity(
Expand Down
Loading
Loading