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
84 changes: 84 additions & 0 deletions packages/app/e2e/session/session-turn-footer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { bodyText } from "../prompt/mock"

test("assistant footer is hover-only and copy writes response to clipboard", async ({
page,
context,
project,
llm,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"])
await project.open()

const reply = "Hello from assistant body for footer test."
await llm.text(reply)
await project.prompt("seed footer hover")

const container = page.locator('[data-slot="session-turn-message-container"]').last()
const footer = container.locator('[data-slot="assistant-turn-footer"]')
await expect(footer).toBeAttached({ timeout: 30_000 })

const opacityBeforeHover = await footer.evaluate((el) => getComputedStyle(el).opacity)
expect(Number(opacityBeforeHover)).toBe(0)

await container.hover()
await expect.poll(async () => footer.evaluate((el) => getComputedStyle(el).opacity), { timeout: 5_000 }).toBe("1")

const copy = footer.getByRole("button").first()
await copy.click()
const clip = await page.evaluate(() => navigator.clipboard.readText())
expect(clip).toBe(reply)
})

test("assistant footer renders below the turn changes panel in the same turn", async ({ page, project, llm }) => {
test.setTimeout(180_000)
await project.open()

const patchText = [
"*** Begin Patch",
"*** Add File: footer-order-fixture.txt",
"+seeded",
"*** End Patch",
].join("\n")
const marker = "seed apply_patch then reply for footer ordering"

await withSession(project.sdk, "footer order with panel", async (session) => {
project.trackSession(session.id)
await llm.toolMatch((hit) => bodyText(hit).includes(marker), "apply_patch", { patchText })
await llm.text("Done patching.")
await project.sdk.session.prompt({
sessionID: session.id,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Call apply_patch once with the provided JSON input, then send a short text reply.",
`Use this JSON input for apply_patch: ${JSON.stringify({ patchText })}`,
].join("\n"),
parts: [{ type: "text", text: marker }],
})

await expect
.poll(
async () => {
const aggregate = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data)
if (!aggregate || aggregate.kind === "empty" || aggregate.kind === "uncaptured") return 0
return aggregate.files.filter((file) => file.restoreState === "applied").length
},
{ timeout: 120_000 },
)
.toBeGreaterThan(0)

await project.gotoSession(session.id)

const panel = page.locator('[data-component="session-turn-changes"]').first()
const footer = page.locator('[data-slot="assistant-turn-footer"]').first()
await expect(panel).toBeVisible({ timeout: 60_000 })
await expect(footer).toBeAttached({ timeout: 60_000 })

const panelBox = await panel.boundingBox()
const footerBox = await footer.boundingBox()
if (!panelBox || !footerBox) throw new Error("Failed to measure panel/footer position")
expect(footerBox.y).toBeGreaterThanOrEqual(panelBox.y + panelBox.height)
})
})
55 changes: 53 additions & 2 deletions packages/app/e2e/snap/session-turn-changes.snap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,37 @@ async function patchWithMock(
.toBeGreaterThan(0)
}

async function mixedWithMock(
llm: Parameters<typeof test>[0]["llm"],
sdk: Parameters<typeof withSession>[0],
sessionID: string,
patchText: string,
shellFile: string,
) {
const callsBefore = await llm.calls()
const command = `touch ${shellFile}`
await llm.tool("apply_patch", { patchText })
await llm.tool("bash", { command, description: "Writes a mixed snap fixture shell file" })
await llm.text("Done.")
await sdk.session.prompt({
sessionID,
agent: "build",
system: [
"You are seeding deterministic snap UI state.",
"Issue exactly two tool calls in order: first apply_patch, then bash.",
`Use this JSON for apply_patch: ${JSON.stringify({ patchText })}`,
`Use this JSON for bash: ${JSON.stringify({ command, description: "Writes a mixed snap fixture shell file" })}`,
"After both tools return, send a short text reply and stop.",
].join("\n"),
parts: [{ type: "text", text: "Run apply_patch first, then bash, then reply." }],
})

await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
await expect
.poll(async () => sdk.session.diff({ sessionID }).then((res) => res.data?.kind), { timeout: 120_000 })
.toBe("mixed")
}

async function uncapturedWithMock(
llm: Parameters<typeof test>[0]["llm"],
sdk: Parameters<typeof withSession>[0],
Expand Down Expand Up @@ -124,15 +155,35 @@ async function runCapturedPass(
await expect(action).toBeVisible()
await action.click()
await action.click()
await expect(page.locator('[data-slot="session-turn-changes-undone"]').first()).toBeVisible()
await expect(page.locator('[data-slot="session-turn-changes-undone-summary"]').first()).toBeVisible()
shots.push(await captureTurnChanges(page, `${label}-captured-undone`))
})

await withSession(project.sdk, `snap turn changes uncaptured ${label}`, async (session) => {
project.trackSession(session.id)
await uncapturedWithMock(llm, project.sdk, session.id, `snap-uncaptured-${label}.txt`)
await project.gotoSession(session.id)
shots.push(await captureTurnChanges(page, `${label}-uncaptured`))
// Uncaptured-only turns intentionally render no panel; assert that and skip the shot.
await expect(page.locator('[data-component="session-turn-changes"]')).toHaveCount(0, { timeout: 10_000 })
})

