summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'packages/desktop/src/pages')
-rw-r--r--packages/desktop/src/pages/index.tsx857
-rw-r--r--packages/desktop/src/pages/layout.tsx75
-rw-r--r--packages/desktop/src/pages/session-layout.tsx12
-rw-r--r--packages/desktop/src/pages/session.tsx693
4 files changed, 780 insertions, 857 deletions
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
deleted file mode 100644
index 5f04c3dbe..000000000
--- a/packages/desktop/src/pages/index.tsx
+++ /dev/null
@@ -1,857 +0,0 @@
-import {
- Button,
- List,
- SelectDialog,
- Tooltip,
- IconButton,
- Tabs,
- Icon,
- Accordion,
- Diff,
- Collapsible,
- DiffChanges,
- Message,
- Typewriter,
- Card,
- Code,
-} from "@opencode-ai/ui"
-import { FileIcon } from "@/ui"
-import FileTree from "@/components/file-tree"
-import { MessageProgress } from "@/components/message-progress"
-import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
-import { useLocal, type LocalFile } from "@/context/local"
-import { createStore } from "solid-js/store"
-import { getDirectory, getFilename } from "@/utils"
-import { ContentPart, PromptInput } from "@/components/prompt-input"
-import { DateTime } from "luxon"
-import {
- DragDropProvider,
- DragDropSensors,
- DragOverlay,
- SortableProvider,
- closestCenter,
- createSortable,
- useDragDropContext,
-} from "@thisbeyond/solid-dnd"
-import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
-import type { JSX } from "solid-js"
-import { useSync } from "@/context/sync"
-import { useSDK } from "@/context/sdk"
-import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
-import { Markdown } from "@opencode-ai/ui"
-import { Spinner } from "@/components/spinner"
-
-export default function Page() {
- const local = useLocal()
- const sync = useSync()
- const sdk = useSDK()
- const [store, setStore] = createStore({
- clickTimer: undefined as number | undefined,
- fileSelectOpen: false,
- })
- let inputRef!: HTMLDivElement
- let messageScrollElement!: HTMLDivElement
- const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
-
- const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
-
- onMount(() => {
- document.addEventListener("keydown", handleKeyDown)
- })
-
- onCleanup(() => {
- document.removeEventListener("keydown", handleKeyDown)
- })
-
- 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()
- setStore("fileSelectOpen", true)
- return
- }
-
- const focused = document.activeElement === inputRef
- if (focused) {
- 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()
- }
- }
-
- const resetClickTimer = () => {
- if (!store.clickTimer) return
- clearTimeout(store.clickTimer)
- setStore("clickTimer", undefined)
- }
-
- const startClickTimer = () => {
- const newClickTimer = setTimeout(() => {
- setStore("clickTimer", undefined)
- }, 300)
- setStore("clickTimer", newClickTimer as unknown as number)
- }
-
- const handleFileClick = async (file: LocalFile) => {
- if (store.clickTimer) {
- resetClickTimer()
- local.file.update(file.path, { ...file, pinned: true })
- } else {
- local.file.open(file.path)
- startClickTimer()
- }
- }
-
- // const navigateChange = (dir: 1 | -1) => {
- // const active = local.file.active()
- // if (!active) return
- // const current = local.file.changeIndex(active.path)
- // const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
- // local.file.setChangeIndex(active.path, next)
- // }
-
- const handleTabChange = (path: string) => {
- local.session.setActiveTab(path)
- if (path === "chat") return
- local.session.open(path)
- }
-
- const handleTabClose = (file: LocalFile) => {
- local.session.close(file.path)
- }
-
- const handleDragStart = (event: unknown) => {
- const id = getDraggableId(event)
- if (!id) return
- setActiveItem(id)
- }
-
- const handleDragOver = (event: DragEvent) => {
- const { draggable, droppable } = event
- if (draggable && droppable) {
- const currentFiles = local.session.tabs()?.opened.map((file) => file)
- const fromIndex = currentFiles?.indexOf(draggable.id.toString())
- const toIndex = currentFiles?.indexOf(droppable.id.toString())
- if (fromIndex !== toIndex && toIndex !== undefined) {
- local.session.move(draggable.id.toString(), toIndex)
- }
- }
- }
-
- const handleDragEnd = () => {
- setActiveItem(undefined)
- }
-
- // const scrollDiffItem = (element: HTMLElement) => {
- // element.scrollIntoView({ block: "start", behavior: "instant" })
- // }
-
- const handleDiffTriggerClick = (event: MouseEvent) => {
- // disabling scroll to diff for now
- return
- // const target = event.currentTarget as HTMLElement
- // queueMicrotask(() => {
- // if (target.getAttribute("aria-expanded") !== "true") return
- // const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
- // if (!item) return
- // scrollDiffItem(item)
- // })
- }
-
- const handlePromptSubmit = async (parts: ContentPart[]) => {
- const existingSession = local.session.active()
- let session = existingSession
- if (!session) {
- const created = await sdk.client.session.create()
- session = created.data ?? undefined
- }
- if (!session) return
-
- local.session.setActive(session.id)
- if (!existingSession) {
- local.session.copyTabs("", session.id)
- }
- local.session.setActiveTab(undefined)
- const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
-
- const text = parts.map((part) => part.content).join("")
- const attachments = parts.filter((part) => part.type === "file")
-
- // const activeFile = local.context.active()
- // if (activeFile) {
- // registerAttachment(
- // activeFile.path,
- // activeFile.selection,
- // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
- // )
- // }
-
- // for (const contextFile of local.context.all()) {
- // registerAttachment(
- // contextFile.path,
- // contextFile.selection,
- // formatAttachmentLabel(contextFile.path, contextFile.selection),
- // )
- // }
-
- const attachmentParts = attachments.map((attachment) => {
- const absolute = toAbsolutePath(attachment.path)
- const query = attachment.selection
- ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
- : ""
- return {
- type: "file" as const,
- mime: "text/plain",
- url: `file://${absolute}${query}`,
- filename: getFilename(attachment.path),
- source: {
- type: "file" as const,
- text: {
- value: attachment.content,
- start: attachment.start,
- end: attachment.end,
- },
- path: absolute,
- },
- }
- })
-
- await sdk.client.session.prompt({
- path: { id: session.id },
- body: {
- agent: local.agent.current()!.name,
- model: {
- modelID: local.model.current()!.id,
- providerID: local.model.current()!.provider.id,
- },
- parts: [
- {
- type: "text",
- text,
- },
- ...attachmentParts,
- ],
- },
- })
- }
-
- const handleNewSession = () => {
- local.session.setActive(undefined)
- inputRef?.focus()
- }
-
- const TabVisual = (props: { file: LocalFile }): JSX.Element => {
- return (
- <div class="flex items-center gap-x-1.5">
- <FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
- <span
- classList={{
- "text-14-medium": true,
- "text-primary": !!props.file.status?.status,
- italic: !props.file.pinned,
- }}
- >
- {props.file.name}
- </span>
- <span class="hidden opacity-70">
- <Switch>
- <Match when={props.file.status?.status === "modified"}>
- <span class="text-primary">M</span>
- </Match>
- <Match when={props.file.status?.status === "added"}>
- <span class="text-success">A</span>
- </Match>
- <Match when={props.file.status?.status === "deleted"}>
- <span class="text-error">D</span>
- </Match>
- </Switch>
- </span>
- </div>
- )
- }
-
- const SortableTab = (props: {
- file: LocalFile
- onTabClick: (file: LocalFile) => void
- onTabClose: (file: LocalFile) => void
- }): JSX.Element => {
- const sortable = createSortable(props.file.path)
-
- return (
- // @ts-ignore
- <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
- <Tooltip value={props.file.path} placement="bottom" class="h-full">
- <div class="relative h-full">
- <Tabs.Trigger
- value={props.file.path}
- class="group/tab pl-3 pr-1"
- onClick={() => props.onTabClick(props.file)}
- >
- <TabVisual file={props.file} />
- <IconButton
- icon="close"
- class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100
- group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle
- hover:opacity-100 group-hover/tab:opacity-100"
- variant="ghost"
- onClick={() => props.onTabClose(props.file)}
- />
- </Tabs.Trigger>
- </div>
- </Tooltip>
- </div>
- )
- }
-
- const ConstrainDragYAxis = (): JSX.Element => {
- const context = useDragDropContext()
- if (!context) return <></>
- const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
- const transformer: Transformer = {
- id: "constrain-y-axis",
- order: 100,
- callback: (transform) => ({ ...transform, y: 0 }),
- }
- onDragStart((event) => {
- const id = getDraggableId(event)
- if (!id) return
- addTransformer("draggables", id, transformer)
- })
- onDragEnd((event) => {
- const id = getDraggableId(event)
- if (!id) return
- removeTransformer("draggables", id, transformer.id)
- })
- return <></>
- }
-
- const getDraggableId = (event: unknown): string | undefined => {
- if (typeof event !== "object" || event === null) return undefined
- if (!("draggable" in event)) return undefined
- const draggable = (event as { draggable?: { id?: unknown } }).draggable
- if (!draggable) return undefined
- return typeof draggable.id === "string" ? draggable.id : undefined
- }
-
- return (
- <div class="relative h-screen flex flex-col">
- <header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
- <main class="h-[calc(100vh-0rem)] flex">
- <div class="w-70 shrink-0 bg-background-weak border-r border-border-weak-base flex flex-col items-start">
- <div class="h-10 flex items-center self-stretch px-5 border-b border-border-weak-base">
- <span class="text-14-regular overflow-hidden text-ellipsis">{getFilename(sync.data.path.directory)}</span>
- </div>
- <div class="flex flex-col items-start gap-4 self-stretch flex-1 py-4 px-3">
- <Button class="w-full" size="large" onClick={handleNewSession} icon="edit-small-2">
- New Session
- </Button>
- <List
- data={sync.data.session}
- key={(x) => x.id}
- current={local.session.active()}
- onSelect={(s) => local.session.setActive(s?.id)}
- onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)}
- >
- {(session) => {
- const diffs = createMemo(() => session.summary?.diffs ?? [])
- const filesChanged = createMemo(() => diffs().length)
- const updated = DateTime.fromMillis(session.time.updated)
- return (
- <Tooltip placement="right" value={session.title}>
- <div>
- <div class="flex items-center self-stretch gap-6 justify-between">
- <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
- {session.title}
- </span>
- <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>
- </div>
- <div class="flex justify-between items-center self-stretch">
- <span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span>
- <DiffChanges diff={diffs()} />
- </div>
- </div>
- </Tooltip>
- )
- }}
- </List>
- </div>
- </div>
- <div class="relative bg-background-base w-full h-full overflow-x-hidden">
- <DragDropProvider
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onDragOver={handleDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragYAxis />
- <Tabs value={local.session.tabs()?.active ?? "chat"} onChange={handleTabChange}>
- <div class="sticky top-0 shrink-0 flex">
- <Tabs.List>
- <Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
- <div>Chat</div>
- {/* <Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5"> */}
- {/* <ProgressCircle percentage={local.session.context() ?? 0} /> */}
- {/* <div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div> */}
- {/* </Tooltip> */}
- </Tabs.Trigger>
- {/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
- <SortableProvider ids={local.session.tabs()?.opened ?? []}>
- <For each={local.session.tabs()?.opened ?? []}>
- {(file) => (
- <SortableTab
- file={local.file.node(file)}
- onTabClick={handleFileClick}
- onTabClose={handleTabClose}
- />
- )}
- </For>
- </SortableProvider>
- <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
- <IconButton
- icon="plus-small"
- variant="ghost"
- iconSize="large"
- onClick={() => setStore("fileSelectOpen", true)}
- />
- </div>
- </Tabs.List>
- </div>
- <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
- <div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
- <Show
- when={local.session.active()}
- fallback={
- <div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
- <div class="text-20-medium text-text-weaker">New session</div>
- <div class="flex justify-center items-center gap-3">
- <Icon name="folder" size="small" />
- <div class="text-12-medium text-text-weak">
- {getDirectory(sync.data.path.directory)}
- <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
- </div>
- </div>
- <div class="flex justify-center items-center gap-3">
- <Icon name="pencil-line" size="small" />
- <div class="text-12-medium text-text-weak">
- Last modified&nbsp;
- <span class="text-text-strong">
- {DateTime.fromMillis(sync.data.project.time.created).toRelative()}
- </span>
- </div>
- </div>
- </div>
- }
- >
- {(session) => {
- const [store, setStore] = createStore<{
- messageId?: string
- }>()
-
- const messages = createMemo(() => sync.data.message[session().id] ?? [])
- const userMessages = createMemo(() =>
- messages()
- .filter((m) => m.role === "user")
- .sort((a, b) => b.id.localeCompare(a.id)),
- )
- const lastUserMessage = createMemo(() => {
- return userMessages()?.at(0)
- })
- const activeMessage = createMemo(() => {
- if (!store.messageId) return lastUserMessage()
- return userMessages()?.find((m) => m.id === store.messageId)
- })
-
- return (
- <div class="pt-3 flex flex-col flex-1 min-h-0">
- <div class="flex-1 min-h-0">
- <Show when={userMessages().length > 1}>
- <ul
- role="list"
- class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
- >
- <For each={userMessages()}>
- {(message) => {
- const diffs = createMemo(() => message.summary?.diffs ?? [])
- const assistantMessages = createMemo(() => {
- return sync.data.message[session().id]?.filter(
- (m) => m.role === "assistant" && m.parentID == message.id,
- ) as AssistantMessageType[]
- })
- const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
- const working = createMemo(() => !message.summary?.body && !error())
-
- return (
- <li class="group/li flex items-center self-stretch">
- <button
- class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
- onClick={() => setStore("messageId", message.id)}
- >
- <Switch>
- <Match when={working()}>
- <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
- </Match>
- <Match when={true}>
- <DiffChanges diff={diffs()} variant="bars" />
- </Match>
- </Switch>
- <div
- data-active={activeMessage()?.id === message.id}
- classList={{
- "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
- "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
- }}
- >
- <Show when={message.summary?.title} fallback="New message">
- {message.summary?.title}
- </Show>
- </div>
- </button>
- </li>
- )
- }}
- </For>
- </ul>
- </Show>
- <div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
- <For each={userMessages()}>
- {(message) => {
- const isActive = createMemo(() => activeMessage()?.id === message.id)
- const [titled, setTitled] = createSignal(!!message.summary?.title)
- const assistantMessages = createMemo(() => {
- return sync.data.message[session().id]?.filter(
- (m) => m.role === "assistant" && m.parentID == message.id,
- ) as AssistantMessageType[]
- })
- const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
- const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
- const [expanded, setExpanded] = createSignal(false)
- const parts = createMemo(() => sync.data.part[message.id])
- const title = createMemo(() => message.summary?.title)
- const summary = createMemo(() => message.summary?.body)
- const diffs = createMemo(() => message.summary?.diffs ?? [])
- const hasToolPart = createMemo(() =>
- assistantMessages()
- ?.flatMap((m) => sync.data.part[m.id])
- .some((p) => p?.type === "tool"),
- )
- const working = createMemo(() => !summary() && !error())
-
- // allowing time for the animations to finish
- createEffect(() => {
- title()
- setTimeout(() => setTitled(!!title()), 10_000)
- })
- createEffect(() => {
- const complete = !!summary() || !!error()
- setTimeout(() => setCompleted(complete), 1200)
- })
-
- return (
- <Show when={isActive()}>
- <div
- data-message={message.id}
- class="flex flex-col items-start self-stretch gap-8 pb-50"
- >
- {/* Title */}
- <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
- <div class="w-full text-14-medium text-text-strong">
- <Show
- when={titled()}
- fallback={
- <Typewriter
- as="h1"
- text={title()}
- class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
- />
- }
- >
- <h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
- {title()}
- </h1>
- </Show>
- </div>
- </div>
- <div class="-mt-8">
- <Message message={message} parts={parts()} />
- </div>
- {/* Summary */}
- <Show when={completed()}>
- <div class="w-full flex flex-col gap-6 items-start self-stretch">
- <div class="flex flex-col items-start gap-1 self-stretch">
- <h2 class="text-12-medium text-text-weak">
- <Switch>
- <Match when={diffs().length}>Summary</Match>
- <Match when={true}>Response</Match>
- </Switch>
- </h2>
- <Show when={summary()}>
- {(summary) => (
- <Markdown
- classList={{ "[&>*]:fade-up-text": !diffs().length }}
- text={summary()}
- />
- )}
- </Show>
- </div>
- <Accordion class="w-full" multiple>
- <For each={diffs()}>
- {(diff) => (
- <Accordion.Item value={diff.file}>
- <Accordion.Header>
- <Accordion.Trigger onClick={handleDiffTriggerClick}>
- <div class="flex items-center justify-between w-full gap-5">
- <div class="grow flex items-center gap-5 min-w-0">
- <FileIcon
- node={{ path: diff.file, type: "file" }}
- class="shrink-0 size-4"
- />
- <div class="flex grow min-w-0">
- <Show when={diff.file.includes("/")}>
- <span class="text-text-base truncate-start">
- {getDirectory(diff.file)}&lrm;
- </span>
- </Show>
- <span class="text-text-strong shrink-0">
- {getFilename(diff.file)}
- </span>
- </div>
- </div>
- <div class="shrink-0 flex gap-4 items-center justify-end">
- <DiffChanges diff={diff} />
- <Icon name="chevron-grabber-vertical" size="small" />
- </div>
- </div>
- </Accordion.Trigger>
- </Accordion.Header>
- <Accordion.Content>
- <Diff
- before={{
- name: diff.file!,
- contents: diff.before!,
- }}
- after={{
- name: diff.file!,
- contents: diff.after!,
- }}
- />
- </Accordion.Content>
- </Accordion.Item>
- )}
- </For>
- </Accordion>
- </div>
- </Show>
- <Show when={error() && !expanded()}>
- <Card variant="error" class="text-text-on-critical-base">
- {error()?.data?.message as string}
- </Card>
- </Show>
- {/* Response */}
- <div class="w-full">
- <Switch>
- <Match when={!completed()}>
- <MessageProgress
- assistantMessages={assistantMessages}
- done={!working()}
- />
- </Match>
- <Match when={completed() && hasToolPart()}>
- <Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
- <Collapsible.Trigger class="text-text-weak hover:text-text-strong">
- <div class="flex items-center gap-1 self-stretch">
- <div class="text-12-medium">
- <Switch>
- <Match when={expanded()}>Hide details</Match>
- <Match when={!expanded()}>Show details</Match>
- </Switch>
- </div>
- <Collapsible.Arrow />
- </div>
- </Collapsible.Trigger>
- <Collapsible.Content>
- <div class="w-full flex flex-col items-start self-stretch gap-3">
- <For each={assistantMessages()}>
- {(assistantMessage) => {
- const parts = createMemo(
- () => sync.data.part[assistantMessage.id],
- )
- return <Message message={assistantMessage} parts={parts()} />
- }}
- </For>
- <Show when={error()}>
- <Card variant="error" class="text-text-on-critical-base">
- {error()?.data?.message as string}
- </Card>
- </Show>
- </div>
- </Collapsible.Content>
- </Collapsible>
- </Match>
- </Switch>
- </div>
- </div>
- </Show>
- )
- }}
- </For>
- </div>
- </div>
- </div>
- )
- }}
- </Show>
- </div>
- </Tabs.Content>
- {/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
- <For each={local.session.tabs()?.opened}>
- {(file) => (
- <Tabs.Content value={file} class="select-text">
- {(() => {
- {
- /* const view = local.file.view(file) */
- }
- {
- /* const showRaw = view === "raw" || !file.content?.diff */
- }
- {
- /* const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") */
- }
- const node = local.file.node(file)
- return (
- <Code
- file={{ name: node.path, contents: node.content?.content ?? "" }}
- disableFileHeader
- overflow="scroll"
- class="pt-3 pb-40"
- />
- )
- })()}
- </Tabs.Content>
- )}
- </For>
- </Tabs>
- <DragOverlay>
- {(() => {
- const id = activeItem()
- if (!id) return null
- const draggedFile = local.file.node(id)
- if (!draggedFile) return null
- return (
- <div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
- <TabVisual file={draggedFile} />
- </div>
- )
- })()}
- </DragOverlay>
- </DragDropProvider>
- <div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
- <PromptInput
- ref={(el) => {
- inputRef = el
- }}
- onSubmit={handlePromptSubmit}
- />
- </div>
- <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
- <FileTree path="" onFileClick={handleFileClick} />
- </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>
- </main>
- <Show when={store.fileSelectOpen}>
- <SelectDialog
- defaultOpen
- title="Select file"
- placeholder="Search files"
- emptyMessage="No files found"
- items={local.file.searchFiles}
- key={(x) => x}
- onOpenChange={(open) => setStore("fileSelectOpen", open)}
- onSelect={(x) => (x ? local.session.open(x) : undefined)}
- >
- {(i) => (
- <div
- classList={{
- "w-full flex items-center justify-between rounded-md": true,
- }}
- >
- <div class="flex items-center gap-x-2 grow min-w-0">
- <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
- <div class="flex items-center text-14-regular">
- <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
- {getDirectory(i)}
- </span>
- <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
- </div>
- </div>
- <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
- </div>
- )}
- </SelectDialog>
- </Show>
- </div>
- )
-}
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
new file mode 100644
index 000000000..afec9ee3c
--- /dev/null
+++ b/packages/desktop/src/pages/layout.tsx
@@ -0,0 +1,75 @@
+import { Button, Tooltip, DiffChanges } from "@opencode-ai/ui"
+import { createMemo, ParentProps } from "solid-js"
+import { getFilename } from "@/utils"
+import { DateTime } from "luxon"
+import { useSync } from "@/context/sync"
+import { VList } from "virtua/solid"
+import { A, useParams } from "@solidjs/router"
+
+export default function Layout(props: ParentProps) {
+ const params = useParams()
+ const sync = useSync()
+ return (
+ <div class="relative h-screen flex flex-col">
+ <header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
+ <div class="h-[calc(100vh-0rem)] flex">
+ <div class="w-70 shrink-0 bg-background-weak border-r border-border-weak-base flex flex-col items-start">
+ <div class="h-10 flex items-center self-stretch px-5 border-b border-border-weak-base">
+ <span class="text-14-regular overflow-hidden text-ellipsis">{getFilename(sync.data.path.directory)}</span>
+ </div>
+ <div class="flex flex-col items-start gap-4 self-stretch flex-1 py-4 px-3">
+ <A href="/session" class="w-full">
+ <Button class="w-full" size="large" icon="edit-small-2">
+ New Session
+ </Button>
+ </A>
+ <VList data={sync.data.session} class="no-scrollbar">
+ {(session) => {
+ const diffs = createMemo(() => session.summary?.diffs ?? [])
+ const filesChanged = createMemo(() => diffs().length)
+ const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
+ return (
+ <A
+ data-active={session.id === params.id}
+ href={`/session/${session.id}`}
+ class="group/session focus:outline-none"
+ >
+ <Tooltip placement="right" value={session.title}>
+ <div
+ class="w-full mb-1.5 px-3 py-1 rounded-md
+ group-data-[active=true]/session:bg-surface-raised-base-hover
+ group-hover/session:bg-surface-raised-base-hover
+ group-focus/session:bg-surface-raised-base-hover"
+ >
+ <div class="flex items-center self-stretch gap-6 justify-between">
+ <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
+ {session.title}
+ </span>
+ <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>
+ </div>
+ <div class="flex justify-between items-center self-stretch">
+ <span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span>
+ <DiffChanges diff={diffs()} />
+ </div>
+ </div>
+ </Tooltip>
+ </A>
+ )
+ }}
+ </VList>
+ </div>
+ </div>
+ <main class="size-full overflow-x-hidden">{props.children}</main>
+ </div>
+ </div>
+ )
+}
diff --git a/packages/desktop/src/pages/session-layout.tsx b/packages/desktop/src/pages/session-layout.tsx
new file mode 100644
index 000000000..9a24608f0
--- /dev/null
+++ b/packages/desktop/src/pages/session-layout.tsx
@@ -0,0 +1,12 @@
+import { Show, type ParentProps } from "solid-js"
+import { SessionProvider } from "@/context/session"
+import { useParams } from "@solidjs/router"
+
+export default function Layout(props: ParentProps) {
+ const params = useParams()
+ return (
+ <Show when={params.id || true} keyed>
+ <SessionProvider sessionId={params.id}>{props.children}</SessionProvider>
+ </Show>
+ )
+}
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
new file mode 100644
index 000000000..9c633f4f7
--- /dev/null
+++ b/packages/desktop/src/pages/session.tsx
@@ -0,0 +1,693 @@
+import {
+ SelectDialog,
+ IconButton,
+ Tabs,
+ Icon,
+ Accordion,
+ Diff,
+ Collapsible,
+ DiffChanges,
+ Message,
+ Typewriter,
+ Card,
+ Code,
+ Tooltip,
+ ProgressCircle,
+} from "@opencode-ai/ui"
+import { FileIcon } from "@/ui"
+import { MessageProgress } from "@/components/message-progress"
+import {
+ For,
+ onCleanup,
+ onMount,
+ Show,
+ Match,
+ Switch,
+ createSignal,
+ createEffect,
+ createMemo,
+ createResource,
+} from "solid-js"
+import { useLocal, type LocalFile } from "@/context/local"
+import { createStore } from "solid-js/store"
+import { getDirectory, getFilename } from "@/utils"
+import { PromptInput } from "@/components/prompt-input"
+import { DateTime } from "luxon"
+import {
+ DragDropProvider,
+ DragDropSensors,
+ DragOverlay,
+ SortableProvider,
+ closestCenter,
+ createSortable,
+ useDragDropContext,
+} from "@thisbeyond/solid-dnd"
+import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
+import type { JSX } from "solid-js"
+import { useSync } from "@/context/sync"
+import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
+import { Markdown } from "@opencode-ai/ui"
+import { Spinner } from "@/components/spinner"
+import { useSession } from "@/context/session"
+
+export default function Page() {
+ const local = useLocal()
+ const sync = useSync()
+ const session = useSession()
+ const [store, setStore] = createStore({
+ clickTimer: undefined as number | undefined,
+ fileSelectOpen: false,
+ activeDraggable: undefined as string | undefined,
+ })
+ let inputRef!: HTMLDivElement
+ let messageScrollElement!: 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)
+ })
+
+ 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()
+ setStore("fileSelectOpen", true)
+ return
+ }
+
+ const focused = document.activeElement === inputRef
+ if (focused) {
+ 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()
+ }
+ }
+
+ const resetClickTimer = () => {
+ if (!store.clickTimer) return
+ clearTimeout(store.clickTimer)
+ setStore("clickTimer", undefined)
+ }
+
+ const startClickTimer = () => {
+ const newClickTimer = setTimeout(() => {
+ setStore("clickTimer", undefined)
+ }, 300)
+ setStore("clickTimer", newClickTimer as unknown as number)
+ }
+
+ const handleTabClick = async (tab: string) => {
+ if (store.clickTimer) {
+ resetClickTimer()
+ // local.file.update(file.path, { ...file, pinned: true })
+ } else {
+ if (tab.startsWith("file://")) {
+ local.file.open(tab.replace("file://", ""))
+ }
+ startClickTimer()
+ }
+ }
+
+ const handleDragStart = (event: unknown) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ setStore("activeDraggable", id)
+ }
+
+ const handleDragOver = (event: DragEvent) => {
+ const { draggable, droppable } = event
+ if (draggable && droppable) {
+ const currentTabs = session.layout.tabs.opened
+ 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)
+ }
+ }
+ }
+
+ const handleDragEnd = () => {
+ setStore("activeDraggable", undefined)
+ }
+
+ const FileVisual = (props: { file: LocalFile }): JSX.Element => {
+ return (
+ <div class="flex items-center gap-x-1.5">
+ <FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
+ <span
+ classList={{
+ "text-14-medium": true,
+ "text-primary": !!props.file.status?.status,
+ italic: !props.file.pinned,
+ }}
+ >
+ {props.file.name}
+ </span>
+ <span class="hidden opacity-70">
+ <Switch>
+ <Match when={props.file.status?.status === "modified"}>
+ <span class="text-primary">M</span>
+ </Match>
+ <Match when={props.file.status?.status === "added"}>
+ <span class="text-success">A</span>
+ </Match>
+ <Match when={props.file.status?.status === "deleted"}>
+ <span class="text-error">D</span>
+ </Match>
+ </Switch>
+ </span>
+ </div>
+ )
+ }
+
+ const SortableTab = (props: {
+ tab: string
+ onTabClick: (tab: string) => void
+ onTabClose: (tab: string) => void
+ }): JSX.Element => {
+ const sortable = createSortable(props.tab)
+
+ const [file] = createResource(
+ () => props.tab,
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+
+ return (
+ // @ts-ignore
+ <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
+ <div class="relative h-full">
+ <Tabs.Trigger value={props.tab} class="group/tab pl-3 pr-1" onClick={() => props.onTabClick(props.tab)}>
+ <Switch>
+ <Match when={file()}>{(f) => <FileVisual file={f()} />}</Match>
+ </Switch>
+ <IconButton
+ icon="close"
+ class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100
+ group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle
+ hover:opacity-100 group-hover/tab:opacity-100"
+ variant="ghost"
+ onClick={() => props.onTabClose(props.tab)}
+ />
+ </Tabs.Trigger>
+ </div>
+ </div>
+ )
+ }
+
+ const ConstrainDragYAxis = (): JSX.Element => {
+ const context = useDragDropContext()
+ if (!context) return <></>
+ const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
+ const transformer: Transformer = {
+ id: "constrain-y-axis",
+ order: 100,
+ callback: (transform) => ({ ...transform, y: 0 }),
+ }
+ onDragStart((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ addTransformer("draggables", id, transformer)
+ })
+ onDragEnd((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ removeTransformer("draggables", id, transformer.id)
+ })
+ return <></>
+ }
+
+ const getDraggableId = (event: unknown): string | undefined => {
+ if (typeof event !== "object" || event === null) return undefined
+ if (!("draggable" in event)) return undefined
+ const draggable = (event as { draggable?: { id?: unknown } }).draggable
+ if (!draggable) return undefined
+ return typeof draggable.id === "string" ? draggable.id : undefined
+ }
+
+ return (
+ <div class="relative bg-background-base size-full overflow-x-hidden">
+ <DragDropProvider
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragYAxis />
+ <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
+ <div class="sticky top-0 shrink-0 flex">
+ <Tabs.List>
+ <Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
+ <div>Chat</div>
+ <Tooltip
+ value={`${new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ compactDisplay: "short",
+ }).format(session.usage.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>
+ </Tooltip>
+ </Tabs.Trigger>
+ {/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
+ <SortableProvider ids={session.layout.tabs.opened ?? []}>
+ <For each={session.layout.tabs.opened ?? []}>
+ {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />}
+ </For>
+ </SortableProvider>
+ <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
+ <IconButton
+ icon="plus-small"
+ variant="ghost"
+ iconSize="large"
+ onClick={() => setStore("fileSelectOpen", true)}
+ />
+ </div>
+ </Tabs.List>
+ </div>
+ <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
+ <div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
+ <Show
+ when={session.id}
+ fallback={
+ <div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
+ <div class="text-20-medium text-text-weaker">New session</div>
+ <div class="flex justify-center items-center gap-3">
+ <Icon name="folder" size="small" />
+ <div class="text-12-medium text-text-weak">
+ {getDirectory(sync.data.path.directory)}
+ <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
+ </div>
+ </div>
+ <div class="flex justify-center items-center gap-3">
+ <Icon name="pencil-line" size="small" />
+ <div class="text-12-medium text-text-weak">
+ Last modified&nbsp;
+ <span class="text-text-strong">
+ {DateTime.fromMillis(sync.data.project.time.created).toRelative()}
+ </span>
+ </div>
+ </div>
+ </div>
+ }
+ >
+ {(_) => {
+ return (
+ <div class="pt-3 flex flex-col flex-1 min-h-0">
+ <div class="flex-1 min-h-0">
+ <Show when={session.messages.user().length > 1}>
+ <ul
+ role="list"
+ class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
+ >
+ <For each={session.messages.user()}>
+ {(message) => {
+ const assistantMessages = createMemo(() => {
+ if (!session.id) return []
+ return sync.data.message[session.id]?.filter(
+ (m) => m.role === "assistant" && m.parentID == message.id,
+ ) as AssistantMessageType[]
+ })
+ const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
+ const working = createMemo(() => !message.summary?.body && !error())
+
+ return (
+ <li class="group/li flex items-center self-stretch">
+ <button
+ class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
+ onClick={() => session.messages.setActive(message.id)}
+ >
+ <Switch>
+ <Match when={working()}>
+ <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
+ </Match>
+ <Match when={true}>
+ <DiffChanges diff={message.summary?.diffs ?? []} variant="bars" />
+ </Match>
+ </Switch>
+ <div
+ data-active={session.messages.active()?.id === message.id}
+ classList={{
+ "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
+ "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
+ }}
+ >
+ <Show when={message.summary?.title} fallback="New message">
+ {message.summary?.title}
+ </Show>
+ </div>
+ </button>
+ </li>
+ )
+ }}
+ </For>
+ </ul>
+ </Show>
+ <div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
+ <For each={session.messages.user()}>
+ {(message) => {
+ const isActive = createMemo(() => session.messages.active()?.id === message.id)
+ const [titled, setTitled] = createSignal(!!message.summary?.title)
+ const assistantMessages = createMemo(() => {
+ if (!session.id) return []
+ return sync.data.message[session.id]?.filter(
+ (m) => m.role === "assistant" && m.parentID == message.id,
+ ) as AssistantMessageType[]
+ })
+ const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
+ const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
+ const [detailsExpanded, setDetailsExpanded] = createSignal(false)
+ const parts = createMemo(() => sync.data.part[message.id])
+ const hasToolPart = createMemo(() =>
+ assistantMessages()
+ ?.flatMap((m) => sync.data.part[m.id])
+ .some((p) => p?.type === "tool"),
+ )
+ const working = createMemo(() => !message.summary?.body && !error())
+
+ // allowing time for the animations to finish
+ createEffect(() => {
+ const title = message.summary?.title
+ setTimeout(() => setTitled(!!title), 10_000)
+ })
+ createEffect(() => {
+ const summary = message.summary?.body
+ const complete = !!summary || !!error()
+ setTimeout(() => setCompleted(complete), 1200)
+ })
+
+ return (
+ <Show when={isActive()}>
+ <div
+ data-message={message.id}
+ class="flex flex-col items-start self-stretch gap-8 pb-50"
+ >
+ {/* Title */}
+ <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
+ <div class="w-full text-14-medium text-text-strong">
+ <Show
+ when={titled()}
+ fallback={
+ <Typewriter
+ as="h1"
+ text={message.summary?.title}
+ class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
+ />
+ }
+ >
+ <h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
+ {message.summary?.title}
+ </h1>
+ </Show>
+ </div>
+ </div>
+ <div class="-mt-8">
+ <Message message={message} parts={parts()} />
+ </div>
+ {/* Summary */}
+ <Show when={completed()}>
+ <div class="w-full flex flex-col gap-6 items-start self-stretch">
+ <div class="flex flex-col items-start gap-1 self-stretch">
+ <h2 class="text-12-medium text-text-weak">
+ <Switch>
+ <Match when={message.summary?.diffs?.length}>Summary</Match>
+ <Match when={true}>Response</Match>
+ </Switch>
+ </h2>
+ <Show when={message.summary?.body}>
+ {(summary) => (
+ <Markdown
+ classList={{
+ "text-14-regular": !!message.summary?.diffs?.length,
+ "[&>*]:fade-up-text": !message.summary?.diffs?.length,
+ }}
+ text={summary()}
+ />
+ )}
+ </Show>
+ </div>
+ <Accordion class="w-full" multiple>
+ <For each={message.summary?.diffs ?? []}>
+ {(diff) => (
+ <Accordion.Item value={diff.file}>
+ <Accordion.Header>
+ <Accordion.Trigger>
+ <div class="flex items-center justify-between w-full gap-5">
+ <div class="grow flex items-center gap-5 min-w-0">
+ <FileIcon
+ node={{ path: diff.file, type: "file" }}
+ class="shrink-0 size-4"
+ />
+ <div class="flex grow min-w-0">
+ <Show when={diff.file.includes("/")}>
+ <span class="text-text-base truncate-start">
+ {getDirectory(diff.file)}&lrm;
+ </span>
+ </Show>
+ <span class="text-text-strong shrink-0">
+ {getFilename(diff.file)}
+ </span>
+ </div>
+ </div>
+ <div class="shrink-0 flex gap-4 items-center justify-end">
+ <DiffChanges diff={diff} />
+ <Icon name="chevron-grabber-vertical" size="small" />
+ </div>
+ </div>
+ </Accordion.Trigger>
+ </Accordion.Header>
+ <Accordion.Content>
+ <Diff
+ before={{
+ name: diff.file!,
+ contents: diff.before!,
+ }}
+ after={{
+ name: diff.file!,
+ contents: diff.after!,
+ }}
+ />
+ </Accordion.Content>
+ </Accordion.Item>
+ )}
+ </For>
+ </Accordion>
+ </div>
+ </Show>
+ <Show when={error() && !detailsExpanded()}>
+ <Card variant="error" class="text-text-on-critical-base">
+ {error()?.data?.message as string}
+ </Card>
+ </Show>
+ {/* Response */}
+ <div class="w-full">
+ <Switch>
+ <Match when={!completed()}>
+ <MessageProgress assistantMessages={assistantMessages} done={!working()} />
+ </Match>
+ <Match when={completed() && hasToolPart()}>
+ <Collapsible
+ variant="ghost"
+ open={detailsExpanded()}
+ onOpenChange={setDetailsExpanded}
+ >
+ <Collapsible.Trigger class="text-text-weak hover:text-text-strong">
+ <div class="flex items-center gap-1 self-stretch">
+ <div class="text-12-medium">
+ <Switch>
+ <Match when={detailsExpanded()}>Hide details</Match>
+ <Match when={!detailsExpanded()}>Show details</Match>
+ </Switch>
+ </div>
+ <Collapsible.Arrow />
+ </div>
+ </Collapsible.Trigger>
+ <Collapsible.Content>
+ <div class="w-full flex flex-col items-start self-stretch gap-3">
+ <For each={assistantMessages()}>
+ {(assistantMessage) => {
+ const parts = createMemo(() => sync.data.part[assistantMessage.id])
+ return <Message message={assistantMessage} parts={parts()} />
+ }}
+ </For>
+ <Show when={error()}>
+ <Card variant="error" class="text-text-on-critical-base">
+ {error()?.data?.message as string}
+ </Card>
+ </Show>
+ </div>
+ </Collapsible.Content>
+ </Collapsible>
+ </Match>
+ </Switch>
+ </div>
+ </div>
+ </Show>
+ )
+ }}
+ </For>
+ </div>
+ </div>
+ </div>
+ )
+ }}
+ </Show>
+ </div>
+ </Tabs.Content>
+ {/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
+ <For each={session.layout.tabs.opened}>
+ {(tab) => {
+ const [file] = createResource(
+ () => tab,
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+ <Tabs.Content value={tab} class="select-text">
+ <Switch>
+ <Match when={file()}>
+ {(f) => (
+ <Code
+ file={{ name: f().path, contents: f().content?.content ?? "" }}
+ overflow="scroll"
+ class="pt-3 pb-40"
+ />
+ )}
+ </Match>
+ </Switch>
+ </Tabs.Content>
+ )
+ }}
+ </For>
+ </Tabs>
+ <DragOverlay>
+ <Show when={store.activeDraggable}>
+ {(draggedFile) => {
+ const [file] = createResource(
+ () => draggedFile(),
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+ <div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
+ <Show when={file()}>{(f) => <FileVisual file={f()} />}</Show>
+ </div>
+ )
+ }}
+ </Show>
+ </DragOverlay>
+ </DragDropProvider>
+ <div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
+ <PromptInput
+ ref={(el) => {
+ inputRef = el
+ }}
+ />
+ </div>
+ <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>
+ <Show when={store.fileSelectOpen}>
+ <SelectDialog
+ defaultOpen
+ title="Select file"
+ placeholder="Search files"
+ emptyMessage="No files found"
+ items={local.file.searchFiles}
+ key={(x) => x}
+ onOpenChange={(open) => setStore("fileSelectOpen", open)}
+ onSelect={(x) => (x ? session.layout.openTab("file://" + x) : undefined)}
+ >
+ {(i) => (
+ <div
+ classList={{
+ "w-full flex items-center justify-between rounded-md": true,
+ }}
+ >
+ <div class="flex items-center gap-x-2 grow min-w-0">
+ <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
+ <div class="flex items-center text-14-regular">
+ <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
+ {getDirectory(i)}
+ </span>
+ <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
+ </div>
+ </div>
+ <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
+ </div>
+ )}
+ </SelectDialog>
+ </Show>
+ </div>
+ )
+}