summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/components')
-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
3 files changed, 163 insertions, 24 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">