summaryrefslogtreecommitdiffhomepage
path: root/packages/app
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app')
-rw-r--r--packages/app/src/components/prompt-input.tsx122
-rw-r--r--packages/app/src/components/session/session-header.tsx17
-rw-r--r--packages/app/src/components/session/session-new-view.tsx48
-rw-r--r--packages/app/src/pages/layout.tsx170
-rw-r--r--packages/app/src/pages/session.tsx28
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>