summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-04 07:12:12 -0600
committerAdam <[email protected]>2026-02-04 07:12:19 -0600
commitc277ee8cbf7ff3ca5a86947d974c2b72f88398d4 (patch)
tree06c6c7ada4ad37e14f56db6ccbeacb913d85ecee /packages
parenta2face30f43fe22148f6abea35b0c654e45d56b2 (diff)
downloadopencode-c277ee8cbf7ff3ca5a86947d974c2b72f88398d4.tar.gz
opencode-c277ee8cbf7ff3ca5a86947d974c2b72f88398d4.zip
fix(app): move session options to the session page
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/pages/layout.tsx170
-rw-r--r--packages/app/src/pages/session.tsx190
2 files changed, 134 insertions, 226 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 46c9c9154..c565d197f 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -1000,69 +1000,6 @@ export default function Layout(props: ParentProps) {
}
}
- async function deleteSession(session: Session) {
- const [store, setStore] = globalSync.child(session.directory)
- const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
- const index = sessions.findIndex((s) => s.id === session.id)
- const nextSession = sessions[index + 1] ?? sessions[index - 1]
-
- const result = await globalSDK.client.session
- .delete({ directory: session.directory, sessionID: session.id })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("session.delete.failed.title"),
- description: errorMessage(err),
- })
- return false
- })
-
- if (!result) return
-
- setStore(
- produce((draft) => {
- const removed = new Set<string>([session.id])
-
- const byParent = new Map<string, string[]>()
- for (const item of draft.session) {
- const parentID = item.parentID
- if (!parentID) continue
- const existing = byParent.get(parentID)
- if (existing) {
- existing.push(item.id)
- continue
- }
- byParent.set(parentID, [item.id])
- }
-
- const stack = [session.id]
- while (stack.length) {
- const parentID = stack.pop()
- if (!parentID) continue
-
- const children = byParent.get(parentID)
- if (!children) continue
-
- for (const child of children) {
- if (removed.has(child)) continue
- removed.add(child)
- stack.push(child)
- }
- }
-
- draft.session = draft.session.filter((s) => !removed.has(s.id))
- }),
- )
-
- if (session.id === params.id) {
- if (nextSession) {
- navigate(`/${params.dir}/session/${nextSession.id}`)
- } else {
- navigate(`/${params.dir}/session`)
- }
- }
- }
-
command.register(() => {
const commands: CommandOption[] = [
{
@@ -1316,15 +1253,6 @@ export default function Layout(props: ParentProps) {
globalSync.project.meta(project.worktree, { name })
}
- async function renameSession(session: Session, next: string) {
- if (next === session.title) return
- await globalSDK.client.session.update({
- directory: session.directory,
- sessionID: session.id,
- title: next,
- })
- }
-
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
if (current === next) return
@@ -1475,33 +1403,6 @@ export default function Layout(props: ParentProps) {
})
}
- function DialogDeleteSession(props: { session: Session }) {
- const handleDelete = async () => {
- await deleteSession(props.session)
- dialog.close()
- }
-
- return (
- <Dialog title={language.t("session.delete.title")} fit>
- <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
- <div class="flex flex-col gap-1">
- <span class="text-14-regular text-text-strong">
- {language.t("session.delete.confirm", { name: props.session.title })}
- </span>
- </div>
- <div class="flex justify-end gap-2">
- <Button variant="ghost" size="large" onClick={() => dialog.close()}>
- {language.t("common.cancel")}
- </Button>
- <Button variant="primary" size="large" onClick={handleDelete}>
- {language.t("session.delete.button")}
- </Button>
- </div>
- </div>
- </Dialog>
- )
- }
-
function DialogDeleteWorkspace(props: { root: string; directory: string }) {
const name = createMemo(() => getFilename(props.directory))
const [data, setData] = createStore({
@@ -1855,10 +1756,6 @@ export default function Layout(props: ParentProps) {
const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)
- const [menu, setMenu] = createStore({
- open: false,
- pendingRename: false,
- })
const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
const cancelHoverPrefetch = () => {
@@ -1885,7 +1782,7 @@ export default function Layout(props: ParentProps) {
const item = (
<A
href={`${props.slug}/session/${props.session.id}`}
- class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menu.open ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
+ class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerEnter={scheduleHoverPrefetch}
onPointerLeave={cancelHoverPrefetch}
onMouseEnter={scheduleHoverPrefetch}
@@ -1917,14 +1814,9 @@ export default function Layout(props: ParentProps) {
</Match>
</Switch>
</div>
- <InlineEditor
- id={`session:${props.session.id}`}
- value={() => props.session.title}
- onSave={(next) => renameSession(props.session, next)}
- class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
- displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
- stopPropagation
- />
+ <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
+ {props.session.title}
+ </span>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
@@ -1989,49 +1881,25 @@ export default function Layout(props: ParentProps) {
<div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
classList={{
- "opacity-100 pointer-events-auto": menu.open,
- "opacity-0 pointer-events-none": !menu.open,
+ "opacity-100 pointer-events-auto": !!props.mobile,
+ "opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
- <DropdownMenu modal={!sidebarHovering()} open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
- <Tooltip value={language.t("common.moreOptions")} placement="top">
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
- aria-label={language.t("common.moreOptions")}
- />
- </Tooltip>
- <DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}>
- <DropdownMenu.Content
- onCloseAutoFocus={(event) => {
- if (!menu.pendingRename) return
- event.preventDefault()
- setMenu("pendingRename", false)
- openEditor(`session:${props.session.id}`, props.session.title)
- }}
- >
- <DropdownMenu.Item
- onSelect={() => {
- setMenu("pendingRename", true)
- setMenu("open", false)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item onSelect={() => archiveSession(props.session)}>
- <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Separator />
- <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession session={props.session} />)}>
- <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
+ <Tooltip value={language.t("common.archive")} placement="top">
+ <IconButton
+ icon="archive"
+ variant="ghost"
+ class="size-6 rounded-md"
+ aria-label={language.t("common.archive")}
+ onClick={(event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ void archiveSession(props.session)
+ }}
+ />
+ </Tooltip>
</div>
</div>
)
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 644fa66b3..2143cd34b 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -25,7 +25,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
-import { TextField } from "@opencode-ai/ui/text-field"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
@@ -440,6 +440,15 @@ export default function Page() {
return sync.session.history.loading(id)
})
+ const [title, setTitle] = createStore({
+ draft: "",
+ editing: false,
+ saving: false,
+ menuOpen: false,
+ pendingRename: false,
+ })
+ let titleRef: HTMLInputElement | undefined
+
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
@@ -449,6 +458,60 @@ export default function Page() {
return language.t("common.requestFailed")
}
+ createEffect(
+ on(
+ () => params.id,
+ () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
+ { defer: true },
+ ),
+ )
+
+ const openTitleEditor = () => {
+ if (!params.id) return
+ setTitle({ editing: true, draft: info()?.title ?? "" })
+ requestAnimationFrame(() => {
+ titleRef?.focus()
+ titleRef?.select()
+ })
+ }
+
+ const closeTitleEditor = () => {
+ if (title.saving) return
+ setTitle({ editing: false, saving: false })
+ }
+
+ const saveTitleEditor = async () => {
+ const sessionID = params.id
+ if (!sessionID) return
+ if (title.saving) return
+
+ const next = title.draft.trim()
+ if (!next || next === (info()?.title ?? "")) {
+ setTitle({ editing: false, saving: false })
+ return
+ }
+
+ setTitle("saving", true)
+ await sdk.client.session
+ .update({ sessionID, title: next })
+ .then(() => {
+ sync.set(
+ produce((draft) => {
+ const index = draft.session.findIndex((s) => s.id === sessionID)
+ if (index !== -1) draft.session[index].title = next
+ }),
+ )
+ setTitle({ editing: false, saving: false })
+ })
+ .catch((err) => {
+ setTitle("saving", false)
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: errorMessage(err),
+ })
+ })
+ }
+
async function archiveSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return
@@ -555,74 +618,6 @@ export default function Page() {
return true
}
- function DialogRenameSession(props: { sessionID: string }) {
- const [data, setData] = createStore({
- title: sync.session.get(props.sessionID)?.title ?? "",
- saving: false,
- })
-
- const submit = (event: Event) => {
- event.preventDefault()
- if (data.saving) return
-
- const title = data.title.trim()
- if (!title) {
- dialog.close()
- return
- }
-
- const current = sync.session.get(props.sessionID)?.title ?? ""
- if (title === current) {
- dialog.close()
- return
- }
-
- setData("saving", true)
- void sdk.client.session
- .update({ sessionID: props.sessionID, title })
- .then(() => {
- sync.set(
- produce((draft) => {
- const index = draft.session.findIndex((s) => s.id === props.sessionID)
- if (index !== -1) draft.session[index].title = title
- }),
- )
- dialog.close()
- })
- .catch((err) => {
- showToast({
- title: language.t("common.requestFailed"),
- description: errorMessage(err),
- })
- })
- .finally(() => {
- setData("saving", false)
- })
- }
-
- return (
- <Dialog title={language.t("common.rename")} fit>
- <form onSubmit={submit} class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
- <TextField
- autofocus
- type="text"
- label={language.t("common.rename")}
- value={data.title}
- onChange={(value) => setData("title", value)}
- />
- <div class="flex justify-end gap-2">
- <Button type="button" variant="ghost" size="large" disabled={data.saving} onClick={() => dialog.close()}>
- {language.t("common.cancel")}
- </Button>
- <Button type="submit" variant="primary" size="large" disabled={data.saving || !data.title.trim()}>
- {language.t("common.save")}
- </Button>
- </div>
- </form>
- </Dialog>
- )
- }
-
function DialogDeleteSession(props: { sessionID: string }) {
const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
@@ -2208,7 +2203,7 @@ export default function Page() {
}}
>
<div class="h-10 w-full flex items-center justify-between gap-2">
- <div class="flex items-center gap-1 min-w-0">
+ <div class="flex items-center gap-1 min-w-0 flex-1">
<Show when={info()?.parentID}>
<IconButton
tabIndex={-1}
@@ -2220,14 +2215,50 @@ export default function Page() {
aria-label={language.t("common.goBack")}
/>
</Show>
- <Show when={info()?.title}>
- <h1 class="text-16-medium text-text-strong truncate min-w-0">{info()?.title}</h1>
+ <Show when={info()?.title || title.editing}>
+ <Show
+ when={title.editing}
+ fallback={
+ <h1
+ class="text-16-medium text-text-strong truncate min-w-0"
+ onDblClick={openTitleEditor}
+ >
+ {info()?.title}
+ </h1>
+ }
+ >
+ <InlineInput
+ ref={(el) => {
+ titleRef = el
+ }}
+ value={title.draft}
+ disabled={title.saving}
+ class="text-16-medium text-text-strong grow-1 min-w-0"
+ onInput={(event) => setTitle("draft", event.currentTarget.value)}
+ onKeyDown={(event) => {
+ event.stopPropagation()
+ if (event.key === "Enter") {
+ event.preventDefault()
+ void saveTitleEditor()
+ return
+ }
+ if (event.key === "Escape") {
+ event.preventDefault()
+ closeTitleEditor()
+ }
+ }}
+ onBlur={() => closeTitleEditor()}
+ />
+ </Show>
</Show>
</div>
<Show when={params.id}>
{(id) => (
<div class="shrink-0 flex items-center">
- <DropdownMenu>
+ <DropdownMenu
+ open={title.menuOpen}
+ onOpenChange={(open) => setTitle("menuOpen", open)}
+ >
<Tooltip value={language.t("common.moreOptions")} placement="top">
<DropdownMenu.Trigger
as={IconButton}
@@ -2238,9 +2269,18 @@ export default function Page() {
/>
</Tooltip>
<DropdownMenu.Portal>
- <DropdownMenu.Content>
+ <DropdownMenu.Content
+ onCloseAutoFocus={(event) => {
+ if (!title.pendingRename) return
+ event.preventDefault()
+ setTitle("pendingRename", false)
+ openTitleEditor()
+ }}
+ >
<DropdownMenu.Item
- onSelect={() => dialog.show(() => <DialogRenameSession sessionID={id()} />)}
+ onSelect={() => {
+ setTitle({ pendingRename: true, menuOpen: false })
+ }}
>
<DropdownMenu.ItemLabel>
{language.t("common.rename")}