Skip to content

Commit b4e49d5

Browse files
arvsrnBrendonovich
andauthored
feat(app): redesign attachment cards (anomalyco#35945)
Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
1 parent 2b29854 commit b4e49d5

25 files changed

Lines changed: 499 additions & 55 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,10 @@ test.describe("session timeline projection", () => {
152152
parentID: "msg_2000_diff_next_user",
153153
created: 1700000011000,
154154
})
155-
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
155+
await setupTimeline(page, {
156+
messages: [user, assistantMessage(), nextUser, nextAssistant],
157+
settings: { newLayoutDesigns: false },
158+
})
156159
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
157160
await scroller.evaluate((element) => (element.scrollTop = 0))
158161

packages/app/src/components/prompt-input.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
1919
import {
2020
ContentPart,
2121
DEFAULT_PROMPT,
22+
isCommentItem,
2223
isPromptEqual,
2324
Prompt,
2425
usePrompt,
@@ -1626,7 +1627,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
16261627
)}
16271628
/>
16281629
<PromptContextItems
1629-
items={contextItems()}
1630+
items={contextItems().filter((item) => !isCommentItem(item))}
16301631
active={(item) => {
16311632
const active = comments.active()
16321633
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1636,6 +1637,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
16361637
if (item.commentID) comments.remove(item.path, item.commentID)
16371638
prompt.context.remove(item.key)
16381639
}}
1640+
newLayoutDesigns={props.controls.newLayoutDesigns}
16391641
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
16401642
/>
16411643
<PromptImageAttachments
@@ -1645,6 +1647,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
16451647
}
16461648
onRemove={removeAttachment}
16471649
removeLabel={language.t("prompt.attachment.remove")}
1650+
newLayoutDesigns={props.controls.newLayoutDesigns}
1651+
comments={contextItems().filter(isCommentItem)}
1652+
commentActive={(item) => {
1653+
const active = comments.active()
1654+
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
1655+
}}
1656+
onOpenComment={openComment}
1657+
onRemoveComment={(item) => {
1658+
if (item.commentID) comments.remove(item.path, item.commentID)
1659+
prompt.context.remove(item.key)
1660+
}}
16481661
/>
16491662
<div
16501663
class="relative min-h-[52px]"
@@ -1852,6 +1865,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
18521865
if (item.commentID) comments.remove(item.path, item.commentID)
18531866
prompt.context.remove(item.key)
18541867
}}
1868+
newLayoutDesigns={props.controls.newLayoutDesigns}
18551869
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
18561870
/>
18571871
<PromptImageAttachments
@@ -1861,6 +1875,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
18611875
}
18621876
onRemove={removeAttachment}
18631877
removeLabel={language.t("prompt.attachment.remove")}
1878+
newLayoutDesigns={props.controls.newLayoutDesigns}
18641879
/>
18651880
<div
18661881
class="relative"

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Component, For, Show } from "solid-js"
2+
import { Dynamic } from "solid-js/web"
23
import { FileIcon } from "@opencode-ai/ui/file-icon"
34
import { IconButton } from "@opencode-ai/ui/icon-button"
5+
import { Tooltip } from "@opencode-ai/ui/tooltip"
46
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
57
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
68
import type { ContextItem } from "@/context/prompt"
@@ -12,6 +14,7 @@ type ContextItemsProps = {
1214
active: (item: PromptContextItem) => boolean
1315
openComment: (item: PromptContextItem) => void
1416
remove: (item: PromptContextItem) => void
17+
newLayoutDesigns: boolean
1518
t: (key: string) => string
1619
}
1720

@@ -27,10 +30,17 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
2730
const selected = props.active(item)
2831

