diff options
| author | Adam <[email protected]> | 2026-02-04 07:12:12 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-02-04 07:12:19 -0600 |
| commit | c277ee8cbf7ff3ca5a86947d974c2b72f88398d4 (patch) | |
| tree | 06c6c7ada4ad37e14f56db6ccbeacb913d85ecee /packages | |
| parent | a2face30f43fe22148f6abea35b0c654e45d56b2 (diff) | |
| download | opencode-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.tsx | 170 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 190 |
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")} |
