diff options
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 122 | ||||
| -rw-r--r-- | packages/app/src/components/session/session-header.tsx | 17 | ||||
| -rw-r--r-- | packages/app/src/components/session/session-new-view.tsx | 48 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 170 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 28 |
5 files changed, 271 insertions, 114 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 8d917213a..9657507ff 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -12,6 +12,7 @@ import { usePrompt, ImageAttachmentPart, AgentPart, + FileAttachmentPart, } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" @@ -33,6 +34,12 @@ import { persisted } from "@/utils/persist" import { Identifier } from "@/utils/id" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" +import { useGlobalSync } from "@/context/global-sync" +import { usePlatform } from "@/context/platform" +import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client" +import { Binary } from "@opencode-ai/util/binary" +import { showToast } from "@opencode-ai/ui/toast" +import { base64Encode } from "@opencode-ai/util/encode" const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] @@ -40,6 +47,8 @@ const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] interface PromptInputProps { class?: string ref?: (el: HTMLDivElement) => void + newSessionWorktree?: string + onNewSessionWorktreeReset?: () => void } const PLACEHOLDERS = [ @@ -83,6 +92,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const navigate = useNavigate() const sdk = useSDK() const sync = useSync() + const globalSync = useGlobalSync() + const platform = usePlatform() const local = useLocal() const files = useFile() const prompt = usePrompt() @@ -1164,18 +1175,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => { setStore("historyIndex", -1) setStore("savedPrompt", null) + const projectDirectory = sdk.directory + const isNewSession = !params.id + const worktreeSelection = props.newSessionWorktree ?? "main" + + let sessionDirectory = projectDirectory + let client = sdk.client + + if (isNewSession) { + if (worktreeSelection === "create") { + const createdWorktree = await client.worktree + .create({ directory: projectDirectory }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: "Failed to create worktree", + description: err?.data?.message ?? (err instanceof Error ? err.message : "Request failed"), + }) + return undefined + }) + + if (!createdWorktree?.directory) { + showToast({ + title: "Failed to create worktree", + description: "Request failed", + }) + return + } + sessionDirectory = createdWorktree.directory + } else if (worktreeSelection !== "main") { + sessionDirectory = worktreeSelection + } + + if (sessionDirectory !== projectDirectory) { + client = createOpencodeClient({ + baseUrl: sdk.url, + fetch: platform.fetch, + directory: sessionDirectory, + throwOnError: true, + }) + } + } + + if (isNewSession) { + if (sessionDirectory !== projectDirectory) { + globalSync.child(sessionDirectory) + } + props.onNewSessionWorktreeReset?.() + } + let existing = info() - if (!existing) { - const created = await sdk.client.session.create() + if (!existing && isNewSession) { + const created = await client.session.create() existing = created.data ?? undefined - if (existing) navigate(existing.id) + if (existing) navigate(`/${base64Encode(sessionDirectory)}/session/${existing.id}`) } if (!existing) return - const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) - const fileAttachments = currentPrompt.filter( - (part) => part.type === "file", - ) as import("@/context/prompt").FileAttachmentPart[] + const toAbsolutePath = (path: string) => + path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/") + const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[] const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[] const fileAttachmentParts = fileAttachments.map((attachment) => { @@ -1275,7 +1334,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const variant = local.model.variant.current() if (isShellMode) { - sdk.client.session + client.session .shell({ sessionID: existing.id, agent, @@ -1293,7 +1352,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const commandName = cmdName.slice(1) const customCommand = sync.data.command.find((c) => c.name === commandName) if (customCommand) { - sdk.client.session + client.session .command({ sessionID: existing.id, command: commandName, @@ -1328,15 +1387,54 @@ export const PromptInput: Component<PromptInputProps> = (props) => { messageID, })) - sync.session.addOptimisticMessage({ + const addOptimisticMessage = (input: { + sessionID: string + messageID: string + parts: Part[] + agent: string + model: { providerID: string; modelID: string } + }) => { + if (sessionDirectory === projectDirectory) { + sync.session.addOptimisticMessage(input) + return + } + + const [, setStore] = globalSync.child(sessionDirectory) + const message: Message = { + id: input.messageID, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent: input.agent, + model: input.model, + } + + setStore( + produce((draft) => { + const messages = draft.message[input.sessionID] + if (!messages) { + draft.message[input.sessionID] = [message] + } else { + const result = Binary.search(messages, input.messageID, (m) => m.id) + messages.splice(result.index, 0, message) + } + draft.part[input.messageID] = input.parts + .filter((p) => !!p?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + }), + ) + } + + addOptimisticMessage({ sessionID: existing.id, messageID, - parts: optimisticParts, + parts: optimisticParts as unknown as Part[], agent, model, }) - sdk.client.session + client.session .prompt({ sessionID: existing.id, agent, diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 0a0b53607..7e4ade523 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,4 +1,4 @@ -import { createMemo, createResource, Show } from "solid-js" +import { createEffect, createMemo, createResource, Show } from "solid-js" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" @@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { iife } from "@opencode-ai/util/iife" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -31,10 +31,11 @@ export function SessionHeader() { const dialog = useDialog() const sync = useSync() + const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID)) const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - const branch = createMemo(() => sync.data.vcs?.branch) function navigateToProject(directory: string) { navigate(`/${base64Encode(directory)}`) @@ -60,12 +61,8 @@ export function SessionHeader() { <div class="hidden xl:flex items-center gap-2"> <Select options={layout.projects.list().map((project) => project.worktree)} - current={sync.directory} - label={(x) => { - const name = getFilename(x) - const b = x === sync.directory ? branch() : undefined - return b ? `${name}:${b}` : name - }} + current={sync.project?.worktree ?? projectDirectory()} + label={(x) => getFilename(x)} onSelect={(x) => (x ? navigateToProject(x) : undefined)} class="text-14-regular text-text-base" variant="ghost" @@ -191,7 +188,7 @@ export function SessionHeader() { let shareURL = session.share?.url if (!shareURL) { shareURL = await globalSDK.client.session - .share({ sessionID: session.id, directory: sync.directory }) + .share({ sessionID: session.id, directory: projectDirectory() }) .then((r) => r.data?.share?.url) .catch((e) => { console.error("Failed to share session", e) diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 90a280612..bd8033ae6 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -1,12 +1,41 @@ -import { Show } from "solid-js" +import { Show, createMemo } from "solid-js" import { DateTime } from "luxon" import { useSync } from "@/context/sync" import { Icon } from "@opencode-ai/ui/icon" import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { Select } from "@opencode-ai/ui/select" -export function NewSessionView() { +const MAIN_WORKTREE = "main" +const CREATE_WORKTREE = "create" + +interface NewSessionViewProps { + worktree: string + onWorktreeChange: (value: string) => void +} + +export function NewSessionView(props: NewSessionViewProps) { const sync = useSync() + const sandboxes = createMemo(() => sync.project?.sandboxes ?? []) + const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) + const current = createMemo(() => { + const selection = props.worktree + if (options().includes(selection)) return selection + return MAIN_WORKTREE + }) + + const label = (value: string) => { + if (value === MAIN_WORKTREE) { + const branch = sync.data.vcs?.branch + if (branch) return `Current branch (${branch})` + return "Main branch" + } + + if (value === CREATE_WORKTREE) return "Create new worktree" + + return getFilename(value) + } + return ( <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6"> <div class="text-20-medium text-text-weaker">New session</div> @@ -17,6 +46,21 @@ export function NewSessionView() { <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span> </div> </div> + <div class="flex justify-center items-center gap-1"> + <Icon name="branch" size="small" /> + <Select + options={options()} + current={current()} + value={(x) => x} + label={label} + onSelect={(value) => { + props.onWorktreeChange(value ?? MAIN_WORKTREE) + }} + size="normal" + variant="ghost" + class="text-12-medium" + /> + </div> <Show when={sync.project}> {(project) => ( <div class="flex justify-center items-center gap-3"> diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index e237d2184..91df9259e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -172,9 +172,9 @@ export default function Layout(props: ParentProps) { const perm = e.details.properties if (permission.autoResponds(perm)) return - const sessionKey = `${directory}:${perm.sessionID}` const [store] = globalSync.child(directory) const session = store.session.find((s) => s.id === perm.sessionID) + const sessionKey = `${directory}:${perm.sessionID}` const sessionTitle = session?.title ?? "New session" const projectName = getFilename(directory) @@ -665,14 +665,13 @@ export default function Layout(props: ParentProps) { <> <div data-session-id={props.session.id} - class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors + class="group/session relative w-full 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": "16px" }} > <Tooltip placement={props.mobile ? "bottom" : "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" + class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1" > <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 @@ -740,10 +739,17 @@ export default function Layout(props: ParentProps) { const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) - const slug = createMemo(() => base64Encode(props.project.worktree)) + const defaultWorktree = createMemo(() => base64Encode(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const [store, setProjectStore] = globalSync.child(props.project.worktree) - const sessions = createMemo(() => store.session.toSorted(sortSessions)) + const stores = createMemo(() => + [props.project.worktree, ...(props.project.sandboxes ?? [])].map((dir) => globalSync.child(dir)[0]), + ) + const sessions = createMemo(() => + stores() + .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory)) + .toSorted(sortSessions), + ) const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) const hasMoreSessions = createMemo(() => store.session.length >= store.limit) const loadMoreSessions = async () => { @@ -799,7 +805,7 @@ export default function Layout(props: ParentProps) { </DropdownMenu.Portal> </DropdownMenu> <TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}> - <IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" /> + <IconButton as={A} href={`${defaultWorktree()}/session`} icon="plus-small" variant="ghost" /> </TooltipKeybind> </div> </Button> @@ -807,7 +813,12 @@ export default function Layout(props: ParentProps) { <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5"> <For each={rootSessions()}> {(session) => ( - <SessionItem session={session} slug={slug()} project={props.project} mobile={props.mobile} /> + <SessionItem + session={session} + slug={base64Encode(session.directory)} + project={props.project} + mobile={props.mobile} + /> )} </For> <Show when={rootSessions().length === 0}> @@ -819,7 +830,7 @@ export default function Layout(props: ParentProps) { <div class="flex-1 min-w-0"> <Tooltip placement={props.mobile ? "bottom" : "right"} value="New session"> <A - href={`${slug()}/session`} + href={`${defaultWorktree()}/session`} class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none" > <div class="flex items-center self-stretch gap-6 justify-between"> @@ -875,76 +886,85 @@ export default function Layout(props: ParentProps) { const SidebarContent = (sidebarProps: { mobile?: boolean }) => { const expanded = () => sidebarProps.mobile || layout.sidebar.opened() return ( - <> - <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden"> + <div class="flex flex-col self-stretch h-full items-center justify-between overflow-hidden min-h-0"> + <div class="flex flex-col items-start self-stretch gap-4 min-h-0"> <Show when={!sidebarProps.mobile}> - <A href="/" class="shrink-0 h-8 flex items-center justify-start px-2" data-tauri-drag-region> - <Mark class="shrink-0" /> - </A> - </Show> - <Show when={!sidebarProps.mobile}> - <TooltipKeybind - class="shrink-0" - placement="right" - title="Toggle sidebar" - keybind={command.keybind("sidebar.toggle")} - inactive={expanded()} - > - <Button - variant="ghost" - size="large" - class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2" - onClick={layout.sidebar.toggle} - > - <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> - <Icon - name={layout.sidebar.opened() ? "layout-left" : "layout-right"} - size="small" - class="group-hover/sidebar-toggle:hidden" - /> - <Icon - name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"} - size="small" - class="hidden group-hover/sidebar-toggle:inline-block" - /> - <Icon - name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"} - size="small" - class="hidden group-active/sidebar-toggle:inline-block" - /> - </div> - <Show when={layout.sidebar.opened()}> - <div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base"> - Toggle sidebar - </div> - </Show> - </Button> - </TooltipKeybind> - </Show> - <DragDropProvider - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - collisionDetector={closestCenter} - > - <DragDropSensors /> - <ConstrainDragXAxis /> <div - ref={(el) => { - if (!sidebarProps.mobile) scrollContainerRef = el + classList={{ + "border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0": true, + "justify-start": expanded(), }} - 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} mobile={sidebarProps.mobile} />} - </For> - </SortableProvider> + <A href="/" class="shrink-0 h-8 flex items-center justify-start px-2 w-full" data-tauri-drag-region> + <Mark class="shrink-0" /> + </A> </div> - <DragOverlay> - <ProjectDragOverlay /> - </DragOverlay> - </DragDropProvider> + </Show> + <div class="flex flex-col items-start self-stretch gap-4 px-2 overflow-hidden min-h-0"> + <Show when={!sidebarProps.mobile}> + <TooltipKeybind + class="shrink-0" + placement="right" + title="Toggle sidebar" + keybind={command.keybind("sidebar.toggle")} + inactive={expanded()} + > + <Button + variant="ghost" + size="large" + class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2" + onClick={layout.sidebar.toggle} + > + <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> + <Icon + name={layout.sidebar.opened() ? "layout-left" : "layout-right"} + size="small" + class="group-hover/sidebar-toggle:hidden" + /> + <Icon + name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"} + size="small" + class="hidden group-hover/sidebar-toggle:inline-block" + /> + <Icon + name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"} + size="small" + class="hidden group-active/sidebar-toggle:inline-block" + /> + </div> + <Show when={layout.sidebar.opened()}> + <div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base"> + Toggle sidebar + </div> + </Show> + </Button> + </TooltipKeybind> + </Show> + <DragDropProvider + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + collisionDetector={closestCenter} + > + <DragDropSensors /> + <ConstrainDragXAxis /> + <div + ref={(el) => { + if (!sidebarProps.mobile) scrollContainerRef = el + }} + 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} mobile={sidebarProps.mobile} />} + </For> + </SortableProvider> + </div> + <DragOverlay> + <ProjectDragOverlay /> + </DragOverlay> + </DragDropProvider> + </div> </div> <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3"> <Switch> @@ -1017,7 +1037,7 @@ export default function Layout(props: ParentProps) { </Button> </Tooltip> </div> - </> + </div> ) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 0a0cd519f..883565dd3 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -218,20 +218,12 @@ export default function Page() { return sync.data.message[id] !== undefined }) const emptyUserMessages: UserMessage[] = [] - const userMessages = createMemo( - () => messages().filter((m) => m.role === "user") as UserMessage[], - emptyUserMessages, - { equals: same }, - ) - const visibleUserMessages = createMemo( - () => { - const revert = revertMessageID() - if (!revert) return userMessages() - return userMessages().filter((m) => m.id < revert) - }, - emptyUserMessages, - { equals: same }, - ) + const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages) + const visibleUserMessages = createMemo(() => { + const revert = revertMessageID() + if (!revert) return userMessages() + return userMessages().filter((m) => m.id < revert) + }, emptyUserMessages) const lastUserMessage = createMemo(() => visibleUserMessages().at(-1)) createEffect( @@ -256,6 +248,7 @@ export default function Page() { mobileTab: "session" as "session" | "review", ignoreScrollSpy: false, initialScrollDone: !params.id, + newSessionWorktree: "main", }) const activeMessage = createMemo(() => { @@ -1017,7 +1010,10 @@ export default function Page() { </Show> </Match> <Match when={true}> - <NewSessionView /> + <NewSessionView + worktree={store.newSessionWorktree} + onWorktreeChange={(value) => setStore("newSessionWorktree", value)} + /> </Match> </Switch> </div> @@ -1034,6 +1030,8 @@ export default function Page() { ref={(el) => { inputRef = el }} + newSessionWorktree={store.newSessionWorktree} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} /> </div> </div> |
