ソースを参照

feat(app): redesign attachment cards (#35945)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Aarav Sareen 1 ヶ月 前
コミット
b4e49d5b32
25 ファイル変更499 行追加55 行削除
  1. 4 1
      packages/app/e2e/regression/session-timeline-projection.spec.ts
  2. 16 1
      packages/app/src/components/prompt-input.tsx
  3. 13 3
      packages/app/src/components/prompt-input/context-items.tsx
  4. 43 0
      packages/app/src/components/prompt-input/image-attachments.css
  5. 128 21
      packages/app/src/components/prompt-input/image-attachments.tsx
  6. 3 0
      packages/app/src/context/platform.tsx
  7. 1 1
      packages/app/src/context/prompt-state.ts
  8. 1 0
      packages/app/src/context/prompt.tsx
  9. 26 2
      packages/app/src/pages/session.tsx
  10. 6 0
      packages/app/src/pages/session/timeline/message-timeline.tsx
  11. 2 0
      packages/app/src/pages/session/timeline/projection.ts
  12. 4 2
      packages/app/src/pages/session/timeline/rows.ts
  13. 10 0
      packages/desktop/src/main/ipc.ts
  14. 1 0
      packages/desktop/src/preload/index.ts
  15. 1 0
      packages/desktop/src/preload/types.ts
  16. 3 0
      packages/desktop/src/renderer/index.tsx
  17. 11 1
      packages/session-ui/src/components/message-file.test.ts
  18. 21 0
      packages/session-ui/src/components/message-file.ts
  19. 24 2
      packages/session-ui/src/components/message-part.css
  20. 62 21
      packages/session-ui/src/components/message-part.tsx
  21. 58 0
      packages/session-ui/src/v2/components/attachment-card-v2.css
  22. 26 0
      packages/session-ui/src/v2/components/attachment-card-v2.tsx
  23. 27 0
      packages/session-ui/src/v2/components/comment-card-v2.tsx
  24. 4 0
      packages/storybook/.storybook/mocks/app/context/prompt.ts
  25. 4 0
      packages/ui/src/v2/components/icon.tsx

+ 4 - 1
packages/app/e2e/regression/session-timeline-projection.spec.ts

@@ -152,7 +152,10 @@ test.describe("session timeline projection", () => {
       parentID: "msg_2000_diff_next_user",
       created: 1700000011000,
     })
-    await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
+    await setupTimeline(page, {
+      messages: [user, assistantMessage(), nextUser, nextAssistant],
+      settings: { newLayoutDesigns: false },
+    })
     const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
     await scroller.evaluate((element) => (element.scrollTop = 0))
 

+ 16 - 1
packages/app/src/components/prompt-input.tsx

@@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
 import {
   ContentPart,
   DEFAULT_PROMPT,
+  isCommentItem,
   isPromptEqual,
   Prompt,
   usePrompt,
@@ -1626,7 +1627,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 )}
               />
               <PromptContextItems
-                items={contextItems()}
+                items={contextItems().filter((item) => !isCommentItem(item))}
                 active={(item) => {
                   const active = comments.active()
                   return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1636,6 +1637,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   if (item.commentID) comments.remove(item.path, item.commentID)
                   prompt.context.remove(item.key)
                 }}
+                newLayoutDesigns={props.controls.newLayoutDesigns}
                 t={(key) => language.t(key as Parameters<typeof language.t>[0])}
               />
               <PromptImageAttachments
@@ -1645,6 +1647,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 }
                 onRemove={removeAttachment}
                 removeLabel={language.t("prompt.attachment.remove")}
+                newLayoutDesigns={props.controls.newLayoutDesigns}
+                comments={contextItems().filter(isCommentItem)}
+                commentActive={(item) => {
+                  const active = comments.active()
+                  return !!item.commentID && item.commentID === active?.id && item.path === active?.file
+                }}
+                onOpenComment={openComment}
+                onRemoveComment={(item) => {
+                  if (item.commentID) comments.remove(item.path, item.commentID)
+                  prompt.context.remove(item.key)
+                }}
               />
               <div
                 class="relative min-h-[52px]"
@@ -1852,6 +1865,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 if (item.commentID) comments.remove(item.path, item.commentID)
                 prompt.context.remove(item.key)
               }}
