@@ -209,13 +249,8 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
- onClick={() =>
- props.onFileClick?.({
- ...row().node,
- path: row().node.originalPath,
- absolute: row().node.originalPath,
- })
- }
+ onClick={() => selectFile(row().node, props.onFileClick)}
+ onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
>
0}>
@@ -239,17 +274,13 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
- aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
- onClick={() =>
- file.tree.state(row().node.path)?.expanded === false
- ? file.tree.expand(row().node.path, { list: false })
- : file.tree.collapse(row().node.path)
- }
+ aria-expanded={expanded(row().node.path)}
+ onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
>
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 324a789825ae..7db0583c5167 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/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,
@@ -36,6 +37,8 @@ import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
@@ -1333,6 +1336,7 @@ export const PromptInput: Component
= (props) => {
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
+ model: props.controls.model.selection,
})
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1623,7 +1627,7 @@ export const PromptInput: Component = (props) => {
)}
/>
!isCommentItem(item))}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1633,6 +1637,7 @@ export const PromptInput: Component = (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[0])}
/>
= (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)
+ }}
/>
= (props) => {
>
}
+ variant="ghost-muted"
+ size="large"
style={buttons()}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.menu.addImagesAndFiles")}
/>
-
-
+
)
}}
diff --git a/packages/app/src/components/prompt-input/image-attachments.css b/packages/app/src/components/prompt-input/image-attachments.css
new file mode 100644
index 000000000000..2aaf494ebfc4
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx
index dd8138e5a4f2..3a0cc3e4dd75 100644
--- a/packages/app/src/components/prompt-input/image-attachments.tsx
+++ b/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
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 = (props) => {
return (
- 0}>
-
-
- {(attachment) => (
-
-
+
0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
+
+
+
+
+ {(item) => (
+
+
+ props.onOpenComment?.(item)}
+ />
+
+
+
+ )}
+
+
+
+ {(attachment) => {
+ const image = attachment.mime.startsWith("image/")
+ const media = () => (
-
-
+
+
+
+ }
+ >
+
+ {typeLabel(attachment.filename, attachment.mime)}
+
+
}
>

props.onOpen(attachment)}
/>
+ )
+ const name = () => (
+
+ {attachment.filename}
+
+ )
+ const remove = () => (
-
- {attachment.filename}
-
-
-
- )}
-
+ )
+ // v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
+ return (
+
+
+ {media()}
+ {name()}
+ {remove()}
+
+
+ }
+ >
+
+
+ {media()}
+
+
+
+
+ {remove()}
+
+
+ )
+ }}
+
+
+
+
+
+
)
diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts
index 9a65fa101dd1..ad9fd31db811 100644
--- a/packages/app/src/components/prompt-input/submit.test.ts
+++ b/packages/app/src/components/prompt-input/submit.test.ts
@@ -1,5 +1,6 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
+import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
@@ -33,6 +34,10 @@ const prompt = {
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
+ model: {
+ current: () => undefined,
+ set: () => undefined,
+ },
reset: () => undefined,
set: () => undefined,
context: {
@@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => {
})
})
+ test("uses an injected model selection", async () => {
+ params = { id: "session-1" }
+ const model = {
+ current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
+ variant: { current: () => "draft-variant" },
+ } as unknown as ModelSelection
+ const submit = createPromptSubmit({
+ prompt,
+ info: () => ({ id: "session-1" }),
+ imageAttachments: () => [],
+ commentCount: () => 0,
+ autoAccept: () => false,
+ mode: () => "normal",
+ working: () => false,
+ editor: () => undefined,
+ queueScroll: () => undefined,
+ promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+ addToHistory: () => undefined,
+ resetHistoryNavigation: () => undefined,
+ setMode: () => undefined,
+ setPopover: () => undefined,
+ model,
+ })
+
+ await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+
+ expect(optimistic[0]).toMatchObject({
+ message: {
+ model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
+ },
+ })
+ })
+
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 05d978a947ce..c9f560d26929 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
-import { batch, type Accessor } from "solid-js"
+import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
-import { useLocal } from "@/context/local"
+import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk"
@@ -191,6 +191,7 @@ type PromptSubmitInput = {
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
+ model?: ModelSelection
}
export function createPromptSubmit(input: PromptSubmitInput) {
@@ -298,9 +299,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
- const currentModel = local.model.current()
+ const modelSelection = input.model ?? local.model
+ const currentModel = modelSelection.current()
const currentAgent = local.agent.current()
- const variant = local.model.variant.current()
+ const variant = modelSelection.variant.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -374,13 +376,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (created) {
seed(sessionDirectory, created)
session = created
- if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
- local.session.promote(sessionDirectory, session.id)
- layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
- const draftID = search.draftId
- if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
- else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
- submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
+ await startTransition(() => {
+ if (!session) return
+ if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
+ local.session.promote(sessionDirectory, session.id, {
+ agent: currentAgent.name,
+ model: { providerID: currentModel.provider.id, modelID: currentModel.id },
+ variant: variant ?? null,
+ })
+ layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
+ const draftID = search.draftId
+ if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
+ else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
+ submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
+ })
}
}
if (!session) {
diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx
index 27d8c97ca2a7..35ac4f9c6edd 100644
--- a/packages/app/src/components/prompt-workspace-selector.tsx
+++ b/packages/app/src/components/prompt-workspace-selector.tsx
@@ -1,5 +1,6 @@
import { For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -96,10 +97,17 @@ export function PromptWorkspaceSelector(props: {
{(branch) => (
<>
/
-
-
- {branch()}
-
+
+
+
+ {branch()}
+
+
>
)}
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
index fd2d1dd1a30e..c1e0ccde0597 100644
--- a/packages/app/src/components/session-context-usage.tsx
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -12,7 +12,7 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
-import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
+import { getSessionContext } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSettings } from "@/context/settings"
@@ -74,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
)
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
- const tokens = createMemo(() => info()?.tokens)
const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0)
})
@@ -132,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
)
diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts
index 8e424f0f36f7..9e44cea17be6 100644
--- a/packages/app/src/components/session/index.ts
+++ b/packages/app/src/components/session/index.ts
@@ -1,6 +1,7 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
+export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
diff --git a/packages/app/src/components/session/open-in-app-v2.tsx b/packages/app/src/components/session/open-in-app-v2.tsx
new file mode 100644
index 000000000000..e26ff2e0eaba
--- /dev/null
+++ b/packages/app/src/components/session/open-in-app-v2.tsx
@@ -0,0 +1,98 @@
+import { For, Show } from "solid-js"
+import { AppIcon } from "@opencode-ai/ui/app-icon"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { useLanguage } from "@/context/language"
+import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
+
+export function OpenInAppV2(props: { directory: () => string }) {
+ const language = useLanguage()
+ const state = useOpenInApp(props)
+
+ return (
+