Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 3 additions & 2 deletions packages/app/e2e/snap/session-turn-changes.snap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,16 @@ 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 })
})
}

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>
)
}
2 changes: 1 addition & 1 deletion packages/ui/src/components/message-part-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ test("review hardening keeps routing, clipboard, url, and write guards explicit"

expect(source).toContain("return path.slice(prefix.length)")
expect(source).toContain("path.search(/\\/session(?:\\/|$)/)")
expect([...source.matchAll(/try \{\n\s+await navigator\.clipboard\.writeText/g)].length).toBe(2)
expect([...source.matchAll(/try \{\n\s+await navigator\.clipboard\.writeText/g)].length).toBe(1)
expect(source).toContain('if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return ""')
expect(source).toContain("getDiagnostics(props.metadata?.diagnostics, props.input.filePath)")
expect(source).toContain("props.input.content != null && path()")
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
102 changes: 1 addition & 101 deletions packages/ui/src/components/message-part/parts/text.tsx
Original file line number Diff line number Diff line change
@@ -1,91 +1,14 @@
import { createMemo, createSignal, Show } from "solid-js"
import { createMemo, Show } from "solid-js"
import type { AssistantMessage, TextPart } from "@opencode-ai/sdk/v2"
import { useData } from "../../../context"
import { useI18n } from "../../../context/i18n"
import { IconButton } from "../../icon-button"
import { Tooltip } from "../../tooltip"
import { MessageMarkdown, PacedMarkdown } from "../markdown-render"
import { registerPartComponent } from "../registry"

registerPartComponent("text", function TextPartDisplay(props) {
const data = useData()
const i18n = useI18n()
const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
const part = () => props.part as TextPart
const interrupted = createMemo(
() =>
props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
)

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

const duration = createMemo(() => {
if (props.message.role !== "assistant") return ""
const message = props.message as AssistantMessage
const completed = message.time.completed
const ms =
typeof props.turnDurationMs === "number"
? props.turnDurationMs
: typeof completed === "number"
? completed - 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(() => {
if (props.message.role !== "assistant") return ""
const agent = (props.message as AssistantMessage).agent
const items = [
agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
model(),
duration(),
interrupted() ? i18n.t("ui.message.interrupted") : "",
]
return items.filter((x) => !!x).join(" \u00B7 ")
})

const streaming = createMemo(
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
)
const text = () => (part().text ?? "").trim()
const isLastTextPart = createMemo(() => {
const last = (data.store.part?.[props.message.id] ?? [])
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
.at(-1)
return last?.id === part().id
})
const showCopy = createMemo(() => {
if (props.message.role !== "assistant") return isLastTextPart()
if (props.showAssistantCopyPartID === null) return false
if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
return isLastTextPart()
})
const [copied, setCopied] = createSignal(false)

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

return (
<Show when={text()}>
Expand All @@ -95,29 +18,6 @@ registerPartComponent("text", function TextPartDisplay(props) {
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
</Show>
</div>
<Show when={showCopy()}>
<div data-slot="text-part-copy-wrapper" 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="text-part-meta" class="text-body text-fg-weak cursor-default">
{meta()}
</span>
</Show>
</div>
</Show>
</div>
</Show>
)
Expand Down
3 changes: 0 additions & 3 deletions packages/ui/src/components/message-part/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ export interface MessageProps {
message: MessageType
parts: PartType[]
actions?: UserActions
showAssistantCopyPartID?: string | null
showReasoningSummaries?: boolean
}

Expand All @@ -21,8 +20,6 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
showAssistantCopyPartID?: string | null
turnDurationMs?: number
stateKey?: string
}

Expand Down
Loading