Commit b9019396 authored by edy's avatar edy

fix(ui): scope task artifact copy feedback

parent e4fdf87e
Pipeline #18473 failed
import { randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import type { Dirent } from "node:fs"; import type { Dirent } from "node:fs";
...@@ -179,6 +179,10 @@ function toLocalDateTimeValue(date: Date): string { ...@@ -179,6 +179,10 @@ function toLocalDateTimeValue(date: Date): string {
return `${year}-${month}-${day} ${hour}:${minute}:${second}`; return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
} }
function createWorkspaceArtifactId(relativePath: string): string {
return "artifact-" + createHash("sha256").update(relativePath).digest("hex").slice(0, 32);
}
function normalizeSnapshotPath(value: string): string { function normalizeSnapshotPath(value: string): string {
return value.replace(/\\/g, "/"); return value.replace(/\\/g, "/");
} }
...@@ -285,7 +289,7 @@ export async function collectWorkspaceExecutionArtifacts( ...@@ -285,7 +289,7 @@ export async function collectWorkspaceExecutionArtifacts(
const artifacts: TaskPanelArtifact[] = []; const artifacts: TaskPanelArtifact[] = [];
for (const entry of changedEntries) { for (const entry of changedEntries) {
artifacts.push({ artifacts.push({
id: "artifact-" + Buffer.from(entry.relativePath).toString("base64url").slice(0, 32), id: createWorkspaceArtifactId(entry.relativePath),
name: path.basename(entry.path), name: path.basename(entry.path),
kind: inferWorkspaceArtifactKind(entry.path), kind: inferWorkspaceArtifactKind(entry.path),
summary: await readWorkspaceArtifactSummary(entry.path, input.assistantSummary), summary: await readWorkspaceArtifactSummary(entry.path, input.assistantSummary),
......
...@@ -86,3 +86,22 @@ test("infers image video and spreadsheet artifact kinds", async () => { ...@@ -86,3 +86,22 @@ test("infers image video and spreadsheet artifact kinds", async () => {
) )
}) })
}) })
test("uses full relative path identity for artifact ids", async () => {
await withProjectRoot(async (projectRoot) => {
const before = await createWorkspaceArtifactSnapshot(projectRoot)
const sharedPrefix = "aaaaaaaaaaaaaaaaaaaaaaaa"
await mkdir(path.join(projectRoot, sharedPrefix), { recursive: true })
await writeFile(path.join(projectRoot, sharedPrefix, "one.md"), "one", "utf8")
await writeFile(path.join(projectRoot, sharedPrefix, "two.md"), "two", "utf8")
const artifacts = await collectWorkspaceExecutionArtifacts({
projectRoot,
beforeSnapshot: before,
assistantSummary: "assistant fallback"
})
assert.deepEqual(artifacts.map((artifact) => artifact.name), ["one.md", "two.md"])
assert.equal(new Set(artifacts.map((artifact) => artifact.id)).size, 2)
})
})
...@@ -153,8 +153,12 @@ function formatTaskPanelOutputTime(task: TaskPanelItem, artifact?: TaskPanelArti ...@@ -153,8 +153,12 @@ function formatTaskPanelOutputTime(task: TaskPanelItem, artifact?: TaskPanelArti
return `${dateText} ${hour}:${minute}:${second}` return `${dateText} ${hour}:${minute}:${second}`
} }
function getTaskPanelOutputRowKey(task: TaskPanelItem, artifact: TaskPanelArtifact) {
return `${task.id}:${artifact.id}:${artifact.path ?? artifact.url ?? artifact.name}`
}
function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) { function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) {
const [copiedArtifactId, setCopiedArtifactId] = useState("") const [copiedOutputRowKey, setCopiedOutputRowKey] = useState("")
const copiedTimerRef = useRef<number | null>(null) const copiedTimerRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
...@@ -166,18 +170,18 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) { ...@@ -166,18 +170,18 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) {
}, []) }, [])
useEffect(() => { useEffect(() => {
setCopiedArtifactId("") setCopiedOutputRowKey("")
}, [outputs]) }, [outputs])
const copyArtifactUrl = async (artifactId: string, artifactUrl: string) => { const copyArtifactUrl = async (outputRowKey: string, artifactUrl: string) => {
try { try {
await navigator.clipboard.writeText(artifactUrl) await navigator.clipboard.writeText(artifactUrl)
setCopiedArtifactId(artifactId) setCopiedOutputRowKey(outputRowKey)
if (copiedTimerRef.current !== null) { if (copiedTimerRef.current !== null) {
window.clearTimeout(copiedTimerRef.current) window.clearTimeout(copiedTimerRef.current)
} }
copiedTimerRef.current = window.setTimeout(() => { copiedTimerRef.current = window.setTimeout(() => {
setCopiedArtifactId("") setCopiedOutputRowKey("")
copiedTimerRef.current = null copiedTimerRef.current = null
}, 1400) }, 1400)
} catch { } catch {
...@@ -198,8 +202,9 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) { ...@@ -198,8 +202,9 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) {
<div className="task-panel-output-list"> <div className="task-panel-output-list">
{outputs.length ? outputs.map(({ artifact, task }) => { {outputs.length ? outputs.map(({ artifact, task }) => {
const artifactPath = artifact.path ?? artifact.url const artifactPath = artifact.path ?? artifact.url
const outputRowKey = getTaskPanelOutputRowKey(task, artifact)
return ( return (
<article key={task.id + "-" + artifact.id} className="task-panel-output-item"> <article key={outputRowKey} className="task-panel-output-item">
<span className="task-panel-output-icon" aria-hidden="true"> <span className="task-panel-output-icon" aria-hidden="true">
<TaskPanelOutputIcon artifact={artifact} /> <TaskPanelOutputIcon artifact={artifact} />
</span> </span>
...@@ -215,11 +220,11 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) { ...@@ -215,11 +220,11 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) {
type="button" type="button"
className="task-panel-output-url" className="task-panel-output-url"
title={artifactPath} title={artifactPath}
onClick={() => void copyArtifactUrl(artifact.id, artifactPath)} onClick={() => void copyArtifactUrl(outputRowKey, artifactPath)}
> >
{artifactPath} {artifactPath}
</button> </button>
{copiedArtifactId === artifact.id ? ( {copiedOutputRowKey === outputRowKey ? (
<span className="task-panel-artifact-copied" aria-live="polite">✅已复制</span> <span className="task-panel-artifact-copied" aria-live="polite">✅已复制</span>
) : null} ) : null}
</div> </div>
......
...@@ -18,6 +18,14 @@ test("task panel output uses real artifact paths and produced timestamps", () => ...@@ -18,6 +18,14 @@ test("task panel output uses real artifact paths and produced timestamps", () =>
assert.match(source, /artifact\.producedAt\s*\?\?/) assert.match(source, /artifact\.producedAt\s*\?\?/)
}) })
test("task panel output copy feedback is scoped to the clicked output row", () => {
assert.match(source, /copiedOutputRowKey/)
assert.match(source, /const outputRowKey = getTaskPanelOutputRowKey\(task, artifact\)/)
assert.match(source, /copyArtifactUrl\(outputRowKey, artifactPath\)/)
assert.match(source, /copiedOutputRowKey === outputRowKey/)
assert.doesNotMatch(source, /copiedArtifactId === artifact\.id/)
})
test("task panel output summaries use a single-line visual ellipsis", () => { test("task panel output summaries use a single-line visual ellipsis", () => {
const summaryBlock = cssBlock(taskStylesSource, ".task-panel-output-main p") const summaryBlock = cssBlock(taskStylesSource, ".task-panel-output-main p")
assert.match(summaryBlock, /overflow:\s*hidden;/) assert.match(summaryBlock, /overflow:\s*hidden;/)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment