diff options
Diffstat (limited to 'packages/desktop/src/pages')
| -rw-r--r-- | packages/desktop/src/pages/directory-layout.tsx | 5 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 365 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 506 |
3 files changed, 634 insertions, 242 deletions
diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx index 0dbb3f6d6..c909a373d 100644 --- a/packages/desktop/src/pages/directory-layout.tsx +++ b/packages/desktop/src/pages/directory-layout.tsx @@ -6,7 +6,6 @@ import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" -import { DialogRoot } from "@opencode-ai/ui/context/dialog" export default function Layout(props: ParentProps) { const params = useParams() @@ -21,9 +20,7 @@ export default function Layout(props: ParentProps) { const sync = useSync() return ( <DataProvider data={sync.data} directory={directory()}> - <LocalProvider> - <DialogRoot>{props.children}</DialogRoot> - </LocalProvider> + <LocalProvider>{props.children}</LocalProvider> </DataProvider> ) })} diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 7560e43e6..79470cf14 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -12,6 +12,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { Spinner } from "@opencode-ai/ui/spinner" import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session, Project } from "@opencode-ai/sdk/v2/client" @@ -35,6 +36,7 @@ import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectProvider } from "@/components/dialog-select-provider" +import { useCommand } from "@/context/command" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -42,6 +44,16 @@ export default function Layout(props: ParentProps) { activeDraggable: undefined as string | undefined, }) + let scrollContainerRef: HTMLDivElement | undefined + + function scrollToSession(sessionId: string) { + if (!scrollContainerRef) return + const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`) + if (element) { + element.scrollIntoView({ block: "center", behavior: "smooth" }) + } + } + const params = useParams() const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() @@ -51,9 +63,161 @@ export default function Layout(props: ParentProps) { const navigate = useNavigate() const providers = useProviders() const dialog = useDialog() + const command = useCommand() + + function flattenSessions(sessions: Session[]): Session[] { + const childrenMap = new Map<string, Session[]>() + for (const session of sessions) { + if (session.parentID) { + const children = childrenMap.get(session.parentID) ?? [] + children.push(session) + childrenMap.set(session.parentID, children) + } + } + const result: Session[] = [] + function visit(session: Session) { + result.push(session) + for (const child of childrenMap.get(session.id) ?? []) { + visit(child) + } + } + for (const session of sessions) { + if (!session.parentID) visit(session) + } + return result + } + + const currentSessions = createMemo(() => { + if (!params.dir) return [] + const directory = base64Decode(params.dir) + return flattenSessions(globalSync.child(directory)[0].session ?? []) + }) + + function navigateSessionByOffset(offset: number) { + const projects = layout.projects.list() + if (projects.length === 0) return + + const currentDirectory = params.dir ? base64Decode(params.dir) : undefined + const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1 + + if (projectIndex === -1) { + const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1] + if (targetProject) navigateToProject(targetProject.worktree) + return + } + + const sessions = currentSessions() + const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 + + let targetIndex: number + if (sessionIndex === -1) { + targetIndex = offset > 0 ? 0 : sessions.length - 1 + } else { + targetIndex = sessionIndex + offset + } + + if (targetIndex >= 0 && targetIndex < sessions.length) { + const session = sessions[targetIndex] + navigateToSession(session) + queueMicrotask(() => scrollToSession(session.id)) + return + } + + const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1) + const nextProject = projects[nextProjectIndex] + if (!nextProject) return + + const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? []) + if (nextProjectSessions.length === 0) { + navigateToProject(nextProject.worktree) + return + } + + const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1] + navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`) + queueMicrotask(() => scrollToSession(targetSession.id)) + } + + async function archiveSession(session: Session) { + const [store, setStore] = globalSync.child(session.directory) + const sessions = store.session ?? [] + const index = sessions.findIndex((s) => s.id === session.id) + const nextSession = sessions[index + 1] ?? sessions[index - 1] + + await globalSDK.client.session.update({ + directory: session.directory, + sessionID: session.id, + time: { archived: Date.now() }, + }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, session.id, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + if (session.id === params.id) { + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + } else { + navigate(`/${params.dir}/session`) + } + } + } + + command.register(() => [ + { + id: "sidebar.toggle", + title: "Toggle sidebar", + category: "View", + keybind: "mod+b", + onSelect: () => layout.sidebar.toggle(), + }, + ...(platform.openDirectoryPickerDialog + ? [ + { + id: "project.open", + title: "Open project", + category: "Project", + keybind: "mod+o", + onSelect: () => chooseProject(), + }, + ] + : []), + { + id: "provider.connect", + title: "Connect provider", + category: "Provider", + onSelect: () => connectProvider(), + }, + { + id: "session.previous", + title: "Previous session", + category: "Session", + keybind: "alt+arrowup", + onSelect: () => navigateSessionByOffset(-1), + }, + { + id: "session.next", + title: "Next session", + category: "Session", + keybind: "alt+arrowdown", + onSelect: () => navigateSessionByOffset(1), + }, + { + id: "session.archive", + title: "Archive session", + category: "Session", + keybind: "mod+shift+backspace", + disabled: !params.dir || !params.id, + onSelect: () => { + const session = currentSessions().find((s) => s.id === params.id) + if (session) archiveSession(session) + }, + }, + ]) function connectProvider() { - dialog.replace(() => <DialogSelectProvider />) + dialog.show(() => <DialogSelectProvider />) } function navigateToProject(directory: string | undefined) { @@ -236,13 +400,117 @@ export default function Layout(props: ParentProps) { ) } - const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => { + const SessionItem = (props: { + session: Session + slug: string + project: Project + depth?: number + childrenMap: Map<string, Session[]> + }): JSX.Element => { const notification = useNotification() + const depth = props.depth ?? 0 + const children = createMemo(() => props.childrenMap.get(props.session.id) ?? []) + const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) + const notifications = createMemo(() => notification.session.unseen(props.session.id)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo( + () => + props.session.id !== params.id && + globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy", + ) + return ( + <> + <div + data-session-id={props.session.id} + class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors + hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" + style={{ "padding-left": `${16 + depth * 12}px` }} + > + <Tooltip placement="right" value={props.session.title} gutter={10}> + <A + href={`${props.slug}/session/${props.session.id}`} + class="flex flex-col min-w-0 text-left w-full focus:outline-none" + > + <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"> + <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> + {props.session.title} + </span> + <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> + <Switch> + <Match when={isWorking()}> + <Spinner class="size-2.5 mr-0.5" /> + </Match> + <Match when={hasError()}> + <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> + </Match> + <Match when={notifications().length > 0}> + <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" /> + </Match> + <Match when={true}> + <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> + {Math.abs(updated().diffNow().as("seconds")) < 60 + ? "Now" + : updated() + .toRelative({ + style: "short", + unit: ["days", "hours", "minutes"], + }) + ?.replace(" ago", "") + ?.replace(/ days?/, "d") + ?.replace(" min.", "m") + ?.replace(" hr.", "h")} + </span> + </Match> + </Switch> + </div> + </div> + <Show when={props.session.summary?.files}> + <div class="flex justify-between items-center self-stretch"> + <span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span> + <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> + </div> + </Show> + </A> + </Tooltip> + <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1"> + <Tooltip placement="right" value="Archive session"> + <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} /> + </Tooltip> + </div> + </div> + <For each={children()}> + {(child) => ( + <SessionItem + session={child} + slug={props.slug} + project={props.project} + depth={depth + 1} + childrenMap={props.childrenMap} + /> + )} + </For> + </> + ) + } + + const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => { const sortable = createSortable(props.project.worktree) const slug = createMemo(() => base64Encode(props.project.worktree)) const name = createMemo(() => getFilename(props.project.worktree)) - const [store, setStore] = globalSync.child(props.project.worktree) + const [store] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session ?? []) + const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) + const childSessionsByParent = createMemo(() => { + const map = new Map<string, Session[]>() + for (const session of sessions()) { + if (session.parentID) { + const children = map.get(session.parentID) ?? [] + children.push(session) + map.set(session.parentID, children) + } + } + return map + }) const [expanded, setExpanded] = createSignal(true) return ( // @ts-ignore @@ -282,83 +550,17 @@ export default function Layout(props: ParentProps) { </Button> <Collapsible.Content> <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5"> - <For each={sessions()}> - {(session) => { - const updated = createMemo(() => DateTime.fromMillis(session.time.updated)) - const notifications = createMemo(() => notification.session.unseen(session.id)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) - async function archive(session: Session) { - await globalSDK.client.session.update({ - directory: session.directory, - sessionID: session.id, - time: { archived: Date.now() }, - }) - setStore( - produce((draft) => { - const match = Binary.search(draft.session, session.id, (s) => s.id) - if (match.found) draft.session.splice(match.index, 1) - }), - ) - } - return ( - <div - class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors - hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" - > - <Tooltip placement="right" value={session.title} gutter={10}> - <A - href={`${slug()}/session/${session.id}`} - class="flex flex-col min-w-0 text-left w-full focus:outline-none" - > - <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"> - <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> - {session.title} - </span> - <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> - <Switch> - <Match when={hasError()}> - <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> - </Match> - <Match when={notifications().length > 0}> - <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" /> - </Match> - <Match when={true}> - <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> - {Math.abs(updated().diffNow().as("seconds")) < 60 - ? "Now" - : updated() - .toRelative({ - style: "short", - unit: ["days", "hours", "minutes"], - }) - ?.replace(" ago", "") - ?.replace(/ days?/, "d") - ?.replace(" min.", "m") - ?.replace(" hr.", "h")} - </span> - </Match> - </Switch> - </div> - </div> - <Show when={session.summary?.files}> - <div class="flex justify-between items-center self-stretch"> - <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span> - <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> - </div> - </Show> - </A> - </Tooltip> - <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1"> - {/* <IconButton icon="dot-grid" variant="ghost" /> */} - <Tooltip placement="right" value="Archive session"> - <IconButton icon="archive" variant="ghost" onClick={() => archive(session)} /> - </Tooltip> - </div> - </div> - ) - }} + <For each={rootSessions()}> + {(session) => ( + <SessionItem + session={session} + slug={slug()} + project={props.project} + childrenMap={childSessionsByParent()} + /> + )} </For> - <Show when={sessions().length === 0}> + <Show when={rootSessions().length === 0}> <div class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" @@ -471,7 +673,10 @@ export default function Layout(props: ParentProps) { > <DragDropSensors /> <ConstrainDragXAxis /> - <div class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"> + <div + ref={scrollContainerRef} + class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar" + > <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}> <For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For> </SortableProvider> diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index bef12fbd8..390872d36 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect, on } from "solid-js" import { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" @@ -27,113 +27,333 @@ import { import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" -import { useSession, type LocalPTY } from "@/context/session" +import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectModel } from "@/components/dialog-select-model" +import { useCommand } from "@/context/command" +import { useNavigate, useParams } from "@solidjs/router" +import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" +import { useSDK } from "@/context/sdk" +import { usePrompt } from "@/context/prompt" +import { extractPromptFromParts } from "@/utils/prompt" export default function Page() { const layout = useLayout() const local = useLocal() const sync = useSync() - const session = useSession() + const terminal = useTerminal() const dialog = useDialog() + const command = useCommand() + const params = useParams() + const navigate = useNavigate() + const sdk = useSDK() + const prompt = usePrompt() + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const revertMessageID = createMemo(() => info()?.revert?.messageID) + const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const userMessages = createMemo(() => + messages() + .filter((m) => m.role === "user") + .sort((a, b) => a.id.localeCompare(b.id)), + ) + // Visible user messages excludes reverted messages (those >= revertMessageID) + const visibleUserMessages = createMemo(() => { + const revert = revertMessageID() + if (!revert) return userMessages() + return userMessages().filter((m) => m.id < revert) + }) + const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1)) + + const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({}) + const activeMessage = createMemo(() => { + if (!messageStore.messageId) return lastUserMessage() + // If the stored message is no longer visible (e.g., was reverted), fall back to last visible + const found = visibleUserMessages()?.find((m) => m.id === messageStore.messageId) + return found ?? lastUserMessage() + }) + const setActiveMessage = (message: UserMessage | undefined) => { + setMessageStore("messageId", message?.id) + } + + function navigateMessageByOffset(offset: number) { + const msgs = visibleUserMessages() + if (msgs.length === 0) return + + const current = activeMessage() + const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1 + + let targetIndex: number + if (currentIndex === -1) { + targetIndex = offset > 0 ? 0 : msgs.length - 1 + } else { + targetIndex = currentIndex + offset + } + + if (targetIndex < 0 || targetIndex >= msgs.length) return + + setActiveMessage(msgs[targetIndex]) + } + + const last = createMemo( + () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, + ) + const model = createMemo(() => + last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, + ) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + + const tokens = createMemo(() => { + if (!last()) return + const t = last().tokens + return t.input + t.output + t.reasoning + t.cache.read + t.cache.write + }) + + const context = createMemo(() => { + const total = tokens() + const limit = model()?.limit.context + if (!total || !limit) return 0 + return Math.round((total / limit) * 100) + }) + const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, + stepsExpanded: false, }) let inputRef!: HTMLDivElement - const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" - - onMount(() => { - document.addEventListener("keydown", handleKeyDown) - }) - - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown) + createEffect(() => { + if (!params.id) return + sync.session.sync(params.id) }) createEffect(() => { if (layout.terminal.opened()) { - if (session.terminal.all().length === 0) { - session.terminal.new() + if (terminal.all().length === 0) { + terminal.new() } } }) + createEffect( + on( + () => visibleUserMessages().at(-1)?.id, + (lastId, prevLastId) => { + if (lastId && prevLastId && lastId > prevLastId) { + setMessageStore("messageId", undefined) + } + }, + { defer: true }, + ), + ) + + const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" }) + + command.register(() => [ + { + id: "session.new", + title: "New session", + description: "Create a new session", + category: "Session", + keybind: "mod+shift+s", + slash: "new", + onSelect: () => navigate(`/${params.dir}/session`), + }, + { + id: "file.open", + title: "Open file", + description: "Search and open a file", + category: "File", + keybind: "mod+p", + slash: "open", + onSelect: () => dialog.show(() => <DialogSelectFile />), + }, + // { + // id: "theme.toggle", + // title: "Toggle theme", + // description: "Switch between themes", + // category: "View", + // keybind: "ctrl+t", + // slash: "theme", + // onSelect: () => { + // const currentTheme = localStorage.getItem("theme") ?? "oc-1" + // const themes = ["oc-1", "oc-2-paper"] + // const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length] + // localStorage.setItem("theme", nextTheme) + // document.documentElement.setAttribute("data-theme", nextTheme) + // }, + // }, + { + id: "terminal.toggle", + title: "Toggle terminal", + description: "Show or hide the terminal", + category: "View", + keybind: "ctrl+`", + slash: "terminal", + onSelect: () => layout.terminal.toggle(), + }, + { + id: "terminal.new", + title: "New terminal", + description: "Create a new terminal tab", + category: "Terminal", + keybind: "ctrl+shift+`", + onSelect: () => terminal.new(), + }, + { + id: "steps.toggle", + title: "Toggle steps", + description: "Show or hide the steps", + category: "View", + keybind: "mod+e", + slash: "steps", + disabled: !params.id, + onSelect: () => setStore("stepsExpanded", (x) => !x), + }, + { + id: "message.previous", + title: "Previous message", + description: "Go to the previous user message", + category: "Session", + keybind: "mod+arrowup", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(-1), + }, + { + id: "message.next", + title: "Next message", + description: "Go to the next user message", + category: "Session", + keybind: "mod+arrowdown", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(1), + }, + { + id: "model.choose", + title: "Choose model", + description: "Select a different model", + category: "Model", + keybind: "mod+'", + slash: "model", + onSelect: () => dialog.show(() => <DialogSelectModel />), + }, + { + id: "agent.cycle", + title: "Cycle agent", + description: "Switch to the next agent", + category: "Agent", + keybind: "mod+.", + slash: "agent", + onSelect: () => local.agent.move(1), + }, + { + id: "session.undo", + title: "Undo", + description: "Undo the last message", + category: "Session", + keybind: "mod+z", + slash: "undo", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + if (status()?.type !== "idle") { + await sdk.client.session.abort({ sessionID }).catch(() => {}) + } + const revert = info()?.revert?.messageID + // Find the last user message that's not already reverted + const message = userMessages().findLast((x) => !revert || x.id < revert) + if (!message) return + await sdk.client.session.revert({ sessionID, messageID: message.id }) + // Restore the prompt from the reverted message + const parts = sync.data.part[message.id] + if (parts) { + const restored = extractPromptFromParts(parts) + prompt.set(restored) + } + // Navigate to the message before the reverted one (which will be the new last visible message) + const priorMessage = userMessages().findLast((x) => x.id < message.id) + setActiveMessage(priorMessage) + }, + }, + { + id: "session.redo", + title: "Redo", + description: "Redo the last undone message", + category: "Session", + keybind: "mod+shift+z", + slash: "redo", + disabled: !params.id || !info()?.revert?.messageID, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + const revertMessageID = info()?.revert?.messageID + if (!revertMessageID) return + const nextMessage = userMessages().find((x) => x.id > revertMessageID) + if (!nextMessage) { + // Full unrevert - restore all messages and navigate to last + await sdk.client.session.unrevert({ sessionID }) + prompt.reset() + // Navigate to the last message (the one that was at the revert point) + const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID) + setActiveMessage(lastMsg) + return + } + // Partial redo - move forward to next message + await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + // Navigate to the message before the new revert point + const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id) + setActiveMessage(priorMsg) + }, + }, + ]) + const handleKeyDown = (event: KeyboardEvent) => { - if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { - event.preventDefault() - return - } - if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { - event.preventDefault() - dialog.replace(() => <DialogSelectFile />) - return - } - if (event.ctrlKey && event.key.toLowerCase() === "t") { - event.preventDefault() - const currentTheme = localStorage.getItem("theme") ?? "oc-1" - const themes = ["oc-1", "oc-2-paper"] - const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length] - localStorage.setItem("theme", nextTheme) - document.documentElement.setAttribute("data-theme", nextTheme) - return - } - if (event.ctrlKey && event.key.toLowerCase() === "`") { - event.preventDefault() - if (event.shiftKey) { - session.terminal.new() - return - } - layout.terminal.toggle() - return - } + if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return + if (dialog.active) return - // @ts-expect-error - if (document.activeElement?.dataset?.component === "terminal") { + if (event.key === "PageUp" || event.key === "PageDown") { + const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement + if (scrollContainer) { + event.preventDefault() + const scrollAmount = scrollContainer.clientHeight * 0.8 + scrollContainer.scrollBy({ + top: event.key === "PageUp" ? -scrollAmount : scrollAmount, + behavior: "instant", + }) + } return } const focused = document.activeElement === inputRef if (focused) { - if (event.key === "Escape") { - inputRef?.blur() - } + if (event.key === "Escape") inputRef?.blur() return } - // if (local.file.active()) { - // const active = local.file.active()! - // if (event.key === "Enter" && active.selection) { - // local.context.add({ - // type: "file", - // path: active.path, - // selection: { ...active.selection }, - // }) - // return - // } - // - // if (event.getModifierState(MOD)) { - // if (event.key.toLowerCase() === "a") { - // return - // } - // if (event.key.toLowerCase() === "c") { - // return - // } - // } - // } - if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } } + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + const resetClickTimer = () => { if (!store.clickTimer) return clearTimeout(store.clickTimer) @@ -167,11 +387,11 @@ export default function Page() { const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const currentTabs = session.layout.tabs.all + const currentTabs = tabs().all() const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== undefined) { - session.layout.moveTab(draggable.id.toString(), toIndex) + tabs().move(draggable.id.toString(), toIndex) } } } @@ -189,11 +409,11 @@ export default function Page() { const handleTerminalDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const terminals = session.terminal.all() - const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString()) - const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString()) + const terminals = terminal.all() + const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) + const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { - session.terminal.move(draggable.id.toString(), toIndex) + terminal.move(draggable.id.toString(), toIndex) } } } @@ -211,8 +431,8 @@ export default function Page() { <Tabs.Trigger value={props.terminal.id} closeButton={ - session.terminal.all().length > 1 && ( - <IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(props.terminal.id)} /> + terminal.all().length > 1 && ( + <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} /> ) } > @@ -327,7 +547,7 @@ export default function Page() { return typeof draggable.id === "string" ? draggable.id : undefined } - const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) + const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length) return ( <div class="relative bg-background-base size-full overflow-x-hidden flex flex-col"> @@ -340,7 +560,7 @@ export default function Page() { > <DragDropSensors /> <ConstrainDragYAxis /> - <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}> + <Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}> <div class="sticky top-0 shrink-0 flex"> <Tabs.List> <Tabs.Trigger value="chat"> @@ -350,15 +570,15 @@ export default function Page() { value={`${new Intl.NumberFormat("en-US", { notation: "compact", compactDisplay: "short", - }).format(session.usage.tokens() ?? 0)} Tokens`} + }).format(tokens() ?? 0)} Tokens`} class="flex items-center gap-1.5" > - <ProgressCircle percentage={session.usage.context() ?? 0} /> - <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div> + <ProgressCircle percentage={context() ?? 0} /> + <div class="text-14-regular text-text-weak text-left w-7">{context() ?? 0}%</div> </Tooltip> </div> </Tabs.Trigger> - <Show when={layout.review.state() === "tab" && session.diffs().length}> + <Show when={layout.review.state() === "tab" && diffs().length}> <Tabs.Trigger value="review" closeButton={ @@ -368,25 +588,23 @@ export default function Page() { } > <div class="flex items-center gap-3"> - <Show when={session.diffs()}> - <DiffChanges changes={session.diffs()} variant="bars" /> + <Show when={diffs()}> + <DiffChanges changes={diffs()} variant="bars" /> </Show> <div class="flex items-center gap-1.5"> <div>Review</div> - <Show when={session.info()?.summary?.files}> + <Show when={info()?.summary?.files}> <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base"> - {session.info()?.summary?.files ?? 0} + {info()?.summary?.files ?? 0} </div> </Show> </div> </div> </Tabs.Trigger> </Show> - <SortableProvider ids={session.layout.tabs.all ?? []}> - <For each={session.layout.tabs.all ?? []}> - {(tab) => ( - <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} /> - )} + <SortableProvider ids={tabs().all() ?? []}> + <For each={tabs().all() ?? []}> + {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />} </For> </SortableProvider> <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3"> @@ -395,7 +613,7 @@ export default function Page() { icon="plus-small" variant="ghost" iconSize="large" - onClick={() => dialog.replace(() => <DialogSelectFile />)} + onClick={() => dialog.show(() => <DialogSelectFile />)} /> </Tooltip> </div> @@ -416,29 +634,33 @@ export default function Page() { }} > <Switch> - <Match when={session.id}> + <Match when={params.id}> <div class="flex items-start justify-start h-full min-h-0"> <SessionMessageRail - messages={session.messages.user()} - current={session.messages.active()} - onMessageSelect={session.messages.setActive} + messages={visibleUserMessages()} + current={activeMessage()} + onMessageSelect={setActiveMessage} wide={wide()} /> - <SessionTurn - sessionID={session.id!} - messageID={session.messages.active()?.id!} - classes={{ - root: "pb-20 flex-1 min-w-0", - content: "pb-20", - container: - "w-full " + - (wide() - ? "max-w-146 mx-auto px-6" - : session.messages.user().length > 1 - ? "pr-6 pl-18" - : "px-6"), - }} - /> + <Show when={activeMessage()}> + <SessionTurn + sessionID={params.id!} + messageID={activeMessage()!.id} + stepsExpanded={store.stepsExpanded} + onStepsExpandedChange={(expanded) => setStore("stepsExpanded", expanded)} + classes={{ + root: "pb-20 flex-1 min-w-0", + content: "pb-20", + container: + "w-full " + + (wide() + ? "max-w-146 mx-auto px-6" + : visibleUserMessages().length > 1 + ? "pr-6 pl-18" + : "px-6"), + }} + /> + </Show> </div> </Match> <Match when={true}> @@ -477,7 +699,7 @@ export default function Page() { </div> </div> </div> - <Show when={layout.review.state() === "pane" && session.diffs().length}> + <Show when={layout.review.state() === "pane" && diffs().length}> <div classList={{ "relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true, @@ -489,7 +711,7 @@ export default function Page() { header: "px-6", container: "px-6", }} - diffs={session.diffs()} + diffs={diffs()} actions={ <Tooltip value="Open in tab"> <IconButton @@ -497,7 +719,7 @@ export default function Page() { variant="ghost" onClick={() => { layout.review.tab() - session.layout.setActiveTab("review") + tabs().setActive("review") }} /> </Tooltip> @@ -507,7 +729,7 @@ export default function Page() { </Show> </div> </Tabs.Content> - <Show when={layout.review.state() === "tab" && session.diffs().length}> + <Show when={layout.review.state() === "tab" && diffs().length}> <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden"> <div classList={{ @@ -520,13 +742,13 @@ export default function Page() { header: "px-6", container: "px-6", }} - diffs={session.diffs()} + diffs={diffs()} split /> </div> </Tabs.Content> </Show> - <For each={session.layout.tabs.all}> + <For each={tabs().all()}> {(tab) => { const [file] = createResource( () => tab, @@ -580,7 +802,7 @@ export default function Page() { </Show> </DragOverlay> </DragDropProvider> - <Show when={session.layout.tabs.active}> + <Show when={tabs().active()}> <div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8"> <PromptInput ref={(el) => { @@ -589,34 +811,6 @@ export default function Page() { /> </div> </Show> - <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto"> - {/* <FileTree path="" onFileClick={ handleTabClick} /> */} - </div> - <div class="hidden shrink-0 w-56 p-2"> - <Show - when={local.file.changes().length} - fallback={<div class="px-2 text-xs text-text-muted">No changes</div>} - > - <ul class=""> - <For each={local.file.changes()}> - {(path) => ( - <li> - <button - onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })} - class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element" - > - <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" /> - <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span> - <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0"> - {getDirectory(path)} - </span> - </button> - </li> - )} - </For> - </ul> - </Show> - </div> </div> <Show when={layout.terminal.opened()}> <div @@ -640,25 +834,21 @@ export default function Page() { > <DragDropSensors /> <ConstrainDragYAxis /> - <Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}> + <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}> <Tabs.List class="h-10"> - <SortableProvider ids={session.terminal.all().map((t) => t.id)}> - <For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For> + <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}> + <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For> </SortableProvider> <div class="h-full flex items-center justify-center"> <Tooltip value="New Terminal" class="flex items-center"> - <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} /> + <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} /> </Tooltip> </div> </Tabs.List> - <For each={session.terminal.all()}> - {(terminal) => ( - <Tabs.Content value={terminal.id}> - <Terminal - pty={terminal} - onCleanup={session.terminal.update} - onConnectError={() => session.terminal.clone(terminal.id)} - /> + <For each={terminal.all()}> + {(pty) => ( + <Tabs.Content value={pty.id}> + <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} /> </Tabs.Content> )} </For> @@ -666,9 +856,9 @@ export default function Page() { <DragOverlay> <Show when={store.activeTerminalDraggable}> {(draggedId) => { - const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId())) + const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) return ( - <Show when={terminal()}> + <Show when={pty()}> {(t) => ( <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> {t().title} |
