Explorar o código

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

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
usrnk1 hai 1 mes
pai
achega
dedb4133dc

+ 51 - 4
packages/session-ui/src/components/message-part.css

@@ -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);

+ 136 - 98
packages/session-ui/src/components/message-part.tsx

@@ -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))
   }
 
-  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")
+  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)
-                      }}
+            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>
+                      }
                     >
-                      <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>
-                  }
+                      <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)}
                 >
-                  <AttachmentCardV2
-                    title={getFilename(name)}
-                    hover={name}
-                    clickable={!!props.actions?.openAttachment}
-                    onClick={() => props.actions?.openAttachment?.(file)}
-                  >
-                    {typeLabel(name, file.mime)}
-                  </AttachmentCardV2>
-                </Show>
-              )
-            }}
-          </For>
+                  {typeLabel(name, file.mime)}
+                </AttachmentCardV2>
+              </Show>
+            )
+          }}
+        </For>
+      </div>
+    </Show>
+  )
+
+  return (
+    <div data-component="user-message" data-timeline-part-id={textPart()?.id}>
+      <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>
   )

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

@@ -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;

+ 8 - 1
packages/session-ui/src/v2/components/attachment-card-v2.tsx

@@ -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>
   )

+ 49 - 12
packages/session-ui/src/v2/components/comment-card-v2.tsx

@@ -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>
   )
 }

+ 1 - 0
packages/ui/src/i18n/ar.ts

@@ -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": "السماح دائمًا",

+ 1 - 0
packages/ui/src/i18n/br.ts

@@ -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",

+ 1 - 0
packages/ui/src/i18n/bs.ts

@@ -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",

+ 1 - 0
packages/ui/src/i18n/da.ts

@@ -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",

+ 1 - 0
packages/ui/src/i18n/de.ts

@@ -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",

+ 1 - 0
packages/ui/src/i18n/en.ts

@@ -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",

+ 1 - 0
packages/ui/src/i18n/es.ts

@@ -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",

+ 1 - 0
packages/ui/src/i18n/fr.ts

@@ -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",

+ 1 - 0
packages/ui/src/i18n/ja.ts

@@ -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": "常に許可",

+ 1 - 0
packages/ui/src/i18n/ko.ts

@@ -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": "항상 허용",

+ 1 - 0
packages/ui/src/i18n/no.ts

@@ -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",

+ 1 - 0
packages/ui/src/i18n/pl.ts

@@ -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",

+ 1 - 0
packages/ui/src/i18n/ru.ts

@@ -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": "Разрешить всегда",

+ 1 - 0
packages/ui/src/i18n/th.ts

@@ -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": "อนุญาตเสมอ",

+ 1 - 0
packages/ui/src/i18n/tr.ts

@@ -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",

+ 1 - 0
packages/ui/src/i18n/uk.ts

@@ -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": "Дозволяти завжди",

+ 1 - 0
packages/ui/src/i18n/zh.ts

@@ -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": "始终允许",

+ 1 - 0
packages/ui/src/i18n/zht.ts

@@ -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": "永遠允許",