+              newLayoutDesigns={props.controls.newLayoutDesigns}
               t={(key) => language.t(key as Parameters<typeof language.t>[0])}
             />
             <PromptImageAttachments
@@ -1861,6 +1875,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               }
               onRemove={removeAttachment}
               removeLabel={language.t("prompt.attachment.remove")}
+              newLayoutDesigns={props.controls.newLayoutDesigns}
             />
             <div
               class="relative"

+ 13 - 3
packages/app/src/components/prompt-input/context-items.tsx

@@ -1,6 +1,8 @@
 import { Component, For, Show } from "solid-js"
+import { Dynamic } from "solid-js/web"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
 import type { ContextItem } from "@/context/prompt"
@@ -12,6 +14,7 @@ type ContextItemsProps = {
   active: (item: PromptContextItem) => boolean
   openComment: (item: PromptContextItem) => void
   remove: (item: PromptContextItem) => void
+  newLayoutDesigns: boolean
   t: (key: string) => string
 }
 
@@ -27,10 +30,17 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
             const selected = props.active(item)
 
             return (
-              <TooltipV2
+              <Dynamic
+                component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
                 value={
                   <span class="flex max-w-[300px]">
-                    <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
+                    <span
+                      classList={{
+                        "truncate-start [unicode-bidi:plaintext] min-w-0": true,
+                        "text-v2-text-text-muted": props.newLayoutDesigns,
+                        "text-text-invert-base": !props.newLayoutDesigns,
+                      }}
+                    >
                       {directory}
                     </span>
                     <span class="shrink-0">{filename}</span>
@@ -78,7 +88,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
                     {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
                   </Show>
                 </div>
-              </TooltipV2>
+              </Dynamic>
             )
           }}
         </For>

+ 43 - 0
packages/app/src/components/prompt-input/image-attachments.css

@@ -0,0 +1,43 @@
+@keyframes prompt-attachments-fade-left {
+  from {
+    visibility: hidden;
+  }
+  to {
+    visibility: visible;
+  }
+}
+
+@keyframes prompt-attachments-fade-right {
+  from {
+    visibility: visible;
+  }
+  to {
+    visibility: hidden;
+  }
+}
+
+[data-slot="prompt-attachments"] {
+  timeline-scope: --prompt-attachments-scroll;
+
+  [data-slot^="prompt-attachments-fade-"] {
+    visibility: hidden;
+  }
+}
+
+@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) {
+  [data-slot="prompt-attachments-scroll"] {
+    scroll-timeline: --prompt-attachments-scroll x;
+  }
+
+  [data-slot="prompt-attachments-fade-left"] {
+    animation: prompt-attachments-fade-left linear both;
+    animation-timeline: --prompt-attachments-scroll;
+    animation-range: 0 0.1px;
+  }
+
+  [data-slot="prompt-attachments-fade-right"] {
+    animation: prompt-attachments-fade-right linear both;
+    animation-timeline: --prompt-attachments-scroll;
+    animation-range: calc(100% - 1.1px) calc(100% - 1px);
+  }
+}

+ 128 - 21
packages/app/src/components/prompt-input/image-attachments.tsx

@@ -1,60 +1,167 @@
 import { Component, For, Show } from "solid-js"
 import { Icon } from "@opencode-ai/ui/icon"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import type { ImageAttachmentPart } from "@/context/prompt"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2"
+import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2"
+import { typeLabel } from "@opencode-ai/session-ui/message-file"
+import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
+import "./image-attachments.css"
+
+type PromptCommentItem = ContextItem & { key: string }
 
 type PromptImageAttachmentsProps = {
   attachments: ImageAttachmentPart[]
   onOpen: (attachment: ImageAttachmentPart) => void
   onRemove: (id: string) => void
   removeLabel: string
+  newLayoutDesigns: boolean
+  comments?: PromptCommentItem[]
+  commentActive?: (item: PromptCommentItem) => boolean
+  onOpenComment?: (item: PromptCommentItem) => void
+  onRemoveComment?: (item: PromptCommentItem) => void
 }
 
 const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
 const imageClass =
   "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
+const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover"
+// inset box-shadows do not paint over <img> content, so the hairline is a separate overlay
+const imageHairlineClassV2 =
+  "absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none"
 const removeClass =
   "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+const removeClassV2 =
