summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-03 05:19:14 -0600
committerAdam <[email protected]>2026-02-03 05:19:43 -0600
commite709808b3214afcf5ab8582829d1339a9eac79c0 (patch)
tree0bef9f8387cb0ee054d290fd23b3ce64a61e71f2
parent0d22068c90430e217c9c903a003b22668890207c (diff)
downloadopencode-e709808b3214afcf5ab8582829d1339a9eac79c0.tar.gz
opencode-e709808b3214afcf5ab8582829d1339a9eac79c0.zip
fix(app): move session search to command palette
-rw-r--r--packages/app/src/components/dialog-select-file.tsx186
-rw-r--r--packages/app/src/pages/layout.tsx212
2 files changed, 170 insertions, 228 deletions
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx
index 64b83d31b..167f21195 100644
--- a/packages/app/src/components/dialog-select-file.tsx
+++ b/packages/app/src/components/dialog-select-file.tsx
@@ -1,17 +1,22 @@
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/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useParams } from "@solidjs/router"
-import { createMemo, createSignal, onCleanup, Show } from "solid-js"
+import { useNavigate, useParams } from "@solidjs/router"
+import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
+import { decode64 } from "@/utils/base64"
-type EntryType = "command" | "file"
+type EntryType = "command" | "file" | "session"
type Entry = {
id: string
@@ -22,6 +27,9 @@ type Entry = {
category: string
option?: CommandOption
path?: string
+ directory?: string
+ sessionID?: string
+ archived?: number
}
type DialogSelectFileMode = "all" | "files"
@@ -33,6 +41,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const file = useFile()
const dialog = useDialog()
const params = useParams()
+ const navigate = useNavigate()
+ const globalSDK = useGlobalSDK()
+ const globalSync = useGlobalSync()
const filesOnly = () => props.mode === "files"
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
@@ -73,6 +84,52 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
path,
})
+ 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(() => globalSync.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] = globalSync.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}`
+ }
+
+ const sessionItem = (input: {
+ directory: string
+ id: string
+ title: string
+ description: string
+ archived?: number
+ }): Entry => ({
+ id: `session:${input.directory}:${input.id}`,
+ type: "session",
+ title: input.title,
+ description: input.description,
+ category: language.t("command.category.session"),
+ directory: input.directory,
+ sessionID: input.id,
+ archived: input.archived,
+ })
+
const list = createMemo(() => allowed().map(commandItem))
const picks = createMemo(() => {
@@ -122,6 +179,68 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return out
}
+ const sessionToken = { value: 0 }
+ let sessionInflight: Promise<Entry[]> | undefined
+ let sessionAll: Entry[] | undefined
+
+ const sessions = (text: string) => {
+ const query = text.trim()
+ if (!query) {
+ sessionToken.value += 1
+ sessionInflight = undefined
+ sessionAll = undefined
+ return [] as Entry[]
+ }
+
+ if (sessionAll) return sessionAll
+ if (sessionInflight) return sessionInflight
+
+ const current = sessionToken.value
+ const dirs = workspaces()
+ if (dirs.length === 0) return [] as Entry[]
+
+ sessionInflight = Promise.all(
+ dirs.map((directory) => {
+ const description = label(directory)
+ return globalSDK.client.session
+ .list({ directory, roots: true })
+ .then((x) =>
+ (x.data ?? [])
+ .filter((s) => !!s?.id)
+ .map((s) => ({
+ id: s.id,
+ title: s.title ?? language.t("command.session.new"),
+ description,
+ directory,
+ archived: s.time?.archived,
+ })),
+ )
+ .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
+ }),
+ )
+ .then((results) => {
+ if (sessionToken.value !== current) return [] as Entry[]
+ 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(sessionItem)
+ sessionAll = next
+ return next
+ })
+ .catch(() => [] as Entry[])
+ .finally(() => {
+ sessionInflight = undefined
+ })
+
+ return sessionInflight
+ }
+
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
@@ -146,9 +265,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const files = await file.searchFiles(query)
return files.map(fileItem)
}
- const files = await file.searchFiles(query)
+
+ const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const entries = files.map(fileItem)
- return [...list(), ...entries]
+ return [...list(), ...nextSessions, ...entries]
}
const handleMove = (item: Entry | undefined) => {
@@ -178,6 +298,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
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)
}
@@ -202,13 +328,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
- groupBy={(item) => item.category}
+ groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove}
onSelect={handleSelect}
>
{(item) => (
- <Show
- when={item.type === "command"}
+ <Switch
fallback={
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0">
@@ -223,18 +348,43 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</div>
}
>
- <div class="w-full flex items-center justify-between gap-4">
- <div class="flex items-center gap-2 min-w-0">
- <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
- <Show when={item.description}>
- <span class="text-14-regular text-text-weak truncate">{item.description}</span>
+ <Match when={item.type === "command"}>
+ <div class="w-full flex items-center justify-between gap-4">
+ <div class="flex items-center gap-2 min-w-0">
+ <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
+ <Show when={item.description}>
+ <span class="text-14-regular text-text-weak truncate">{item.description}</span>
+ </Show>
+ </div>
+ <Show when={item.keybind}>
+ <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
</Show>
</div>
- <Show when={item.keybind}>
- <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
- </Show>
- </div>
- </Show>
+ </Match>
+ <Match when={item.type === "session"}>
+ <div class="w-full flex items-center justify-between rounded-md pl-1">
+ <div class="flex items-center gap-x-3 grow min-w-0">
+ <Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
+ <div class="flex items-center gap-2 min-w-0">
+ <span
+ class="text-14-regular text-text-strong truncate"
+ classList={{ "opacity-70": !!item.archived }}
+ >
+ {item.title}
+ </span>
+ <Show when={item.description}>
+ <span
+ class="text-14-regular text-text-weak truncate"
+ classList={{ "opacity-70": !!item.archived }}
+ >
+ {item.description}
+ </span>
+ </Show>
+ </div>
+ </div>
+ </div>
+ </Match>
+ </Switch>
)}
</List>
</Dialog>
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 2f963ae28..46c9c9154 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -27,7 +27,6 @@ import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { InlineInput } from "@opencode-ai/ui/inline-input"
-import { List, type ListRef } from "@opencode-ai/ui/list"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { MessageNav } from "@opencode-ai/ui/message-nav"
@@ -2706,14 +2705,6 @@ export default function Layout(props: ParentProps) {
}
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
- type SearchItem = {
- id: string
- title: string
- directory: string
- label: string
- archived?: number
- }
-
const projectName = createMemo(() => {
const project = panelProps.project
if (!project) return ""
@@ -2729,107 +2720,6 @@ export default function Layout(props: ParentProps) {
})
const homedir = createMemo(() => globalSync.data.path.home)
- const [search, setSearch] = createStore({
- value: "",
- })
- const searching = createMemo(() => search.value.trim().length > 0)
- let searchRef: HTMLInputElement | undefined
- let listRef: ListRef | undefined
-
- const token = { value: 0 }
- let inflight: Promise<SearchItem[]> | undefined
- let all: SearchItem[] | undefined
-
- const reset = () => {
- token.value += 1
- inflight = undefined
- all = undefined
- setSearch({ value: "" })
- listRef = undefined
- }
-
- const open = (item: SearchItem | undefined) => {
- if (!item) return
-
- const href = `/${base64Encode(item.directory)}/session/${item.id}`
- if (!layout.sidebar.opened()) {
- setState("hoverSession", undefined)
- setState("hoverProject", undefined)
- }
- reset()
- navigate(href)
- layout.mobileSidebar.hide()
- }
-
- const items = (filter: string) => {
- const query = filter.trim()
- if (!query) {
- token.value += 1
- inflight = undefined
- all = undefined
- return [] as SearchItem[]
- }
-
- const project = panelProps.project
- if (!project) return [] as SearchItem[]
- if (all) return all
- if (inflight) return inflight
-
- const current = token.value
- const dirs = workspaceIds(project)
- inflight = Promise.all(
- dirs.map((input) => {
- const directory = workspaceKey(input)
- const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
- const kind =
- directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
- const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
- const label = `${kind} : ${name}`
- return globalSDK.client.session
- .list({ directory, roots: true })
- .then((x) =>
- (x.data ?? [])
- .filter((s) => !!s?.id)
- .map((s) => ({
- id: s.id,
- title: s.title ?? language.t("command.session.new"),
- directory,
- label,
- archived: s.time?.archived,
- })),
- )
- .catch(() => [] as SearchItem[])
- }),
- )
- .then((results) => {
- if (token.value !== current) return [] as SearchItem[]
-
- 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
- })
- all = next
- return next
- })
- .catch(() => [] as SearchItem[])
- .finally(() => {
- inflight = undefined
- })
-
- return inflight
- }
-
- createEffect(
- on(
- () => panelProps.project?.worktree,
- () => reset(),
- { defer: true },
- ),
- )
-
return (
<div
classList={{
@@ -2918,105 +2808,7 @@ export default function Layout(props: ParentProps) {
</div>
</div>
- <div class="shrink-0 px-2 pt-2">
- <div
- class="flex items-center gap-2 p-2 rounded-md bg-surface-base shadow-xs-border-base focus-within:shadow-xs-border-select"
- onPointerDown={(event) => {
- const target = event.target
- if (!(target instanceof Element)) return
- if (target.closest("input, textarea, [contenteditable='true']")) return
- searchRef?.focus()
- }}
- >
- <Icon name="magnifying-glass" />
- <InlineInput
- ref={(el) => {
- searchRef = el
- }}
- class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak"
- style={{ "box-shadow": "none" }}
- value={search.value}
- onInput={(event) => setSearch("value", event.currentTarget.value)}
- onKeyDown={(event) => {
- if (event.key === "Escape") {
- event.preventDefault()
- setSearch("value", "")
- queueMicrotask(() => searchRef?.focus())
- return
- }
-
- if (!searching()) return
-
- if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") {
- const ref = listRef
- if (!ref) return
- event.stopPropagation()
- ref.onKeyDown(event)
- return
- }
-
- if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
- if (event.key === "n" || event.key === "p") {
- const ref = listRef
- if (!ref) return
- event.stopPropagation()
- ref.onKeyDown(event)
- }
- }
- }}
- placeholder={language.t("session.header.search.placeholder", { project: projectName() })}
- spellcheck={false}
- autocorrect="off"
- autocomplete="off"
- autocapitalize="off"
- />
- <Show when={search.value}>
- <IconButton
- icon="circle-x"
- variant="ghost"
- class="size-5"
- aria-label={language.t("common.close")}
- onClick={() => {
- setSearch("value", "")
- queueMicrotask(() => searchRef?.focus())
- }}
- />
- </Show>
- </div>
- </div>
-
- <Show when={searching()}>
- <List
- class="flex-1 min-h-0 pb-2 pt-2 !px-2 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
- items={items}
- filter={search.value}
- filterKeys={["title", "label", "id"]}
- key={(item) => `${item.directory}:${item.id}`}
- onSelect={open}
- ref={(ref) => {
- listRef = ref
- }}
- >
- {(item) => (
- <div class="flex flex-col gap-0.5 min-w-0 pr-2 text-left">
- <span
- class="text-14-medium text-text-strong truncate"
- classList={{ "opacity-70": !!item.archived }}
- >
- {item.title}
- </span>
- <span
- class="text-12-regular text-text-weak truncate"
- classList={{ "opacity-70": !!item.archived }}
- >
- {item.label}
- </span>
- </div>
- )}
- </List>
- </Show>
-
- <div class="flex-1 min-h-0 flex flex-col" classList={{ hidden: searching() }}>
+ <div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
@@ -3100,7 +2892,7 @@ export default function Layout(props: ParentProps) {
<div
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
classList={{
- hidden: searching() || !(providers.all().length > 0 && providers.paid().length === 0),
+ hidden: !(providers.all().length > 0 && providers.paid().length === 0),
}}
>
<div class="rounded-md bg-background-base shadow-xs-border-base">