chore: merge dev into v2 (#35962)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-07-08 17:58:55 -05:00
committed by GitHub
co-authored by opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> James Long Aiden Cline Dustin Deus starptech Frank Luke Parker opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 Aiden Cline Dax usrnk1 Jay Brendan Allan Brendan Allan runvip Shoubhit Dash Jack opencode Aarav Sareen Julian Coy Vladimir Glafirov Adam Kit Langton Simon Klee Jay David Hill James Long 冯基魁 Aiden Cline
parent 2347f4d7b4
commit 687dbba6a3
150 changed files with 8943 additions and 728 deletions
@@ -0,0 +1,325 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js"
import { useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = {
id: string
type: "command" | "file" | "session"
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
const seen = new Set<string>()
return items.filter((item) => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
return {
id: "file:" + path,
type: "file",
title: path,
category,
path,
}
}
export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
const file = useFile()
const layout = useLayout()
const { tabs, view } = useSessionLayout()
return (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
onOpenFile?.(path)
tabs().setActive(value)
}
}
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()()
const serverSync = useServerSync()
const { params, tabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => {
if (filesOnly()) return []
return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandEntry(option, category))
})
const preferredCommandEntries = createMemo(() => {
const all = allowedCommands()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const recentFileEntries = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = language.t("palette.group.files")
return order
.map((item) => file.pathFromTab(item))
.filter((path): path is string => {
if (!path || seen.has(path)) return false
seen.add(path)
return true
})
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const rootFileEntries = createMemo(() => {
const category = language.t("palette.group.files")
return file.tree
.children("")
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return undefined
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const sessions = createSessionEntries({
workspaces,
label: (directory) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
openFile(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return {
language,
file,
commandEntries,
preferredCommandEntries,
recentFileEntries,
rootFileEntries,
sessions,
highlight,
select,
close: () => dialog.close(),
}
}
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return {
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
}
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
untitled: () => string
category: () => string
}) {
const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
return (text: string) => {
if (!text.trim()) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as CommandPaletteEntry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as CommandPaletteEntry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props
.load(directory)
.then((result) =>
(result.data ?? [])
.filter((session) => !!session?.id)
.map((session) => ({
id: session.id,
title: session.title ?? props.untitled(),
description,
directory,
archived: session.time?.archived,
updated: session.time?.updated,
})),
)
.catch(() => [] as SessionEntryInput[])
}),
)
.then((results) => {
if (state.token !== current) return [] as CommandPaletteEntry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, props.category()))
state.cached = next
return next
})
.catch(() => [] as CommandPaletteEntry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
}
type SessionEntryInput = {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
return {
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
}
}
@@ -0,0 +1,220 @@
.command-palette-v2 {
overflow: hidden;
}
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
top stays put while the content-driven height grows and shrinks. */
[data-component="dialog-v2"]:has(.command-palette-v2) {
align-items: flex-start;
}
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
width: min(calc(100vw - 24px), 640px);
height: auto;
min-height: 280px;
max-height: min(calc(100vh - 96px), 480px);
margin-top: max(48px, calc((100vh - 480px) / 2));
border-radius: 12px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-floating);
}
.command-palette-v2-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 0;
padding: 0;
}
.command-palette-v2-search {
flex-shrink: 0;
padding: 6px;
}
.command-palette-v2-search [data-component="text-input-v2"] {
width: 100%;
height: 36px;
border-radius: 6px;
outline: 0;
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
transition:
background-color 120ms ease-in-out,
box-shadow 120ms ease-in-out;
}
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
background: var(--v2-background-bg-layer-02);
box-shadow: none;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
border-radius: 6px;
background: transparent;
gap: 8px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
padding-left: 12px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
border-radius: 6px;
background: transparent;
}
.command-palette-v2-scroll {
min-height: 0;
flex: 1;
}
.command-palette-v2-results {
display: flex;
flex-direction: column;
gap: 16px;
padding: 6px 6px 8px;
}
.command-palette-v2-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.command-palette-v2-group-title {
margin: 6px 0;
padding: 0 12px;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
user-select: none;
}
.command-palette-v2-row {
display: flex;
width: 100%;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--v2-text-text-base);
text-align: left;
cursor: default;
scroll-margin: 6px 0;
}
.command-palette-v2-row[data-active] {
background: var(--v2-overlay-simple-overlay-hover);
}
.command-palette-v2-row:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
.command-palette-v2-row-main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 8px;
}
.command-palette-v2-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.command-palette-v2-row-text {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.command-palette-v2-title {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-description,
.command-palette-v2-meta {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-meta {
flex-shrink: 0;
}
.command-palette-v2-file-path {
display: flex;
min-width: 0;
align-items: baseline;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
}
.command-palette-v2-file-dir {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-weight: 440;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-file-name {
flex-shrink: 0;
color: var(--v2-text-text-base);
font-weight: 530;
white-space: nowrap;
}
.command-palette-v2-state {
display: grid;
min-height: 120px;
place-items: center;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
}
@media (max-width: 640px) {
.command-palette-v2-row-text {
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.command-palette-v2-description {
max-width: 100%;
}
}
@@ -0,0 +1,221 @@
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { formatKeybindParts } from "@/context/command"
import { useLanguage } from "@/context/language"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import "./dialog-command-palette-v2.css"
function groups(entries: CommandPaletteEntry[]) {
const map = new Map<string, CommandPaletteEntry[]>()
for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
}
function matchesEntry(entry: CommandPaletteEntry, query: string) {
const value = query.toLowerCase()
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
}
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => {
const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions.filter((entry) => matchesEntry(entry, q)),
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
}
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()])
createEffect(() => {
query()
visibleEntries()
setActive(0)
})
createEffect(() => {
palette.highlight(activeEntry())
})
let resultsRef: HTMLDivElement | undefined
const move = (delta: -1 | 1) => {
const count = visibleEntries().length
if (count === 0) return
setActive((index) => (index + delta + count) % count)
requestAnimationFrame(() => {
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
})
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault()
move(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
move(-1)
return
}
if (event.key === "Enter") {
event.preventDefault()
palette.select(activeEntry())
return
}
if (event.key === "Escape") {
event.preventDefault()
palette.close()
}
}
return (
<Dialog class="command-palette-v2" size="large">
<DialogBody class="command-palette-v2-body">
<div class="command-palette-v2-search">
<TextInputV2
value={query()}
autofocus
autocomplete="off"
spellcheck={false}
appearance="large"
placeholder={palette.language.t("palette.search.placeholder")}
leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
</div>
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-v2-results" role="listbox">
<Show
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-v2-state">
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
</div>
}
>
<For each={groupedEntries()}>
{(group) => (
<div class="command-palette-v2-group">
<Show when={group.category}>
<div class="command-palette-v2-group-title">{group.category}</div>
</Show>
<For each={group.entries}>
{(item) => (
<PaletteRow
item={item}
active={activeEntry()?.id === item.id}
language={palette.language}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => palette.select(item)}
/>
)}
</For>
</div>
)}
</For>
</Show>
</div>
</ScrollView>
</DialogBody>
</Dialog>
)
}
function PaletteRow(props: {
item: CommandPaletteEntry
active: boolean
language: ReturnType<typeof useLanguage>
onActive: () => void
onSelect: () => void
}) {
return (
<button
type="button"
class="command-palette-v2-row"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
onMouseMove={(event) => {
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
if (event.movementX === 0 && event.movementY === 0) return
props.onActive()
}}
onMouseDown={(event) => event.preventDefault()}
onClick={props.onSelect}
>
<Switch
fallback={
<div class="command-palette-v2-row-main">
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
<div class="command-palette-v2-file-path">
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
</div>
</div>
}
>
<Match when={props.item.type === "command"}>
<div class="command-palette-v2-row-main">
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title">{props.item.title}</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description">{props.item.description}</span>
</Show>
</div>
</div>
<Show when={props.item.keybind}>
<KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
</Show>
</Match>
<Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main">
<Icon name="status" class="command-palette-v2-row-icon" />
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title}
</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.description}
</span>
</Show>
</div>
</div>
<Show when={props.item.updated}>
<span class="command-palette-v2-meta">
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
</span>
</Show>
</Match>
</Switch>
</button>
)
}
@@ -1,329 +1,84 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteFileOpener,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type EntryType = "command" | "file" | "session"
type Entry = {
id: string
type: EntryType
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
const DialogCommandPaletteV2 = lazy(() =>
import("./dialog-command-palette-v2").then((module) => ({ default: module.DialogCommandPaletteV2 })),
)
type DialogSelectFileMode = "all" | "files"
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
const uniqueEntries = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
})
const createFileEntry = (path: string, category: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category,
path,
})
const createSessionEntry = (
input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
},
category: string,
): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
function createCommandEntries(props: {
filesOnly: () => boolean
command: ReturnType<typeof useCommand>
language: ReturnType<typeof useLanguage>
}) {
const allowed = createMemo(() => {
if (props.filesOnly()) return []
return props.command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const list = createMemo(() => {
const category = props.language.t("palette.group.commands")
return allowed().map((option) => createCommandEntry(option, category))
})
const picks = createMemo(() => {
const all = allowed()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = props.language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
return { allowed, list, picks }
}
function createFileEntries(props: {
file: ReturnType<typeof useFile>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage>
}) {
const tabState = createSessionTabs({
tabs: props.tabs,
pathFromTab: props.file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
})
const recent = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = props.language.t("palette.group.files")
const items: Entry[] = []
for (const item of order) {
const path = props.file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(createFileEntry(path, category))
}
return items.slice(0, ENTRY_LIMIT)
})
const root = createMemo(() => {
const category = props.language.t("palette.group.files")
const nodes = props.file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
})
return { recent, root }
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
serverSDK: ServerSDK
language: ReturnType<typeof useLanguage>
}) {
const state: {
token: number
inflight: Promise<Entry[]> | undefined
cached: Entry[] | undefined
} = {
token: 0,
inflight: undefined,
cached: undefined,
}
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as Entry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as Entry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.serverSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? props.language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(
() =>
[] as {
id: string
title: string
description: string
directory: string
archived?: number
updated?: number
}[],
)
}),
)
.then((results) => {
if (state.token !== current) return [] as Entry[]
const seen = new Set<string>()
const category = props.language.t("command.category.session")
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, category))
state.cached = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
return { sessions }
}
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files"
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const commandEntries = createCommandEntries({ filesOnly, command, language })
const fileEntries = createFileEntries({ file, tabs, language })
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
if (!filesOnly() && settings.general.newLayoutDesigns()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
}
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
}
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
}
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
const language = useLanguage()
const serverSDK = useServerSDK()
const { params } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
openFile(result)
}}
/>
)
}
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [grouped, setGrouped] = createSignal(false)
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
if (!query && filesOnly()) {
const loaded = file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : file.tree.list("")
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
if (!query && props.filesOnly()) {
const loaded = palette.file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
if (loaded || next.length > 0) {
void pending
@@ -331,79 +86,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
}
await pending
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
}
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
if (filesOnly()) {
const files = await file.searchFiles(query)
const category = language.t("palette.group.files")
return files.map((path) => createFileEntry(path, category))
if (props.filesOnly()) {
const files = await palette.file.searchFiles(query)
const category = palette.language.t("palette.group.files")
return files.map((path) => createCommandPaletteFileEntry(path, category))
}
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const category = language.t("palette.group.files")
const entries = files.map((path) => createFileEntry(path, category))
return [...commandEntries.list(), ...nextSessions, ...entries]
}
const handleMove = (item: Entry | undefined) => {
state.cleanup?.()
if (!item) return
if (item.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const open = (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
tabs().setActive(value)
}
const handleSelect = (item: Entry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
open(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
open(result)
}}
/>
)
const [files, nextSessions] = await Promise.all([
palette.file.searchFiles(query),
Promise.resolve(palette.sessions(query)),
])
const category = palette.language.t("palette.group.files")
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
return [...palette.commandEntries(), ...nextSessions, ...entries]
}
return (
@@ -411,21 +111,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
<List
class="px-3"
search={{
placeholder: filesOnly()
? language.t("session.header.searchFiles")
: language.t("palette.search.placeholder"),
placeholder: props.filesOnly()
? palette.language.t("session.header.searchFiles")
: palette.language.t("palette.search.placeholder"),
autofocus: true,
hideIcon: true,
}}
emptyMessage={language.t("palette.empty")}
loadingMessage={language.t("common.loading")}
emptyMessage={palette.language.t("palette.empty")}
loadingMessage={palette.language.t("common.loading")}
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove}
onSelect={handleSelect}
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
>
{(item) => (
<Switch
@@ -452,7 +152,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
</Show>
</div>
</Match>
@@ -479,7 +179,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</div>
<Show when={item.updated}>
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
</span>
</Show>
</div>
@@ -24,6 +24,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
@@ -92,6 +93,7 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
@@ -446,26 +448,41 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.RadioGroup value={current()}>
<For each={group.items}>
{(item) => (
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
</TooltipV2>
)}
</For>
</MenuV2.RadioGroup>
+35 -2
View File
@@ -1,4 +1,4 @@
import { Show, type Component } from "solid-js"
import { Show, type Component, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
@@ -23,7 +23,18 @@ type ModelInfo = {
}
}
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => {
function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
return (
<div class="flex min-w-0 items-center gap-4">
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
</div>
)
}
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = (
props,
) => {
const language = useLanguage()
const sourceName = (model: ModelInfo) => {
const value = `${model.id} ${model.name}`.toLowerCase()
@@ -51,6 +62,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${sourceName(props.model)} ${props.model.name}${suffix}`
}
const name = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${props.model.name}${suffix}`
}
const inputs = () => {
if (props.model.capabilities) {
const input = props.model.capabilities.input
@@ -73,6 +91,21 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
: language.t("model.tooltip.reasoning.none")
}
const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
const contextLimit = () => props.model.limit.context.toLocaleString(language.intl())
if (props.v2) {
return (
<div class="flex w-[180px] flex-col gap-2">
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
<Show when={inputs()}>
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
</Show>
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
</div>
)
}
return (
<div class="flex flex-col gap-1 py-1">
@@ -440,11 +440,6 @@ export const SettingsGeneral: Component = () => {
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -471,11 +466,6 @@ export const SettingsGeneral: Component = () => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -422,11 +422,6 @@ export const SettingsGeneralV2: Component<{
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -454,11 +449,6 @@ export const SettingsGeneralV2: Component<{
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -246,18 +246,23 @@ export function TabNavItem(props: {
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<Show when={props.session()}>
{(session) => (
<span data-slot="project-avatar-slot">
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
{(session) => (
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
</span>
)}
</Show>
)}
</Show>
</span>
<span
ref={(el) => {
titleEl = el
@@ -40,6 +40,7 @@ function SessionTabSlot(props: {
let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx()
@@ -71,8 +72,10 @@ function SessionTabSlot(props: {
createEffect(() => {
const value = session()
if (!value) return
tabs.rememberSessionInfo(props.tab, value)
const current = sdk()
if (!value || !current) return
if (!current) return
createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.directory),
id: value.id,
@@ -86,7 +89,7 @@ function SessionTabSlot(props: {
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() && !missingSession() }}
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
>
<TabNavItem
ref={(el) => {
@@ -95,7 +98,7 @@ function SessionTabSlot(props: {
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()