+  "absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
 const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
 
 export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
   return (
-    <Show when={props.attachments.length > 0}>
-      <div class="flex flex-wrap gap-2 px-3 pt-3">
-        <For each={props.attachments}>
-          {(attachment) => (
-            <Tooltip value={attachment.filename} placement="top" contentClass="break-all">
-              <div class="relative group">
+    <Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
+      <div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
+        <div
+          data-slot="prompt-attachments-scroll"
+          classList={{
+            "flex gap-2": true,
+            "flex-nowrap overflow-x-auto no-scrollbar px-2 pt-2 pb-1": props.newLayoutDesigns,
+            "flex-wrap px-3 pt-3": !props.newLayoutDesigns,
+          }}
+        >
+          <Show when={props.newLayoutDesigns}>
+            <For each={props.comments ?? []}>
+              {(item) => (
+                <div class="relative group shrink-0">
+                  <TooltipV2
+                    value={item.comment}
+                    placement="top"
+                    openDelay={800}
+                    contentClass="max-w-[300px] break-words"
+                  >
+                    <CommentCardV2
+                      comment={item.comment ?? ""}
+                      path={item.path}
+                      selection={item.selection}
+                      active={props.commentActive?.(item)}
+                      onClick={() => props.onOpenComment?.(item)}
+                    />
+                  </TooltipV2>
+                  <button
+                    type="button"
+                    onClick={() => props.onRemoveComment?.(item)}
+                    class={removeClassV2}
+                    aria-label={props.removeLabel}
+                  >
+                    <IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
+                  </button>
+                </div>
+              )}
+            </For>
+          </Show>
+          <For each={props.attachments}>
+            {(attachment) => {
+              const image = attachment.mime.startsWith("image/")
+              const media = () => (
                 <Show
-                  when={attachment.mime.startsWith("image/")}
+                  when={image}
                   fallback={
-                    <div class={fallbackClass}>
-                      <Icon name="folder" class="size-6 text-text-weak" />
-                    </div>
+                    <Show
+                      when={props.newLayoutDesigns}
+                      fallback={
+                        <div class={fallbackClass}>
+                          <Icon name="folder" class="size-6 text-text-weak" />
+                        </div>
+                      }
+                    >
+                      <AttachmentCardV2 title={attachment.filename}>
+                        {typeLabel(attachment.filename, attachment.mime)}
+                      </AttachmentCardV2>
+                    </Show>
                   }
                 >
                   <img
                     src={attachment.dataUrl}
                     alt={attachment.filename}
-                    class={imageClass}
+                    class={props.newLayoutDesigns ? imageClassV2 : imageClass}
                     onClick={() => props.onOpen(attachment)}
                   />
                 </Show>
+              )
+              const name = () => (
+                <div class={nameClass}>
+                  <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
+                </div>
+              )
+              const remove = () => (
                 <button
                   type="button"
                   onClick={() => props.onRemove(attachment.id)}
-                  class={removeClass}
+                  class={props.newLayoutDesigns ? removeClassV2 : removeClass}
                   aria-label={props.removeLabel}
                 >
-                  <Icon name="close" class="size-3 text-text-weak" />
+                  <Show when={props.newLayoutDesigns} fallback={<Icon name="close" class="size-3 text-text-weak" />}>
+                    <IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
+                  </Show>
                 </button>
-                <div class={nameClass}>
-                  <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
-                </div>
-              </div>
-            </Tooltip>
-          )}
-        </For>
+              )
+              // v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
+              return (
+                <Show
+                  when={props.newLayoutDesigns}
+                  fallback={
+                    <Tooltip value={attachment.filename} placement="top" contentClass="break-all">
+                      <div class="relative group">
+                        {media()}
+                        {name()}
+                        {remove()}
+                      </div>
+                    </Tooltip>
+                  }
+                >
+                  <div class="relative group shrink-0">
+                    <TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
+                      {media()}
+                      <Show when={image}>
+                        <div class={imageHairlineClassV2} />
+                      </Show>
+                    </TooltipV2>
+                    {remove()}
+                  </div>
+                </Show>
+              )
+            }}
+          </For>
+        </div>
+        <Show when={props.newLayoutDesigns}>
+          <div
+            data-slot="prompt-attachments-fade-left"
+            class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
+          />
+          <div
+            data-slot="prompt-attachments-fade-right"
+            class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
+          />
+        </Show>
       </div>
     </Show>
   )

+ 3 - 0
packages/app/src/context/platform.tsx

