refactor(ui): share comment chip variants

This commit is contained in:
David Hill 2026-03-20 12:48:54 +00:00
commit 6c25b9aa2d
3 changed files with 136 additions and 78 deletions

View file

@ -0,0 +1,85 @@
import { Show, type Component } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
type Range = {
start: number
end: number
}
type CommentChipProps = {
variant?: "preview" | "full"
path: string
label: string
selection?: Range
comment?: string
class?: string
onOpen?: () => void
onRemove?: () => void
removeLabel?: string
}
const removeClass =
"absolute top-0 right-0 size-6 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
const removeIconClass =
"absolute top-1 right-1 size-3.5 rounded-[var(--radius-sm)] flex items-center justify-center bg-transparent group-hover/remove:bg-surface-base-hover group-active/remove:bg-surface-base-active"
export const CommentChip: Component<CommentChipProps> = (props) => {
const variant = () => props.variant ?? "preview"
const range = () => {
const sel = props.selection
if (!sel) return
const start = Math.min(sel.start, sel.end)
const end = Math.max(sel.start, sel.end)
return { start, end }
}
const pad = () => (props.onRemove ? "pr-7" : "pr-2")
return (
<div
class={`group relative flex flex-col rounded-[6px] cursor-default bg-background-stronger ${
variant() === "full" ? "border border-border-weak-base" : "shadow-xs-border"
} ${variant() === "full" ? `pl-2 py-1 ${pad()}` : `pl-2 py-1 h-12 ${pad()}`} ${props.class ?? ""}`}
onClick={() => props.onOpen?.()}
>
<div class="flex items-center gap-1.5 min-w-0">
<FileIcon node={{ path: props.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{props.label}</span>
<Show when={range()}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap shrink-0">
{sel().start === sel().end ? `:${sel().start}` : `:${sel().start}-${sel().end}`}
</span>
)}
</Show>
</div>
</div>
<Show when={(props.comment ?? "").trim().length > 0}>
<div
class={`text-base text-text-strong ml-5 ${
variant() === "full" ? "whitespace-pre-wrap break-words" : "truncate"
}`}
>
{props.comment}
</div>
</Show>
<Show when={props.onRemove}>
<button
type="button"
class={`${removeClass} group/remove`}
onClick={(e) => {
e.stopPropagation()
props.onRemove?.()
}}
aria-label={props.removeLabel}
>
<span class={removeIconClass}>
<Icon name="close-small" size="small" class="text-text-weak group-hover/remove:text-text-strong" />
</span>
</button>
</Show>
</div>
)
}

View file

@ -1,10 +1,9 @@
import { Component, For, Show, createMemo } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
import { PromptImageAttachment } from "./image-attachments"
import { CommentChip } from "@/components/comment-chip"
type PromptContextItem = ContextItem & { key: string }
@ -73,46 +72,24 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
openDelay={2000}
class="shrink-0"
>
<div
class="group relative flex flex-col rounded-[6px] pl-2 pr-7 py-1 max-w-[200px] h-12 cursor-default shadow-xs-border bg-background-stronger"
onClick={() => props.openComment(row.item)}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: row.item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{label}</span>
<Show when={row.item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap shrink-0">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
</div>
</div>
<Show when={row.item.comment}>
{(comment) => <div class="text-base text-text-strong ml-5 truncate">{comment()}</div>}
</Show>
<button
type="button"
class="absolute top-0 right-0 size-6 opacity-0 pointer-events-none transition-opacity group/remove group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
onClick={(e) => {
e.stopPropagation()
props.remove(row.item)
}}
aria-label={props.t("prompt.context.removeFile")}
>
<span class="absolute top-1 right-1 size-3.5 rounded-[var(--radius-sm)] flex items-center justify-center bg-transparent group-hover/remove:bg-surface-base-hover group-active/remove:bg-surface-base-active">
<Icon
name="close-small"
size="small"
class="text-text-weak group-hover/remove:text-text-strong"
/>
</span>
</button>
</div>
<CommentChip
variant="preview"
path={row.item.path}
label={label}
selection={
row.item.selection
? {
start: row.item.selection.startLine,
end: row.item.selection.endLine,
}
: undefined
}
comment={row.item.comment}
class="max-w-[200px]"
onOpen={() => props.openComment(row.item)}
onRemove={() => props.remove(row.item)}
removeLabel={props.t("prompt.context.removeFile")}
/>
</Tooltip>
)
}}

View file

@ -2,7 +2,6 @@ import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } f
import { createStore, produce } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
@ -15,7 +14,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { getFilenameTruncated } from "@opencode-ai/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
@ -29,6 +28,7 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { CommentChip } from "@/components/comment-chip"
type MessageComment = {
path: string
@ -960,40 +960,36 @@ export function MessageTimeline(props: {
>
<Show when={commentCount() > 0}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}>
{(commentAccessor: () => MessageComment) => {
const comment = createMemo(() => commentAccessor())
return (
<Show when={comment()}>
{(c) => (
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
<FileIcon
node={{ path: c().path, type: "file" }}
class="size-3.5 shrink-0"
/>
<span class="truncate">{getFilename(c().path)}</span>
<Show when={c().selection}>
{(selection) => (
<span class="shrink-0 text-text-weak">
{selection().startLine === selection().endLine
? `:${selection().startLine}`
: `:${selection().startLine}-${selection().endLine}`}
</span>
)}
</Show>
</div>
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
{c().comment}
</div>
</div>
)}
</Show>
)
}}
</Index>
<div class="w-full overflow-visible">
<div class="overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}>
{(commentAccessor: () => MessageComment) => {
const comment = createMemo(() => commentAccessor())
return (
<Show when={comment()}>
{(c) => (
<CommentChip
variant="full"
path={c().path}
label={getFilenameTruncated(c().path, 14)}
selection={
c().selection
? {
start: c().selection!.startLine,
end: c().selection!.endLine,
}
: undefined
}
comment={c().comment}
class="max-w-[260px]"
/>
)}
</Show>
)
}}
</Index>
</div>
</div>
</div>
</div>