summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-02 14:22:10 -0600
committerAdam <[email protected]>2026-02-02 14:26:09 -0600
commitc002ca03ba7a617090ab104c5d2a07f1c8be2958 (patch)
tree6d3e2938c03fc6cd71e9275dab3f798630b2ac1e /packages
parentbefb5d54fbfd8df9706c49159095b1ef7f2ec23d (diff)
downloadopencode-c002ca03ba7a617090ab104c5d2a07f1c8be2958.tar.gz
opencode-c002ca03ba7a617090ab104c5d2a07f1c8be2958.zip
feat(app): search through sessions
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/pages/layout.tsx410
-rw-r--r--packages/ui/src/components/list.tsx33
2 files changed, 343 insertions, 100 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 5d285c5ec..fe8618b73 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -27,6 +27,7 @@ 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"
@@ -2682,6 +2683,14 @@ 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 ""
@@ -2697,6 +2706,107 @@ 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={{
@@ -2705,7 +2815,7 @@ export default function Layout(props: ParentProps) {
}}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
>
- <Show when={panelProps.project} keyed>
+ <Show when={panelProps.project}>
{(p) => (
<>
<div class="shrink-0 px-2 py-1">
@@ -2714,7 +2824,7 @@ export default function Layout(props: ParentProps) {
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
- onSave={(next) => renameProject(p, next)}
+ onSave={(next) => renameProject(p(), next)}
class="text-16-medium text-text-strong truncate"
displayClass="text-16-medium text-text-strong truncate"
stopPropagation
@@ -2723,7 +2833,7 @@ export default function Layout(props: ParentProps) {
<Tooltip
placement="bottom"
gutter={2}
- value={p.worktree}
+ value={p().worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
@@ -2731,7 +2841,7 @@ export default function Layout(props: ParentProps) {
}}
>
<span class="text-12-regular text-text-base truncate select-text">
- {p.worktree.replace(homedir(), "~")}
+ {p().worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div>
@@ -2742,31 +2852,31 @@ export default function Layout(props: ParentProps) {
icon="dot-grid"
variant="ghost"
data-action="project-menu"
- data-project={base64Encode(p.worktree)}
+ data-project={base64Encode(p().worktree)}
class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Content class="mt-1">
- <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
+ <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p()} />)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
- data-project={base64Encode(p.worktree)}
- disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
+ data-project={base64Encode(p().worktree)}
+ disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
onSelect={() => {
- const enabled = layout.sidebar.workspaces(p.worktree)()
+ const enabled = layout.sidebar.workspaces(p().worktree)()
if (enabled) {
- layout.sidebar.toggleWorkspaces(p.worktree)
+ layout.sidebar.toggleWorkspaces(p().worktree)
return
}
- if (p.vcs !== "git") return
- layout.sidebar.toggleWorkspaces(p.worktree)
+ if (p().vcs !== "git") return
+ layout.sidebar.toggleWorkspaces(p().worktree)
}}
>
<DropdownMenu.ItemLabel>
- {layout.sidebar.workspaces(p.worktree)()
+ {layout.sidebar.workspaces(p().worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
@@ -2774,8 +2884,8 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
- data-project={base64Encode(p.worktree)}
- onSelect={() => closeProject(p.worktree)}
+ data-project={base64Encode(p().worktree)}
+ onSelect={() => closeProject(p().worktree)}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -2785,103 +2895,207 @@ export default function Layout(props: ParentProps) {
</div>
</div>
- <Show
- when={workspacesEnabled()}
- fallback={
+ <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() }}>
+ <Show
+ when={workspacesEnabled()}
+ fallback={
+ <>
+ <div class="shrink-0 py-4 px-3">
+ <TooltipKeybind
+ title={language.t("command.session.new")}
+ keybind={command.keybind("session.new")}
+ placement="top"
+ >
+ <Button
+ size="large"
+ icon="plus-small"
+ class="w-full"
+ onClick={() => {
+ if (!layout.sidebar.opened()) {
+ setState("hoverSession", undefined)
+ setState("hoverProject", undefined)
+ }
+ navigate(`/${base64Encode(p().worktree)}/session`)
+ layout.mobileSidebar.hide()
+ }}
+ >
+ {language.t("command.session.new")}
+ </Button>
+ </TooltipKeybind>
+ </div>
+ <div class="flex-1 min-h-0">
+ <LocalWorkspace project={p()} mobile={panelProps.mobile} />
+ </div>
+ </>
+ }
+ >
<>
- <div class="py-4 px-3">
+ <div class="shrink-0 py-4 px-3">
<TooltipKeybind
- title={language.t("command.session.new")}
- keybind={command.keybind("session.new")}
+ title={language.t("workspace.new")}
+ keybind={command.keybind("workspace.new")}
placement="top"
>
- <Button
- size="large"
- icon="plus-small"
- class="w-full"
- onClick={() => {
- if (!layout.sidebar.opened()) {
- setState("hoverSession", undefined)
- setState("hoverProject", undefined)
- }
- navigate(`/${base64Encode(p.worktree)}/session`)
- layout.mobileSidebar.hide()
- }}
- >
- {language.t("command.session.new")}
+ <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
+ {language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div>
- <div class="flex-1 min-h-0">
- <LocalWorkspace project={p} mobile={panelProps.mobile} />
+ <div class="relative flex-1 min-h-0">
+ <DragDropProvider
+ onDragStart={handleWorkspaceDragStart}
+ onDragEnd={handleWorkspaceDragEnd}
+ onDragOver={handleWorkspaceDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragXAxis />
+ <div
+ ref={(el) => {
+ if (!panelProps.mobile) scrollContainerRef = el
+ }}
+ class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
+ >
+ <SortableProvider ids={workspaces()}>
+ <For each={workspaces()}>
+ {(directory) => (
+ <SortableWorkspace directory={directory} project={p()} mobile={panelProps.mobile} />
+ )}
+ </For>
+ </SortableProvider>
+ </div>
+ <DragOverlay>
+ <WorkspaceDragOverlay />
+ </DragOverlay>
+ </DragDropProvider>
</div>
</>
- }
- >
- <>
- <div class="py-4 px-3">
- <TooltipKeybind
- title={language.t("workspace.new")}
- keybind={command.keybind("workspace.new")}
- placement="top"
- >
- <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
- {language.t("workspace.new")}
- </Button>
- </TooltipKeybind>
- </div>
- <div class="relative flex-1 min-h-0">
- <DragDropProvider
- onDragStart={handleWorkspaceDragStart}
- onDragEnd={handleWorkspaceDragEnd}
- onDragOver={handleWorkspaceDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragXAxis />
- <div
- ref={(el) => {
- if (!panelProps.mobile) scrollContainerRef = el
- }}
- class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
- >
- <SortableProvider ids={workspaces()}>
- <For each={workspaces()}>
- {(directory) => (
- <SortableWorkspace directory={directory} project={p} mobile={panelProps.mobile} />
- )}
- </For>
- </SortableProvider>
- </div>
- <DragOverlay>
- <WorkspaceDragOverlay />
- </DragOverlay>
- </DragDropProvider>
- </div>
- </>
- </Show>
+ </Show>
+ </div>
</>
)}
</Show>
- <Show when={providers.all().length > 0 && providers.paid().length === 0}>
- <div class="shrink-0 px-2 py-3 border-t border-border-weak-base">
- <div class="rounded-md bg-background-base shadow-xs-border-base">
- <div class="p-3 flex flex-col gap-2">
- <div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
- <div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
- <div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
- </div>
- <Button
- class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
- size="large"
- icon="plus"
- onClick={connectProvider}
- >
- {language.t("command.provider.connect")}
- </Button>
+
+ <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),
+ }}
+ >
+ <div class="rounded-md bg-background-base shadow-xs-border-base">
+ <div class="p-3 flex flex-col gap-2">
+ <div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
+ <div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
+ <div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
</div>
+ <Button
+ class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
+ size="large"
+ icon="plus"
+ onClick={connectProvider}
+ >
+ {language.t("command.provider.connect")}
+ </Button>
</div>
- </Show>
+ </div>
</div>
)
}
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
index 6c654cbb7..abd557220 100644
--- a/packages/ui/src/components/list.tsx
+++ b/packages/ui/src/components/list.tsx
@@ -57,6 +57,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
const i18n = useI18n()
const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
const [internalFilter, setInternalFilter] = createSignal("")
+ let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const [store, setStore] = createStore({
mouseActive: false,
})
@@ -176,6 +177,14 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
if (e.key === "Enter" && !e.isComposing) {
e.preventDefault()
if (selected) handleSelect(selected, index)
+ } else if (props.search) {
+ if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")) {
+ onKeyDown(e)
+ return
+ }
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+ onKeyDown(e)
+ }
} else {
onKeyDown(e)
}
@@ -247,7 +256,21 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
<div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
<Show when={!!props.search}>
<div data-slot="list-search-wrapper">
- <div data-slot="list-search" classList={{ [searchProps().class ?? ""]: !!searchProps().class }}>
+ <div
+ data-slot="list-search"
+ classList={{ [searchProps().class ?? ""]: !!searchProps().class }}
+ onPointerDown={(event) => {
+ const container = event.currentTarget
+ if (!(container instanceof HTMLElement)) return
+
+ const node = container.querySelector("input, textarea")
+ const input = node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ? node : inputRef
+ input?.focus()
+
+ // Prevent global listeners (e.g. dnd sensors) from cancelling focus.
+ event.stopPropagation()
+ }}
+ >
<div data-slot="list-search-container">
<Show when={!searchProps().hideIcon}>
<Icon name="magnifying-glass" />
@@ -257,6 +280,9 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
variant="ghost"
data-slot="list-search-input"
type="text"
+ ref={(el: HTMLInputElement | HTMLTextAreaElement) => {
+ inputRef = el
+ }}
value={internalFilter()}
onChange={(value) => applyFilter(value)}
onKeyDown={handleKey}
@@ -271,7 +297,10 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
<IconButton
icon="circle-x"
variant="ghost"
- onClick={() => applyFilter("")}
+ onClick={() => {
+ setInternalFilter("")
+ queueMicrotask(() => inputRef?.focus())
+ }}
aria-label={i18n.t("ui.list.clearFilter")}
/>
</Show>