@@ -37,6 +37,9 @@ type PlatformBase = {
   /** Open a local path in a local app (desktop only) */
   openPath?(path: string, app?: string): Promise<void>
 
+  /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
+  revealPath?(path: string): Promise<boolean>
+
   /** Restart the app  */
   restart(): Promise<void>
 

+ 1 - 1
packages/app/src/context/prompt-state.ts

@@ -145,7 +145,7 @@ function contextItemKey(item: ContextItem) {
   return `${key}:c=${digest.slice(0, 8)}`
 }
 
-function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
+export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
   return item.type === "file" && !!item.comment?.trim()
 }
 

+ 1 - 0
packages/app/src/context/prompt.tsx

@@ -24,6 +24,7 @@ export {
   createPromptSession,
   createPromptState,
   DEFAULT_PROMPT,
+  isCommentItem,
   isPromptEqual,
 } from "./prompt-state"
 export type {

+ 26 - 2
packages/app/src/pages/session.tsx

@@ -1,4 +1,5 @@
-import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import { getFilename } from "@opencode-ai/core/util/path"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
 import {
@@ -1905,7 +1906,30 @@ export default function Page() {
       .map((item) => ({ id: item.id, text: line(item.id) }))
   })
 
-  const actions = { revert }
+  // attachment bytes are embedded as a data URL, so downloading always works;
+  // revealing requires the on-disk path captured by the client that attached the file
+  const openAttachment = (file: FilePart) => {
+    const download = () => {
+      const anchor = document.createElement("a")
+      anchor.href = file.url
+      anchor.download = getFilename(file.filename) || "attachment"
+      anchor.click()
+    }
+    const path = file.filename ?? ""
+    const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
+    if (platform.revealPath && absolute) {
+      void platform.revealPath(path).then(
+        (revealed) => {
+          if (!revealed) download()
+        },
+        () => download(),
+      )
+      return
+    }
+    download()
+  }
+
+  const actions = { revert, openAttachment }
 
   createEffect(() => {
     const sessionID = params.id

+ 6 - 0
packages/app/src/pages/session/timeline/message-timeline.tsx

@@ -327,6 +327,7 @@ export function MessageTimeline(props: {
     parts: getMsgParts,
     status: sessionStatus,
     showReasoningSummaries: settings.general.showReasoningSummaries,
+    inlineComments: settings.general.newLayoutDesigns,
   })
   const activeMessageID = projection.activeMessageID
   const assistantMessagesByParent = projection.assistantMessagesByParent
@@ -1135,6 +1136,10 @@ export function MessageTimeline(props: {
           const m = messageByID().get(userMessageRow().userMessageID)
           if (m?.role === "user") return m
         })
+        const messageComments = createMemo(() => {
+          if (!settings.general.newLayoutDesigns()) return []
+          return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
+        })
         return (
           <TimelineRowFrame row={userMessageRow}>
             <Show when={message()}>
@@ -1146,6 +1151,7 @@ export function MessageTimeline(props: {
                       parts={getMsgParts(userMessageRow().userMessageID)}
                       actions={props.actions}
                       useV2Actions={settings.general.newLayoutDesigns()}
+                      comments={messageComments()}
                     />
                   </div>
                 </div>

+ 2 - 0
packages/app/src/pages/session/timeline/projection.ts

@@ -14,6 +14,7 @@ export function createTimelineProjection(input: {
   parts: (messageID: string) => Part[]
   status: Accessor<SessionStatus>
   showReasoningSummaries: Accessor<boolean>
+  inlineComments: Accessor<boolean>
 }) {
   const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
   const assistantMessagesByParent = createMemo(() => {
@@ -59,6 +60,7 @@ export function createTimelineProjection(input: {
             input.showReasoningSummaries(),
             input.status().type,
             activeMessageID() === userMessage.id,
+            input.inlineComments(),
           ),
         ),
       ),

+ 4 - 2
packages/app/src/pages/session/timeline/rows.ts

@@ -38,6 +38,8 @@ export namespace Timeline {
     showReasoning: boolean,
     status: SessionStatus["type"],
     isActive: boolean,
+    // v2 renders comments inside the user message attachments row instead of a strip row
+    inlineComments: boolean,
   ) {
     const rows: TimelineRow.TimelineRow[] = []
 
@@ -74,7 +76,7 @@ export namespace Timeline {
         : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
     if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
 
-    if (comments.length > 0)
+    if (comments.length > 0 && !inlineComments)
       rows.push(
         new TimelineRow.CommentStrip({
           userMessageID: userMessage.id,
@@ -84,7 +86,7 @@ export namespace Timeline {
     rows.push(
       new TimelineRow.UserMessage({
         userMessageID: userMessage.id,
-        anchor: comments.length === 0,
+        anchor: inlineComments || comments.length === 0,
       }),
     )
 

+ 10 - 0
packages/desktop/src/main/ipc.ts

@@ -184,6 +184,16 @@ export function registerIpcHandlers(deps: Deps) {
     })
   })
 
+  ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => {
+    const exists = await stat(path).then(
+      () => true,
+      () => false,
+    )
+    if (!exists) return false
+    shell.showItemInFolder(path)
+    return true
+  })
+
   ipcMain.handle("read-clipboard-image", () => {
     const image = clipboard.readImage()
     if (image.isEmpty()) return null

+ 1 - 0
packages/desktop/src/preload/index.ts

@@ -95,6 +95,7 @@ const api: ElectronAPI = {
   saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
   openLink: (url) => ipcRenderer.send("open-link", url),
   openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
+  revealPath: (path) => ipcRenderer.invoke("reveal-path", path),
   readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
   showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
   getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),

+ 1 - 0
packages/desktop/src/preload/types.ts

@@ -86,6 +86,7 @@ export type ElectronAPI = {
   saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
   openLink: (url: string) => void
   openPath: (path: string, app?: string) => Promise<void>
+  revealPath: (path: string) => Promise<boolean>
   readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
   showNotification: (title: string, body?: string) => void
   getWindowFocused: () => Promise<boolean>

+ 3 - 0
packages/desktop/src/renderer/index.tsx

@@ -218,6 +218,9 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
       }
       return window.api.openPath(path, app)
     },
+    async revealPath(path: string) {
+      return window.api.revealPath(path)
+    },
 
     back() {
       window.history.back()

+ 11 - 1
packages/session-ui/src/components/message-file.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import type { FilePart } from "@opencode-ai/sdk/v2"
-import { attached, inline, kind } from "./message-file"
+import { attached, inline, kind, typeLabel } from "./message-file"
 
 function file(part: Partial<FilePart> = {}): FilePart {
   return {
@@ -52,4 +52,14 @@ describe("message-file", () => {
     expect(kind(file({ mime: "image/png" }))).toBe("image")
     expect(kind(file({ mime: "application/pdf" }))).toBe("file")
   })
+
+  test("labels attachment types from the basename extension", () => {
+    expect(typeLabel("list.md", "text/plain")).toBe("Markdown")
+    expect(typeLabel("/repo/src/main.ts", "text/plain")).toBe("TypeScript")
+    expect(typeLabel("/tmp/report.pdf", "application/pdf")).toBe("PDF")
+    expect(typeLabel("notes.xyz", "text/plain")).toBe("XYZ")
+    expect(typeLabel("/home/user/my.project/Makefile", "text/plain")).toBe("File")
+    expect(typeLabel(".gitignore", "text/plain")).toBe("File")
+    expect(typeLabel("/repo/.env", "text/plain")).toBe("File")
+  })
 })

+ 21 - 0
packages/session-ui/src/components/message-file.ts

@@ -1,3 +1,5 @@
+import { bundledLanguagesInfo } from "shiki"
+import { getFilename } from "@opencode-ai/core/util/path"
 import type { FilePart } from "@opencode-ai/sdk/v2"
 
 export function attached(part: FilePart) {
@@ -12,3 +14,22 @@ export function inline(part: FilePart) {
 export function kind(part: FilePart) {
   return part.mime.startsWith("image/") ? "image" : "file"
 }
+
+// language metadata only; grammars stay behind shiki's lazy imports
+const LANGUAGE_NAMES = new Map<string, string>(
+  bundledLanguagesInfo.flatMap((info) =>
+    [info.id, ...(info.aliases ?? [])].map((alias) => [alias, info.name] as [string, string]),
+  ),
+)
+
+// attachments carry text/plain for all text files, so the label comes from the extension;
+// filename may be an absolute path, so extract the basename before looking for one
+export function typeLabel(filename: string, mime: string) {
+  if (mime === "application/pdf") return "PDF"
+  const base = getFilename(filename)
+  // idx 0 is a dotfile like .gitignore, not an extension
+  const idx = base.lastIndexOf(".")
+  const suffix = idx <= 0 ? "" : base.slice(idx + 1).toLowerCase()
+  if (!suffix) return "File"
+  return LANGUAGE_NAMES.get(suffix) ?? suffix.toUpperCase()
+}

+ 24 - 2
packages/session-ui/src/components/message-part.css

@@ -57,8 +57,20 @@
     }
 
     &[data-type="image"] {
-      width: 48px;
-      height: 48px;
+      position: relative;
+      width: 58px;
+      height: 46px;
+      border: none;
+
+      /* inset box-shadows do not paint over <img> content, so the hairline is an overlay */
+      &::after {
+        content: "";
+        position: absolute;
+        inset: 0;
+        border-radius: inherit;
+        box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
+        pointer-events: none;
+      }
     }
 
     &[data-type="file"] {
@@ -1323,6 +1335,16 @@ body:not([data-new-layout]) {
       &:hover {
         border-color: var(--border-strong-base);
       }
+
+      &[data-type="image"] {
+        width: 48px;
+        height: 48px;
+        border: 1px solid var(--border-weak-base);
+
+        &::after {
+          content: none;
+        }
+      }
     }
 
     [data-slot="user-message-attachment-name"] {

+ 62 - 21
packages/session-ui/src/components/message-part.tsx

@@ -47,6 +47,8 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
 import { Markdown } from "./markdown"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path"
+import { AttachmentCardV2 } from "../v2/components/attachment-card-v2"
+import { CommentCardV2 } from "../v2/components/comment-card-v2"
 import { checksum } from "@opencode-ai/core/util/encode"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -60,7 +62,7 @@ import { ToolStatusTitle } from "./tool-status-title"
 import { patchFiles } from "./apply-patch-file"
 import { animate } from "motion"
 import { useLocation } from "@solidjs/router"
-import { attached, inline, kind } from "./message-file"
+import { attached, inline, kind, typeLabel } from "./message-file"
 import { readPartText } from "./message-part-text"
 import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
 
@@ -167,6 +169,7 @@ export interface MessageProps {
   showAssistantCopyPartID?: string | null
   showReasoningSummaries?: boolean
   useV2Actions?: boolean
+  comments?: UserMessageComment[]
 }
 
 export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise<void> | void
@@ -174,6 +177,16 @@ export type SessionAction = (input: { sessionID: string; messageID: string }) =>
 export type UserActions = {
   fork?: SessionAction
   revert?: SessionAction
+  openAttachment?: (file: FilePart) => void
+}
+
+export type UserMessageComment = {
+  path: string
+  comment: string
+  selection?: {
+    startLine: number
+    endLine: number
+  }
 }
 
 export interface MessagePartProps {
@@ -946,6 +959,7 @@ export function Message(props: MessageProps) {
             parts={props.parts}
             actions={props.actions}
             useV2Actions={props.useV2Actions}
+            comments={props.comments}
           />
         )}
       </Match>
@@ -1163,6 +1177,7 @@ export function UserMessageDisplay(props: {
   parts: PartType[]
   actions?: UserActions
   useV2Actions?: boolean
+  comments?: UserMessageComment[]
 }) {
   const data = useData()
   const dialog = useDialog()
@@ -1184,6 +1199,8 @@ export function UserMessageDisplay(props: {
 
   const attachments = createMemo(() => files().filter(attached))
 
+  const messageComments = createMemo(() => (newLayout() ? (props.comments ?? []) : []))
+
   const inlineFiles = createMemo(() => files().filter(inline))
 
   const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
@@ -1240,35 +1257,59 @@ export function UserMessageDisplay(props: {
 
   return (
     <div data-component="user-message" data-timeline-part-id={textPart()?.id}>
-      <Show when={attachments().length > 0}>
+      <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 (
-                <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={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>
+                  }
                 >
-                  <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>
-                    }
+                  <AttachmentCardV2
+                    title={getFilename(name)}
+                    hover={name}
+                    clickable={!!props.actions?.openAttachment}
+                    onClick={() => props.actions?.openAttachment?.(file)}
                   >
-                    <img data-slot="user-message-attachment-image" src={file.url} alt={name} />
-                  </Show>
-                </div>
+                    {typeLabel(name, file.mime)}
+                  </AttachmentCardV2>
+                </Show>
               )
             }}
           </For>

+ 58 - 0
packages/session-ui/src/v2/components/attachment-card-v2.css

@@ -0,0 +1,58 @@
+[data-component="attachment-card-v2"] {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  box-sizing: border-box;
+  width: 160px;
+  min-width: 160px;
+  max-width: 160px;
+  padding: 8px;
+  border-radius: 6px;
+  background: var(--v2-overlay-simple-overlay-hover);
+  box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
+  cursor: default;
+
+  &[data-active] {
+    box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
+  }
+
+  &[data-clickable] {
+    cursor: pointer;
+  }
+
+  [data-slot="attachment-card-v2-title"],
+  [data-slot="attachment-card-v2-subtitle"] {
+    max-width: 100%;
+    font-family: var(--v2-font-family-sans, "Inter", sans-serif);
+    font-style: normal;
+    font-size: 11px;
+    line-height: 12px;
+    letter-spacing: 0.05px;
+    font-variation-settings: "slnt" 0;
+  }
+
+  [data-slot="attachment-card-v2-title"] {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-weight: 530;
+    color: var(--v2-text-text-base);
+  }
+
+  [data-slot="attachment-card-v2-subtitle"] {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    min-width: 0;
+    overflow: hidden;
+    white-space: nowrap;
+    font-weight: 440;
+    color: var(--v2-text-text-muted);
+
+    [data-component="file-icon"] {
+      width: 12px;
+      height: 12px;
+      flex: none;
+    }
+  }
+}

+ 26 - 0
packages/session-ui/src/v2/components/attachment-card-v2.tsx

@@ -0,0 +1,26 @@
+import type { JSX } from "solid-js"
+import "./attachment-card-v2.css"
+
+/** Shared 160px two-line card used by v2 file and comment attachments in the composer and timeline. */
+export function AttachmentCardV2(props: {
+  title: string
+  active?: boolean
+  clickable?: boolean
+  /** native title attribute */
+  hover?: string
+  onClick?: () => void
+  children: JSX.Element
+}) {
+  return (
+    <div
+      data-component="attachment-card-v2"
+      data-active={props.active ? "true" : undefined}
+      data-clickable={props.clickable ? "true" : undefined}
+      title={props.hover}
+      onClick={() => props.onClick?.()}
+    >
+      <span data-slot="attachment-card-v2-title">{props.title}</span>
+      <span data-slot="attachment-card-v2-subtitle">{props.children}</span>
+    </div>
+  )
+}

+ 27 - 0
packages/session-ui/src/v2/components/comment-card-v2.tsx

@@ -0,0 +1,27 @@
+import { Show } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { getFilenameTruncated } from "@opencode-ai/core/util/path"
+import { AttachmentCardV2 } from "./attachment-card-v2"
+
+export function CommentCardV2(props: {
+  comment: string
+  path: string
+  selection?: { startLine: number; endLine: number }
+  active?: boolean
+  title?: string
+  onClick?: () => void
+}) {
+  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>
+  )
+}

+ 4 - 0
packages/storybook/.storybook/mocks/app/context/prompt.ts

@@ -60,6 +60,10 @@ export function isPromptEqual(a: Prompt, b: Prompt) {
   return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i]))
 }
 
+export function isCommentItem(item: ContextItem) {
+  return !!item.comment?.trim()
+}
+
 export function createPromptState() {
   const [store, setStore] = createStore({
     prompt: clonePrompt(DEFAULT_PROMPT),

+ 4 - 0
packages/ui/src/v2/components/icon.tsx

@@ -89,6 +89,10 @@ const icons = {
     viewBox: "0 0 16 16",
     body: `<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor"/>`,
   },
+  "outline-xmark": {
+    viewBox: "0 0 16 16",
+    body: `<path fill-rule="evenodd" clip-rule="evenodd" d="M4.99487 5.70186L7.29297 7.99995L4.99487 10.2981L5.70198 11.0052L8.00008 8.70706L10.2982 11.0052L11.0053 10.2981L8.70718 7.99995L11.0053 5.70186L10.2982 4.99475L8.00008 7.29285L5.70198 4.99475L4.99487 5.70186Z" fill="currentColor"/>`,
+  },
   "outline-chevron-down": {
     viewBox: "0 0 16 16",
     body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`,