await withSession(project.sdk, `snap turn changes mixed ${label}`, async (session) => {
project.trackSession(session.id)
await mixedWithMock(
llm,
project.sdk,
session.id,
patch(`snap-mixed-${label}.txt`, `mixed-${label}`),
`snap-mixed-shell-${label}.txt`,
)
await project.gotoSession(session.id)
// Mixed turns render the captured-files panel only; the uncaptured diagnostic copy
// is intentionally suppressed (originally surfaced as "部分 shell 改动未逐个捕获").
const panel = page.locator('[data-component="session-turn-changes"]').first()
await expect(panel).toBeVisible({ timeout: 30_000 })
await expect(panel.locator('[data-slot="session-turn-changes-uncaptured"]')).toHaveCount(0)
await expect(panel.locator('[data-slot="session-turn-change-item"]')).toHaveCount(1)
shots.push(await captureTurnChanges(page, `${label}-mixed`))
})
}

Expand Down
90 changes: 90 additions & 0 deletions packages/ui/src/components/assistant-turn-footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createMemo, createSignal, Show } from "solid-js"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useI18n } from "../context/i18n"
import { IconButton } from "./icon-button"
import { Tooltip } from "./tooltip"

export function AssistantTurnFooter(props: {
text: string
message: AssistantMessage
turnDurationMs?: number
}) {
const data = useData()
const i18n = useI18n()
const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
const [copied, setCopied] = createSignal(false)

const interrupted = createMemo(() => props.message.error?.name === "MessageAbortedError")

const model = createMemo(() => {
const match = data.store.provider?.all?.find((p) => p.id === props.message.providerID)
return match?.models?.[props.message.modelID]?.name ?? props.message.modelID
})

const duration = createMemo(() => {
const completed = props.message.time.completed
const ms =
typeof props.turnDurationMs === "number"
? props.turnDurationMs
: typeof completed === "number"
? completed - props.message.time.created
: -1
if (!(ms >= 0)) return ""
const total = Math.round(ms / 1000)
if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
const minutes = Math.floor(total / 60)
const seconds = total % 60
return i18n.t("ui.message.duration.minutesSeconds", {
minutes: numfmt().format(minutes),
seconds: numfmt().format(seconds),
})
})

const meta = createMemo(() => {
const agent = props.message.agent
const items = [
agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
model(),
duration(),
interrupted() ? i18n.t("ui.message.interrupted") : "",
]
return items.filter((x) => !!x).join(" · ")
})

const handleCopy = async () => {
const content = props.text
if (!content) return
try {
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
setCopied(false)
}
}

return (
<div data-slot="assistant-turn-footer" data-interrupted={interrupted() ? "" : undefined}>
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
placement="top"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
/>
</Tooltip>
<Show when={meta()}>
<span data-slot="assistant-turn-footer-meta" class="text-body text-fg-weak cursor-default">
{meta()}
</span>
</Show>
</div>
)
}
1 change: 1 addition & 0 deletions packages/ui/src/components/message-part-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function sourceFiles(dir: string): string[] {
function readMessagePartSources() {
return [
readFileSync(join(COMPONENT_DIR, "message-part.tsx"), "utf8"),
readFileSync(join(COMPONENT_DIR, "assistant-turn-footer.tsx"), "utf8"),
...sourceFiles(MESSAGE_PART_DIR).map((file) => readFileSync(file, "utf8")),
].join("\n")
}
Expand Down
34 changes: 0 additions & 34 deletions packages/ui/src/components/message-part.css
Original file line number Diff line number Diff line change
Expand Up @@ -243,40 +243,6 @@
[data-slot="text-part-body"] {
margin-top: 0;
}

[data-slot="text-part-copy-wrapper"] {
min-height: 30px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;

[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
}

[data-slot="text-part-meta"] {
user-select: none;
}

[data-slot="text-part-copy-wrapper"][data-interrupted] {
width: 100%;
justify-content: flex-end;
gap: 12px;
}

&:hover [data-slot="text-part-copy-wrapper"],
&:focus-within [data-slot="text-part-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
}

[data-component="compaction-part"] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { Part } from "./message-router"
export function AssistantMessageDisplay(props: {
message: AssistantMessage
parts: PartType[]
showAssistantCopyPartID?: string | null
showReasoningSummaries?: boolean
}) {
const emptyTools: ToolPart[] = []
Expand Down Expand Up @@ -69,7 +68,6 @@ export function AssistantMessageDisplay(props: {
<Part
part={stableItem()!}
message={props.message}
showAssistantCopyPartID={props.showAssistantCopyPartID}
stateKey={`tool:${stableItem()!.id}`}
/>
</Show>
Expand Down
4 changes: 0 additions & 4 deletions packages/ui/src/components/message-part/assistant-parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import { Part } from "./message-router"

export function AssistantParts(props: {
messages: AssistantMessage[]
showAssistantCopyPartID?: string | null
turnDurationMs?: number
working?: boolean
showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean
Expand Down Expand Up @@ -94,8 +92,6 @@ export function AssistantParts(props: {
<Part
part={stableItem()!}
message={stableMessage()!}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
defaultOpen={partDefaultOpen(
stableItem()!,
props.shellToolDefaultOpen,
Expand Down
3 changes: 0 additions & 3 deletions packages/ui/src/components/message-part/message-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export function Message(props: MessageProps) {
<AssistantMessageDisplay
message={props.message as AssistantMessage}
parts={props.parts}
showAssistantCopyPartID={props.showAssistantCopyPartID}
showReasoningSummaries={props.showReasoningSummaries}
/>
</Show>
Expand All @@ -33,8 +32,6 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
stateKey={props.stateKey}
/>
</Show>
Expand Down
Loading
Loading