diff options
| author | Adam <[email protected]> | 2026-02-04 06:37:59 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-02-04 07:12:18 -0600 |
| commit | a2face30f43fe22148f6abea35b0c654e45d56b2 (patch) | |
| tree | 1fb79a8e92d4c0324ba93442f589a02f1743f399 | |
| parent | a219615fe5aeb6a40898300b17f076072aed5bcc (diff) | |
| download | opencode-a2face30f43fe22148f6abea35b0c654e45d56b2.tar.gz opencode-a2face30f43fe22148f6abea35b0c654e45d56b2.zip | |
wip(app): session options
| -rw-r--r-- | packages/app/src/pages/session.tsx | 288 |
1 files changed, 273 insertions, 15 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e31ab18b9..644fa66b3 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -16,13 +16,16 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" import { useLocal } from "@/context/local" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" import { SessionContextUsage } from "@/components/session-context-usage" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" 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 { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { useCodeComponent } from "@opencode-ai/ui/context/code" @@ -436,6 +439,218 @@ export default function Page() { if (!id) return false return sync.session.history.loading(id) }) + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return language.t("common.requestFailed") + } + + async function archiveSession(sessionID: string) { + const session = sync.session.get(sessionID) + if (!session) return + + const sessions = sync.data.session ?? [] + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + await sdk.client.session + .update({ sessionID, time: { archived: Date.now() } }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + + if (params.id !== sessionID) return + if (session.parentID) { + navigate(`/${params.dir}/session/${session.parentID}`) + return + } + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + return + } + navigate(`/${params.dir}/session`) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + async function deleteSession(sessionID: string) { + const session = sync.session.get(sessionID) + if (!session) return false + + const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + const result = await sdk.client.session + .delete({ sessionID }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(err), + }) + return false + }) + + if (!result) return false + + sync.set( + produce((draft) => { + const removed = new Set<string>([sessionID]) + + 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 = [sessionID] + 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 (params.id !== sessionID) return true + if (session.parentID) { + navigate(`/${params.dir}/session/${session.parentID}`) + return true + } + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + return true + } + navigate(`/${params.dir}/session`) + 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 () => { + await deleteSession(props.sessionID) + 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: 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> + ) + } + const emptyUserMessages: UserMessage[] = [] const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], @@ -1992,20 +2207,63 @@ export default function Page() { centered(), }} > - <div class="h-10 flex items-center gap-1"> - <Show when={info()?.parentID}> - <IconButton - tabIndex={-1} - icon="arrow-left" - variant="ghost" - onClick={() => { - navigate(`/${params.dir}/session/${info()?.parentID}`) - }} - aria-label={language.t("common.goBack")} - /> - </Show> - <Show when={info()?.title}> - <h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1> + <div class="h-10 w-full flex items-center justify-between gap-2"> + <div class="flex items-center gap-1 min-w-0"> + <Show when={info()?.parentID}> + <IconButton + tabIndex={-1} + icon="arrow-left" + variant="ghost" + onClick={() => { + navigate(`/${params.dir}/session/${info()?.parentID}`) + }} + 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> + </div> + <Show when={params.id}> + {(id) => ( + <div class="shrink-0 flex items-center"> + <DropdownMenu> + <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> + <DropdownMenu.Content> + <DropdownMenu.Item + onSelect={() => dialog.show(() => <DialogRenameSession sessionID={id()} />)} + > + <DropdownMenu.ItemLabel> + {language.t("common.rename")} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Item onSelect={() => void archiveSession(id())}> + <DropdownMenu.ItemLabel> + {language.t("common.archive")} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Separator /> + <DropdownMenu.Item + onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)} + > + <DropdownMenu.ItemLabel> + {language.t("common.delete")} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </div> + )} </Show> </div> </div> |
