feat(desktop): improve file comments in session timeline (#36845)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
usrnk1 2026-07-14 15:12:46 +02:00 committed by GitHub
parent 21f8184c58
commit dedb4133dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 275 additions and 116 deletions

View file

@ -133,10 +133,6 @@
align-items: flex-end;
}
[data-slot="user-message-attachments"] + [data-slot="user-message-body"] {
margin-top: 8px;
}
[data-slot="user-message-text"] {
display: inline-block;
white-space: pre-wrap;
@ -1324,10 +1320,61 @@
}
}
body[data-new-layout] [data-component="user-message"] {
font-weight: 440;
[data-slot="user-message-text"] {
background: var(--v2-background-bg-layer-01);
}
[data-slot="user-message-comments"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: min(320px, 82%);
margin-left: auto;
&[data-bounded] {
width: 318px;
max-width: 100%;
margin-top: 8px;
}
[data-component="tooltip-v2-trigger"] {
width: 100%;
}
[data-component="button-v2"] {
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
font-weight: 530;
font-variation-settings: "wght" 530, "slnt" 0;
}
}
[data-slot="user-message-body"] + [data-slot="user-message-attachments"],
[data-slot="user-message-comments"] + [data-slot="user-message-attachments"] {
margin-top: 8px;
}
[data-slot="user-message-text"][data-comments] {
width: 342px;
padding-bottom: 12px;
}
}
[data-color-scheme="light"] body[data-new-layout] [data-component="user-message"] [data-slot="user-message-text"] {
background: var(--v2-background-bg-layer-02);
}
body:not([data-new-layout]) {
[data-component="user-message"] {
color: var(--text-strong);
[data-slot="user-message-attachments"] + [data-slot="user-message-body"] {
margin-top: 8px;
}
[data-slot="user-message-attachment"] {
background: var(--surface-weak);
border: 1px solid var(--border-weak-base);

View file

@ -54,6 +54,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
@ -1172,6 +1173,36 @@ export function ContextToolGroup(props: {
)
}
function UserMessageComments(props: { comments: UserMessageComment[]; bounded: boolean }) {
const i18n = useI18n()
const [state, setState] = createStore({ expanded: false })
const comments = createMemo(() =>
props.bounded && !state.expanded ? props.comments.slice(0, 5) : props.comments,
)
return (
<div data-slot="user-message-comments" data-bounded={props.bounded ? "true" : undefined}>
<For each={comments()}>
{(comment) => (
<CommentCardV2
comment={comment.comment}
path={comment.path}
selection={comment.selection}
title={comment.comment}
tooltip
wide
/>
)}
</For>
<Show when={props.bounded && props.comments.length > 5 && !state.expanded}>
<ButtonV2 size="small" variant="ghost-muted" onClick={() => setState("expanded", true)}>
{i18n.t("ui.common.showMore")}
</ButtonV2>
</Show>
</div>
)
}
export function UserMessageDisplay(props: {
message: UserMessage
parts: PartType[]
@ -1255,107 +1286,114 @@ export function UserMessageDisplay(props: {
.finally(() => setState("busy", false))
}
const renderAttachments = () => (
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
{(file) => {
const type = kind(file)
const name = file.filename ?? i18n.t("ui.message.attachment.alt")
return (
<Show
when={newLayout() && type === "file"}
fallback={
<div
data-slot="user-message-attachment"
data-type={type}
data-clickable={type === "image" ? "true" : undefined}
title={type === "file" ? name : undefined}
onClick={() => {
if (type === "image") openImagePreview(file.url, name)
}}
>
<Show
when={type === "image"}
fallback={
<div data-slot="user-message-attachment-file">
<FileIcon node={{ path: name, type: "file" }} />
<span data-slot="user-message-attachment-name">{name}</span>
</div>
}
>
<img data-slot="user-message-attachment-image" src={file.url} alt={name} />
</Show>
</div>
}
>
<AttachmentCardV2
title={getFilename(name)}
hover={name}
clickable={!!props.actions?.openAttachment}
onClick={() => props.actions?.openAttachment?.(file)}
>
{typeLabel(name, file.mime)}
</AttachmentCardV2>
</Show>
)
}}
</For>
</div>
</Show>
)
return (
<div data-component="user-message" data-timeline-part-id={textPart()?.id}>
<Show when={attachments().length > 0 || messageComments().length > 0}>
<div data-slot="user-message-attachments">
<For each={messageComments()}>
{(comment) => (
<CommentCardV2
comment={comment.comment}
path={comment.path}
selection={comment.selection}
title={comment.comment}
/>
)}
</For>
<For each={attachments()}>
{(file) => {
const type = kind(file)
const name = file.filename ?? i18n.t("ui.message.attachment.alt")
return (
<Show
when={newLayout() && type === "file"}
fallback={
<div
data-slot="user-message-attachment"
data-type={type}
data-clickable={type === "image" ? "true" : undefined}
title={type === "file" ? name : undefined}
onClick={() => {
if (type === "image") openImagePreview(file.url, name)
}}
>
<Show
when={type === "image"}
fallback={
<div data-slot="user-message-attachment-file">
<FileIcon node={{ path: name, type: "file" }} />
<span data-slot="user-message-attachment-name">{name}</span>
</div>
}
>
<img data-slot="user-message-attachment-image" src={file.url} alt={name} />
</Show>
</div>
}
>
<AttachmentCardV2
title={getFilename(name)}
hover={name}
clickable={!!props.actions?.openAttachment}
onClick={() => props.actions?.openAttachment?.(file)}
>
{typeLabel(name, file.mime)}
</AttachmentCardV2>
</Show>
)
}}
</For>
<Show when={!props.useV2Actions}>{renderAttachments()}</Show>
<Show
when={text()}
fallback={
<Show when={messageComments().length > 0}>
<UserMessageComments comments={messageComments()} bounded={false} />
</Show>
}
>
<div data-slot="user-message-body">
<div data-slot="user-message-text" data-comments={messageComments().length > 0 ? "true" : undefined}>
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
<Show when={messageComments().length > 0}>
<UserMessageComments comments={messageComments()} bounded />
</Show>
</div>
</div>
</Show>
<Show when={text()}>
<>
<div data-slot="user-message-body">
<div data-slot="user-message-text">
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
</div>
<div data-slot="user-message-copy-wrapper">
<Show when={metaHead() || metaTail()}>
<span data-slot="user-message-meta-wrap">
<Show when={metaHead()}>
<span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default">
{metaHead()}
</span>
</Show>
<Show when={metaHead() && metaTail()}>
<span data-slot="user-message-meta-sep" class="text-12-regular text-text-weak cursor-default">
{"\u00A0\u00B7\u00A0"}
</span>
</Show>
<Show when={metaTail()}>
<span data-slot="user-message-meta-tail" class="text-12-regular text-text-weak cursor-default">
{metaTail()}
</span>
</Show>
</span>
</Show>
<Show when={props.actions?.revert}>
<MessageActionButton
icon="reset"
label={i18n.t("ui.message.revertMessage")}
useV2={props.useV2Actions}
disabled={!!busy()}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
revert()
}}
aria-label={i18n.t("ui.message.revertMessage")}
/>
</Show>
<Show when={props.useV2Actions}>{renderAttachments()}</Show>
<Show when={text() || (props.useV2Actions && messageComments().length > 0)}>
<div data-slot="user-message-copy-wrapper">
<Show when={metaHead() || metaTail()}>
<span data-slot="user-message-meta-wrap">
<Show when={metaHead()}>
<span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default">
{metaHead()}
</span>
</Show>
<Show when={metaHead() && metaTail()}>
<span data-slot="user-message-meta-sep" class="text-12-regular text-text-weak cursor-default">
{"\u00A0\u00B7\u00A0"}
</span>
</Show>
<Show when={metaTail()}>
<span data-slot="user-message-meta-tail" class="text-12-regular text-text-weak cursor-default">
{metaTail()}
</span>
</Show>
</span>
</Show>
<Show when={props.actions?.revert}>
<MessageActionButton
icon="reset"
label={i18n.t("ui.message.revertMessage")}
useV2={props.useV2Actions}
disabled={!!busy()}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
revert()
}}
aria-label={i18n.t("ui.message.revertMessage")}
/>
</Show>
<Show when={text()}>
<MessageActionButton
icon={copied() ? "check" : "copy"}
label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
@ -1367,8 +1405,8 @@ export function UserMessageDisplay(props: {
}}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
/>
</div>
</>
</Show>
</div>
</Show>
</div>
)

View file

@ -12,6 +12,16 @@
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
cursor: default;
&[data-wide] {
width: 100%;
min-width: 0;
max-width: none;
}
&[data-surface="base"] {
background: var(--v2-background-bg-base);
}
&[data-active] {
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
}
@ -32,6 +42,8 @@
}
[data-slot="attachment-card-v2-title"] {
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View file

@ -6,8 +6,11 @@ export function AttachmentCardV2(props: {
title: string
active?: boolean
clickable?: boolean
wide?: boolean
surface?: "base"
/** native title attribute */
hover?: string
titleRef?: (element: HTMLSpanElement) => void
onClick?: () => void
children: JSX.Element
}) {
@ -16,10 +19,14 @@ export function AttachmentCardV2(props: {
data-component="attachment-card-v2"
data-active={props.active ? "true" : undefined}
data-clickable={props.clickable ? "true" : undefined}
data-wide={props.wide ? "true" : undefined}
data-surface={props.surface}
title={props.hover}
onClick={() => props.onClick?.()}
>
<span data-slot="attachment-card-v2-title">{props.title}</span>
<span ref={(element) => props.titleRef?.(element)} data-slot="attachment-card-v2-title">
{props.title}
</span>
<span data-slot="attachment-card-v2-subtitle">{props.children}</span>
</div>
)

View file

@ -1,6 +1,7 @@
import { Show } from "solid-js"
import { createSignal, onCleanup, onMount, Show } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { getFilenameTruncated } from "@opencode-ai/core/util/path"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { AttachmentCardV2 } from "./attachment-card-v2"
export function CommentCardV2(props: {
@ -9,19 +10,55 @@ export function CommentCardV2(props: {
selection?: { startLine: number; endLine: number }
active?: boolean
title?: string
tooltip?: boolean
wide?: boolean
onClick?: () => void
}) {
let title: HTMLSpanElement | undefined
const [truncated, setTruncated] = createSignal(false)
onMount(() => {
const element = title
if (!element) return
const sync = () => setTruncated(element.scrollWidth > element.clientWidth)
const measure = () => requestAnimationFrame(sync)
const observer = new ResizeObserver(sync)
observer.observe(element)
measure()
void document.fonts?.ready.then(measure)
onCleanup(() => observer.disconnect())
})
return (
<AttachmentCardV2 title={props.comment} active={props.active} hover={props.title} onClick={props.onClick}>
<FileIcon node={{ path: props.path, type: "file" }} />
<span>
{getFilenameTruncated(props.path, 14)}
<Show when={props.selection}>
{(sel) =>
sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}`
}
</Show>
</span>
</AttachmentCardV2>
<TooltipV2
placement="top"
openDelay={1000}
value={props.title ?? props.comment}
disabled={!props.tooltip || !truncated()}
class={props.wide ? "w-full" : undefined}
contentStyle={{ "max-width": "320px", "white-space": "pre-wrap" }}
>
<AttachmentCardV2
title={props.comment}
active={props.active}
clickable={!!props.onClick}
wide={props.wide}
surface="base"
titleRef={(element) => {
title = element
}}
onClick={props.onClick}
>
<FileIcon node={{ path: props.path, type: "file" }} />
<span>
{getFilenameTruncated(props.path, 14)}
<Show when={props.selection}>
{(sel) =>
sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}`
}
</Show>
</span>
</AttachmentCardV2>
</TooltipV2>
)
}

View file

@ -149,6 +149,7 @@ export const dict = {
"ui.common.close": "إغلاق",
"ui.common.next": "التالي",
"ui.common.submit": "إرسال",
"ui.common.showMore": "عرض المزيد",
"ui.permission.deny": "رفض",
"ui.permission.allowAlways": "السماح دائمًا",

View file

@ -149,6 +149,7 @@ export const dict = {
"ui.common.close": "Fechar",
"ui.common.next": "Próximo",
"ui.common.submit": "Enviar",
"ui.common.showMore": "Mostrar mais",
"ui.permission.deny": "Negar",
"ui.permission.allowAlways": "Permitir sempre",

View file

@ -153,6 +153,7 @@ export const dict = {
"ui.common.close": "Zatvori",
"ui.common.next": "Dalje",
"ui.common.submit": "Pošalji",
"ui.common.showMore": "Prikaži više",
"ui.permission.deny": "Zabrani",
"ui.permission.allowAlways": "Uvijek dozvoli",

View file

@ -148,6 +148,7 @@ export const dict = {
"ui.common.close": "Luk",
"ui.common.next": "Næste",
"ui.common.submit": "Indsend",
"ui.common.showMore": "Vis mere",
"ui.permission.deny": "Afvis",
"ui.permission.allowAlways": "Tillad altid",

View file

@ -155,6 +155,7 @@ export const dict = {
"ui.common.close": "Schließen",
"ui.common.next": "Weiter",
"ui.common.submit": "Absenden",
"ui.common.showMore": "Mehr anzeigen",
"ui.permission.deny": "Verweigern",
"ui.permission.allowAlways": "Immer erlauben",

View file

@ -162,6 +162,7 @@ export const dict: Record<string, string> = {
"ui.common.close": "Close",
"ui.common.next": "Next",
"ui.common.submit": "Submit",
"ui.common.showMore": "Show more",
"ui.permission.deny": "Deny",
"ui.permission.allowAlways": "Allow always",

View file

@ -149,6 +149,7 @@ export const dict = {
"ui.common.close": "Cerrar",
"ui.common.next": "Siguiente",
"ui.common.submit": "Enviar",
"ui.common.showMore": "Mostrar más",
"ui.permission.deny": "Denegar",
"ui.permission.allowAlways": "Permitir siempre",

View file

@ -149,6 +149,7 @@ export const dict = {
"ui.common.close": "Fermer",
"ui.common.next": "Suivant",
"ui.common.submit": "Soumettre",
"ui.common.showMore": "Afficher plus",
"ui.permission.deny": "Refuser",
"ui.permission.allowAlways": "Toujours autoriser",

View file

@ -148,6 +148,7 @@ export const dict = {
"ui.common.close": "閉じる",
"ui.common.next": "次へ",
"ui.common.submit": "送信",
"ui.common.showMore": "さらに表示",
"ui.permission.deny": "拒否",
"ui.permission.allowAlways": "常に許可",

View file

@ -125,6 +125,7 @@ export const dict = {
"ui.common.close": "닫기",
"ui.common.next": "다음",
"ui.common.submit": "제출",
"ui.common.showMore": "더 보기",
"ui.permission.deny": "거부",
"ui.permission.allowAlways": "항상 허용",

View file

@ -128,6 +128,7 @@ export const dict: Record<Keys, string> = {
"ui.common.close": "Lukk",
"ui.common.next": "Neste",
"ui.common.submit": "Send inn",
"ui.common.showMore": "Vis mer",
"ui.permission.deny": "Avslå",
"ui.permission.allowAlways": "Tillat alltid",

View file

@ -148,6 +148,7 @@ export const dict = {
"ui.common.close": "Zamknij",
"ui.common.next": "Dalej",
"ui.common.submit": "Prześlij",
"ui.common.showMore": "Pokaż więcej",
"ui.permission.deny": "Odmów",
"ui.permission.allowAlways": "Zezwalaj zawsze",

View file

@ -148,6 +148,7 @@ export const dict = {
"ui.common.close": "Закрыть",
"ui.common.next": "Далее",
"ui.common.submit": "Отправить",
"ui.common.showMore": "Показать ещё",
"ui.permission.deny": "Запретить",
"ui.permission.allowAlways": "Разрешить всегда",

View file

@ -150,6 +150,7 @@ export const dict = {
"ui.common.close": "ปิด",
"ui.common.next": "ถัดไป",
"ui.common.submit": "ส่ง",
"ui.common.showMore": "แสดงเพิ่มเติม",
"ui.permission.deny": "ปฏิเสธ",
"ui.permission.allowAlways": "อนุญาตเสมอ",

View file

@ -155,6 +155,7 @@ export const dict = {
"ui.common.close": "Kapat",
"ui.common.next": "İleri",
"ui.common.submit": "Gönder",
"ui.common.showMore": "Daha fazla göster",
"ui.permission.deny": "Reddet",
"ui.permission.allowAlways": "Her zaman izin ver",

View file

@ -162,6 +162,7 @@ export const dict: Record<string, string> = {
"ui.common.close": "Закрити",
"ui.common.next": "Далі",
"ui.common.submit": "Надіслати",
"ui.common.showMore": "Показати більше",
"ui.permission.deny": "Заборонити",
"ui.permission.allowAlways": "Дозволяти завжди",

View file

@ -152,6 +152,7 @@ export const dict = {
"ui.common.close": "关闭",
"ui.common.next": "下一步",
"ui.common.submit": "提交",
"ui.common.showMore": "显示更多",
"ui.permission.deny": "拒绝",
"ui.permission.allowAlways": "始终允许",

View file

@ -152,6 +152,7 @@ export const dict = {
"ui.common.close": "關閉",
"ui.common.next": "下一步",
"ui.common.submit": "提交",
"ui.common.showMore": "顯示更多",
"ui.permission.deny": "拒絕",
"ui.permission.allowAlways": "永遠允許",