2932
return (
30-
<TooltipV2
33+
<Dynamic
34+
component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
3135
value={
3236
<span class="flex max-w-[300px]">
33-
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
37+
<span
38+
classList={{
39+
"truncate-start [unicode-bidi:plaintext] min-w-0": true,
40+
"text-v2-text-text-muted": props.newLayoutDesigns,
41+
"text-text-invert-base": !props.newLayoutDesigns,
42+
}}
43+
>
3444
{directory}
3545
</span>
3646
<span class="shrink-0">{filename}</span>
@@ -78,7 +88,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
7888
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
7989
</Show>
8090
</div>
81-
</TooltipV2>
91+
</Dynamic>
8292
)
8393
}}
8494
</For>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
@keyframes prompt-attachments-fade-left {
2+
from {
3+
visibility: hidden;
4+
}
5+
to {
6+
visibility: visible;
7+
}
8+
}
9+
10+
@keyframes prompt-attachments-fade-right {
11+
from {
12+
visibility: visible;
13+
}
14+
to {
15+
visibility: hidden;
16+
}
17+
}
18+
19+
[data-slot="prompt-attachments"] {
20+
timeline-scope: --prompt-attachments-scroll;
21+
22+
[data-slot^="prompt-attachments-fade-"] {
23+
visibility: hidden;
24+
}
25+
}
26+
27+
@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) {
28+
[data-slot="prompt-attachments-scroll"] {
29+
scroll-timeline: --prompt-attachments-scroll x;
30+
}
31+
32+
[data-slot="prompt-attachments-fade-left"] {
33+
animation: prompt-attachments-fade-left linear both;
34+
animation-timeline: --prompt-attachments-scroll;
35+
animation-range: 0 0.1px;
36+
}
37+
38+
[data-slot="prompt-attachments-fade-right"] {
39+
animation: prompt-attachments-fade-right linear both;
40+
animation-timeline: --prompt-attachments-scroll;
41+
animation-range: calc(100% - 1.1px) calc(100% - 1px);
42+
}
43+
}

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

Lines changed: 128 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,167 @@
11
import { Component, For, Show } from "solid-js"
22
import { Icon } from "@opencode-ai/ui/icon"
3+
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
34
import { Tooltip } from "@opencode-ai/ui/tooltip"
4-
import type { ImageAttachmentPart } from "@/context/prompt"
5+
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
6+
import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2"
7+
import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2"
8+
import { typeLabel } from "@opencode-ai/session-ui/message-file"
9+
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
10+
import "./image-attachments.css"
11+
12+
type PromptCommentItem = ContextItem & { key: string }
513

614
type PromptImageAttachmentsProps = {
715
attachments: ImageAttachmentPart[]
816
onOpen: (attachment: ImageAttachmentPart) => void
917
onRemove: (id: string) => void
1018
removeLabel: string
19+
newLayoutDesigns: boolean
20+
comments?: PromptCommentItem[]
21+
commentActive?: (item: PromptCommentItem) => boolean
22+
onOpenComment?: (item: PromptCommentItem) => void
23+
onRemoveComment?: (item: PromptCommentItem) => void
1124
}
1225

1326
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
1427
const imageClass =
1528
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
29+
const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover"
30+
// inset box-shadows do not paint over <img> content, so the hairline is a separate overlay
31+
const imageHairlineClassV2 =
32+
"absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none"
1633
const removeClass =
1734
"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"
35+
const removeClassV2 =
36+
"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"
1837
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
1938

