Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 110 additions & 12 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
usePrompt,
ImageAttachmentPart,
AgentPart,
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
Expand All @@ -33,13 +34,21 @@ 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"]

interface PromptInputProps {
class?: string
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
}

const PLACEHOLDERS = [
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 7 additions & 10 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand All @@ -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)}`)
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
48 changes: 46 additions & 2 deletions packages/app/src/components/session/session-new-view.tsx
Original file line number Diff line number Diff line change
@@ -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>
Expand All @@ -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">
Expand Down
Loading
Loading