Skip to content

Commit 3778cf1

Browse files
authored
Add task artifacts and file reveal support (#26)
* Add task artifacts and file reveal support * Address artifact review feedback
1 parent 9379301 commit 3778cf1

10 files changed

Lines changed: 795 additions & 17 deletions

File tree

src/client/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ export const api = {
7676
async deleteTask(id: string) {
7777
return request<void>(`/api/tasks/${id}`, { method: "DELETE" });
7878
},
79+
async openTaskArtifact(id: string, artifactId: string) {
80+
return request<{ ok: boolean; path: string }>(
81+
`/api/tasks/${id}/artifacts/${artifactId}/open`,
82+
{ method: "POST" },
83+
);
84+
},
7985
async settings() {
8086
return request<AppSettings>("/api/settings");
8187
},

src/client/components/TaskModal.test.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,22 @@ import { TaskModal } from "./TaskModal";
88
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
99
true;
1010

11+
const apiMocks = vi.hoisted(() => ({
12+
openTaskArtifact: vi.fn(),
13+
}));
14+
15+
vi.mock("../api", () => ({
16+
api: {
17+
openTaskArtifact: apiMocks.openTaskArtifact,
18+
},
19+
}));
20+
1121
let container: HTMLDivElement;
1222
let root: Root;
1323

1424
beforeEach(() => {
25+
apiMocks.openTaskArtifact.mockReset();
26+
apiMocks.openTaskArtifact.mockResolvedValue({ ok: true, path: "/tmp/task-report.md" });
1527
try {
1628
window.localStorage.clear();
1729
} catch {
@@ -90,6 +102,71 @@ describe("TaskModal", () => {
90102
);
91103
});
92104

105+
it("labels the inspector tab as Artifacts and renders useful artifacts as clickable rows", async () => {
106+
await renderTaskModal({
107+
...task(),
108+
execution: execution({
109+
status: "succeeded",
110+
output: "Created useful evidence.",
111+
artifacts: [
112+
{
113+
id: "artifact-issue",
114+
type: "link",
115+
title: "GitHub issue #25",
116+
content: "github.com",
117+
url: "https://github.com/afurm/draftmora/issues/25",
118+
createdAt: "2026-05-03T07:01:00.000Z",
119+
},
120+
{
121+
id: "artifact-issue-duplicate",
122+
type: "link",
123+
title: "GitHub issue #25",
124+
content: "github.com",
125+
url: "https://github.com/afurm/draftmora/issues/25",
126+
createdAt: "2026-05-03T07:01:01.000Z",
127+
},
128+
{
129+
id: "artifact-file",
130+
type: "output",
131+
title: "File: task-report.md",
132+
content: "/tmp/task-report.md",
133+
url: "file:///tmp/task-report.md",
134+
createdAt: "2026-05-03T07:01:02.000Z",
135+
},
136+
],
137+
}),
138+
});
139+
140+
await clickButtonByLabel("Open Artifacts");
141+
142+
expect(document.body.textContent).toContain("Artifacts");
143+
expect(document.body.textContent).not.toContain("Files");
144+
expect(document.body.textContent).toContain("GitHub issue #25");
145+
expect(document.body.textContent).toContain("File: task-report.md");
146+
147+
const inspector = document.body.querySelector<HTMLElement>('aside[aria-label="Task details"]');
148+
expect(inspector).toBeTruthy();
149+
const issueLinks = inspector!.querySelectorAll<HTMLAnchorElement>(
150+
'a[href="https://github.com/afurm/draftmora/issues/25"]',
151+
);
152+
expect(issueLinks).toHaveLength(1);
153+
expect(issueLinks[0]?.target).toBe("_blank");
154+
expect(issueLinks[0]?.rel).toBe("noreferrer");
155+
156+
const fileLink = inspector!.querySelector<HTMLAnchorElement>('a[href="file:///tmp/task-report.md"]');
157+
expect(fileLink).toBeNull();
158+
159+
const fileButton = inspector!.querySelector<HTMLButtonElement>(
160+
'button[aria-label="Reveal File: task-report.md"]',
161+
);
162+
expect(fileButton).toBeTruthy();
163+
await act(async () => {
164+
fileButton!.click();
165+
await Promise.resolve();
166+
});
167+
expect(apiMocks.openTaskArtifact).toHaveBeenCalledWith("task-1", "artifact-file");
168+
});
169+
93170
it("shows running AI work above locked details", async () => {
94171
await renderTaskModal({
95172
...task(),

src/client/components/TaskModal.tsx

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Clock3,
77
ExternalLink,
88
FileText,
9+
FolderOpen,
910
Info,
1011
Link2,
1112
LoaderCircle,
@@ -40,6 +41,7 @@ import type {
4041
TaskStatus,
4142
} from "../../shared/types";
4243
import { COLUMN_LABELS, PRIORITIES, TASK_STATUSES } from "../../shared/types";
44+
import { api } from "../api";
4345
import { Alert, AlertDescription } from "@/components/ui/alert";
4446
import {
4547
AlertDialog,
@@ -206,7 +208,7 @@ const INSPECTOR_TABS: Array<{
206208
{ value: "details", label: "Details", icon: Info },
207209
{ value: "context", label: "Context", icon: MessageSquareText },
208210
{ value: "runs", label: "Runs", icon: Clock3 },
209-
{ value: "artifacts", label: "Files", icon: FileText },
211+
{ value: "artifacts", label: "Artifacts", icon: FileText },
210212
];
211213

212214
export function TaskModal(props: {
@@ -1424,8 +1426,8 @@ function TaskThreadMessageRow(props: {
14241426

14251427
return (
14261428
<ItemGroup>
1427-
{message.execution.artifacts.map((artifact) => (
1428-
<ArtifactRow artifact={artifact} key={artifact.id} />
1429+
{dedupeTaskExecutionArtifacts(message.execution.artifacts).map((artifact) => (
1430+
<ArtifactRow artifact={artifact} key={artifact.id} taskId={message.execution.taskId} />
14291431
))}
14301432
</ItemGroup>
14311433
);
@@ -1582,7 +1584,7 @@ function TaskInspector(props: {
15821584
<TabsTrigger value="details">Details</TabsTrigger>
15831585
<TabsTrigger value="context">Context</TabsTrigger>
15841586
<TabsTrigger value="runs">Runs</TabsTrigger>
1585-
<TabsTrigger value="artifacts">Files</TabsTrigger>
1587+
<TabsTrigger value="artifacts">Artifacts</TabsTrigger>
15861588
</TabsList>
15871589
<Button
15881590
type="button"
@@ -1771,7 +1773,9 @@ function TaskRunsPanel(props: { task: Task }) {
17711773
}
17721774

17731775
function TaskArtifactsPanel(props: { task: Task }) {
1774-
const artifacts = getTaskExecutionHistory(props.task).flatMap((execution) => execution.artifacts);
1776+
const artifacts = dedupeTaskExecutionArtifacts(
1777+
getTaskExecutionHistory(props.task).flatMap((execution) => execution.artifacts),
1778+
);
17751779
if (artifacts.length === 0) {
17761780
return (
17771781
<Empty className="min-h-40 border">
@@ -1788,7 +1792,7 @@ function TaskArtifactsPanel(props: { task: Task }) {
17881792
return (
17891793
<ItemGroup>
17901794
{artifacts.map((artifact) => (
1791-
<ArtifactRow artifact={artifact} key={artifact.id} />
1795+
<ArtifactRow artifact={artifact} key={artifact.id} taskId={props.task.id} />
17921796
))}
17931797
</ItemGroup>
17941798
);
@@ -2251,7 +2255,10 @@ function ExecutionOutput(props: {
22512255
);
22522256
}
22532257

2254-
function ArtifactRow(props: { artifact: TaskExecutionArtifact }) {
2258+
function ArtifactRow(props: { artifact: TaskExecutionArtifact; taskId: string }) {
2259+
const [opening, setOpening] = useState(false);
2260+
const canRevealFile = isLocalFileArtifact(props.artifact);
2261+
const externalUrl = isHttpArtifactUrl(props.artifact.url) ? props.artifact.url : null;
22552262
const icon =
22562263
props.artifact.type === "link" ? (
22572264
<Link2 />
@@ -2266,20 +2273,60 @@ function ArtifactRow(props: { artifact: TaskExecutionArtifact }) {
22662273
<ItemContent>
22672274
<ItemTitle>{props.artifact.title}</ItemTitle>
22682275
{props.artifact.content && (
2269-
<p className="text-sm text-muted-foreground">{props.artifact.content}</p>
2276+
<p className="break-words text-sm text-muted-foreground">{props.artifact.content}</p>
22702277
)}
22712278
</ItemContent>
2272-
{props.artifact.url && (
2279+
{(externalUrl || canRevealFile) && (
22732280
<ItemActions>
2274-
<ExternalLink />
2281+
{canRevealFile ? (
2282+
opening ? (
2283+
<LoaderCircle className="animate-spin" />
2284+
) : (
2285+
<FolderOpen />
2286+
)
2287+
) : (
2288+
<ExternalLink />
2289+
)}
22752290
</ItemActions>
22762291
)}
22772292
</>
22782293
);
22792294

2280-
return props.artifact.url ? (
2295+
async function revealFile() {
2296+
if (opening) {
2297+
return;
2298+
}
2299+
setOpening(true);
2300+
try {
2301+
await api.openTaskArtifact(props.taskId, props.artifact.id);
2302+
} catch (error) {
2303+
const localPath = props.artifact.content ?? props.artifact.url ?? "";
2304+
if (localPath) {
2305+
await navigator.clipboard?.writeText(localPath).catch(() => undefined);
2306+
}
2307+
window.alert(error instanceof Error ? error.message : "Could not reveal artifact file.");
2308+
} finally {
2309+
setOpening(false);
2310+
}
2311+
}
2312+
2313+
if (canRevealFile) {
2314+
return (
2315+
<Item asChild variant="outline" size="sm">
2316+
<button
2317+
type="button"
2318+
aria-label={`Reveal ${props.artifact.title}`}
2319+
onClick={() => void revealFile()}
2320+
>
2321+
{content}
2322+
</button>
2323+
</Item>
2324+
);
2325+
}
2326+
2327+
return externalUrl ? (
22812328
<Item asChild variant="outline" size="sm">
2282-
<a href={props.artifact.url} target="_blank" rel="noreferrer">
2329+
<a href={externalUrl} target="_blank" rel="noreferrer">
22832330
{content}
22842331
</a>
22852332
</Item>
@@ -2290,6 +2337,35 @@ function ArtifactRow(props: { artifact: TaskExecutionArtifact }) {
22902337
);
22912338
}
22922339

2340+
function isHttpArtifactUrl(url: string | undefined): url is string {
2341+
return Boolean(url?.startsWith("http://") || url?.startsWith("https://"));
2342+
}
2343+
2344+
function isLocalFileArtifact(artifact: TaskExecutionArtifact) {
2345+
return (
2346+
artifact.url?.startsWith("file://") ||
2347+
(artifact.type === "output" && Boolean(artifact.content && isAbsoluteLocalPath(artifact.content)))
2348+
);
2349+
}
2350+
2351+
function isAbsoluteLocalPath(value: string) {
2352+
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
2353+
}
2354+
2355+
function dedupeTaskExecutionArtifacts(artifacts: TaskExecutionArtifact[]) {
2356+
const seen = new Set<string>();
2357+
return artifacts.filter((artifact) => {
2358+
const key =
2359+
artifact.url?.trim().toLowerCase() ??
2360+
`${artifact.type}:${artifact.title.trim()}:${artifact.content?.trim() ?? ""}`;
2361+
if (seen.has(key)) {
2362+
return false;
2363+
}
2364+
seen.add(key);
2365+
return true;
2366+
});
2367+
}
2368+
22932369
function executionStatusIcon(execution: TaskExecution) {
22942370
if (execution.status === "succeeded") {
22952371
return <CheckCircle2 data-icon="inline-start" />;

src/server/artifact-file-opener.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { execFile } from "node:child_process";
2+
import { access, stat } from "node:fs/promises";
3+
import path from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
import type { TaskExecutionArtifact } from "../shared/types";
6+
7+
export type ArtifactOpenCommand = {
8+
command: string;
9+
args: string[];
10+
};
11+
12+
export type ArtifactFileOpenResult =
13+
| { ok: true; path: string }
14+
| { ok: false; path: string; error: string };
15+
16+
export function resolveArtifactFilePath(artifact: TaskExecutionArtifact): string | null {
17+
if (artifact.url?.startsWith("file://")) {
18+
try {
19+
return fileURLToPath(artifact.url);
20+
} catch {
21+
return null;
22+
}
23+
}
24+
return artifact.type === "output" && artifact.content && path.isAbsolute(artifact.content)
25+
? artifact.content
26+
: null;
27+
}
28+
29+
export function resolveArtifactRevealCommand(
30+
filePath: string,
31+
platform: NodeJS.Platform = process.platform,
32+
isDirectory = false,
33+
): ArtifactOpenCommand {
34+
if (platform === "darwin") {
35+
return { command: "open", args: ["-R", filePath] };
36+
}
37+
if (platform === "win32") {
38+
return { command: "explorer.exe", args: [`/select,${filePath}`] };
39+
}
40+
return { command: "xdg-open", args: [isDirectory ? filePath : path.dirname(filePath)] };
41+
}
42+
43+
export async function revealArtifactFile(filePath: string): Promise<ArtifactFileOpenResult> {
44+
try {
45+
const fileStat = await stat(filePath);
46+
if (!fileStat.isFile() && !fileStat.isDirectory()) {
47+
return { ok: false, path: filePath, error: "artifact path is not a file or folder" };
48+
}
49+
await access(filePath);
50+
await execArtifactOpenCommand(
51+
resolveArtifactRevealCommand(filePath, process.platform, fileStat.isDirectory()),
52+
);
53+
return { ok: true, path: filePath };
54+
} catch {
55+
return { ok: false, path: filePath, error: "failed to reveal artifact file" };
56+
}
57+
}
58+
59+
function execArtifactOpenCommand(command: ArtifactOpenCommand): Promise<void> {
60+
return new Promise((resolve, reject) => {
61+
execFile(command.command, command.args, (error) => {
62+
if (error) {
63+
reject(error);
64+
return;
65+
}
66+
resolve();
67+
});
68+
});
69+
}

src/server/local-agent-tools.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
String as TypeString,
1717
} from "typebox";
1818
import type { Tool, ToolCall, ToolResultMessage } from "@mariozechner/pi-ai";
19+
import type { TaskExecutionArtifact } from "../shared/types";
20+
import { extractLocalToolResultArtifacts } from "./task-artifacts";
1921

2022
const DEFAULT_OUTPUT_LIMIT = 30_000;
2123
const MAX_OUTPUT_LIMIT = 120_000;
@@ -81,6 +83,7 @@ export const LOCAL_AGENT_TOOLS: Tool[] = [
8183
export type ExecutedLocalTool = {
8284
message: ToolResultMessage;
8385
summary: string;
86+
artifacts: TaskExecutionArtifact[];
8487
};
8588

8689
export async function executeLocalToolCall(
@@ -100,6 +103,7 @@ export async function executeLocalToolCall(
100103
return {
101104
message: buildToolResult(call, content, isError),
102105
summary: summarizeToolCall(call, content, Date.now() - started),
106+
artifacts: isError ? [] : extractLocalToolResultArtifacts(call.name, content),
103107
};
104108
} catch (err) {
105109
if (isToolRunCancellationError(err)) {
@@ -110,6 +114,7 @@ export async function executeLocalToolCall(
110114
return {
111115
message: buildToolResult(call, content, true),
112116
summary: `${call.name} failed: ${message}`,
117+
artifacts: [],
113118
};
114119
}
115120
}

0 commit comments

Comments
 (0)