2039
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
2140
return (
22-
<Show when={props.attachments.length > 0}>
23-
<div class="flex flex-wrap gap-2 px-3 pt-3">
24-
<For each={props.attachments}>
25-
{(attachment) => (
26-
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
27-
<div class="relative group">
41+
<Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
42+
<div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
43+
<div
44+
data-slot="prompt-attachments-scroll"
45+
classList={{
46+
"flex gap-2": true,
47+
"flex-nowrap overflow-x-auto no-scrollbar px-2 pt-2 pb-1": props.newLayoutDesigns,
48+
"flex-wrap px-3 pt-3": !props.newLayoutDesigns,
49+
}}
50+
>
51+
<Show when={props.newLayoutDesigns}>
52+
<For each={props.comments ?? []}>
53+
{(item) => (
54+
<div class="relative group shrink-0">
55+
<TooltipV2
56+
value={item.comment}
57+
placement="top"
58+
openDelay={800}
59+
contentClass="max-w-[300px] break-words"
60+
>
61+
<CommentCardV2
62+
comment={item.comment ?? ""}
63+
path={item.path}
64+
selection={item.selection}
65+
active={props.commentActive?.(item)}
66+
onClick={() => props.onOpenComment?.(item)}
67+
/>
68+
</TooltipV2>
69+
<button
70+
type="button"
71+
onClick={() => props.onRemoveComment?.(item)}
72+
class={removeClassV2}
73+
aria-label={props.removeLabel}
74+
>
75+
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
76+
</button>
77+
</div>
78+
)}
79+
</For>
80+
</Show>
81+
<For each={props.attachments}>
82+
{(attachment) => {
83+
const image = attachment.mime.startsWith("image/")
84+
const media = () => (
2885
<Show
29-
when={attachment.mime.startsWith("image/")}
86+
when={image}
3087
fallback={
31-
<div class={fallbackClass}>
32-
<Icon name="folder" class="size-6 text-text-weak" />
33-
</div>
88+
<Show
89+
when={props.newLayoutDesigns}
90+
fallback={
91+
<div class={fallbackClass}>
92+
<Icon name="folder" class="size-6 text-text-weak" />
93+
</div>
94+
}
95+
>
96+
<AttachmentCardV2 title={attachment.filename}>
97+
{typeLabel(attachment.filename, attachment.mime)}
98+
</AttachmentCardV2>
99+
</Show>
34100
}
35101
>
36102
<img
37103
src={attachment.dataUrl}
38104
alt={attachment.filename}
39-
class={imageClass}
105+
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
40106
onClick={() => props.onOpen(attachment)}
41107
/>
42108
</Show>
109+
)
110+
const name = () => (
111+
<div class={nameClass}>
112+
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
113+
</div>
114+
)
115+
const remove = () => (
43116
<button
44117
type="button"
45118
onClick={() => props.onRemove(attachment.id)}
46-
class={removeClass}
119+
class={props.newLayoutDesigns ? removeClassV2 : removeClass}
47120
aria-label={props.removeLabel}
48121
>
49-
<Icon name="close" class="size-3 text-text-weak" />
122+
<Show when={props.newLayoutDesigns} fallback={<Icon name="close" class="size-3 text-text-weak" />}>
123+
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
124+
</Show>
50125
</button>
51-
<div class={nameClass}>
52-
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
53-
</div>
54-
</div>
55-
</Tooltip>
56-
)}
57-
</For>
126+
)
127+
// v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
128+
return (
129+
<Show
130+
when={props.newLayoutDesigns}
131+
fallback={
132+
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
133+
<div class="relative group">
134+
{media()}
135+
{name()}
136+
{remove()}
137+
</div>
138+
</Tooltip>
139+
}
140+
>
141+
<div class="relative group shrink-0">
142+
<TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
143+
{media()}
144+
<Show when={image}>
145+
<div class={imageHairlineClassV2} />
146+
</Show>
147+
</TooltipV2>
148+
{remove()}
149+
</div>
150+
</Show>
151+
)
152+
}}
153+
</For>
154+
</div>
155+
<Show when={props.newLayoutDesigns}>
156+
<div
157+
data-slot="prompt-attachments-fade-left"
158+
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)]"
159+
/>
160+
<div
161+
data-slot="prompt-attachments-fade-right"
162+
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)]"
163+
/>
164+
</Show>
58165
</div>
59166
</Show>
60167
)

packages/app/src/context/platform.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ type PlatformBase = {
3737
/** Open a local path in a local app (desktop only) */
3838
openPath?(path: string, app?: string): Promise<void>
3939

40+
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
41+
revealPath?(path: string): Promise<boolean>
42+
4043
/** Restart the app */
4144
restart(): Promise<void>
4245

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ function contextItemKey(item: ContextItem) {
145145
return `${key}:c=${digest.slice(0, 8)}`
146146
}
147147

148-
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
148+
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
149149
return item.type === "file" && !!item.comment?.trim()
150150
}
151151

packages/app/src/context/prompt.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export {
2424
createPromptSession,
2525
createPromptState,
2626
DEFAULT_PROMPT,
27+
isCommentItem,
2728
isPromptEqual,
2829
} from "./prompt-state"
2930
export type {

0 commit comments

Comments
 (0)