summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-20 19:52:12 -0600
committerAdam <[email protected]>2025-12-20 19:52:12 -0600
commit182630e0d7d49d8c9842781f08b19388346d8e5c (patch)
tree2b6c1139c7f4a22204893fcd1a8a469d792614e7 /packages
parentc81506b28d78c944da72bad33899bb0ef00ff816 (diff)
downloadopencode-182630e0d7d49d8c9842781f08b19388346d8e5c.tar.gz
opencode-182630e0d7d49d8c9842781f08b19388346d8e5c.zip
feat(desktop): new layout
Diffstat (limited to 'packages')
-rw-r--r--packages/desktop/src/context/layout.tsx27
-rw-r--r--packages/desktop/src/pages/session.tsx464
2 files changed, 202 insertions, 289 deletions
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index 0d3de5683..8bfc8aa21 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -46,8 +46,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
opened: false,
height: 280,
},
- review: {
- state: "pane" as "pane" | "tab",
+ session: {
+ width: 600,
},
sessionTabs: {} as Record<string, SessionTabs>,
}),
@@ -156,13 +156,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("terminal", "height", height)
},
},
- review: {
- state: createMemo(() => store.review?.state ?? "closed"),
- pane() {
- setStore("review", "state", "pane")
- },
- tab() {
- setStore("review", "state", "tab")
+ session: {
+ width: createMemo(() => store.session?.width ?? 600),
+ resize(width: number) {
+ if (!store.session) {
+ setStore("session", { width })
+ } else {
+ setStore("session", "width", width)
+ }
},
},
tabs(sessionKey: string) {
@@ -186,14 +187,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
async open(tab: string) {
- if (tab === "chat") {
- if (!store.sessionTabs[sessionKey]) {
- setStore("sessionTabs", sessionKey, { all: [], active: undefined })
- } else {
- setStore("sessionTabs", sessionKey, "active", undefined)
- }
- return
- }
const current = store.sessionTabs[sessionKey] ?? { all: [] }
if (tab !== "review") {
if (!current.all.includes(tab)) {
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 6e993ff8f..796b5b0c7 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -22,7 +22,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
-import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
@@ -50,7 +49,7 @@ import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router"
-import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
+import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { extractPromptFromParts } from "@/utils/prompt"
@@ -118,27 +117,8 @@ export default function Page() {
setActiveMessage(msgs[targetIndex])
}
- const last = createMemo(
- () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
- )
- const model = createMemo(() =>
- last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
- )
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
- const tokens = createMemo(() => {
- if (!last()) return
- const t = last().tokens
- return t.input + t.output + t.reasoning + t.cache.read + t.cache.write
- })
-
- const context = createMemo(() => {
- const total = tokens()
- const limit = model()?.limit.context
- if (!total || !limit) return 0
- return Math.round((total / limit) * 100)
- })
-
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
activeDraggable: undefined as string | undefined,
@@ -551,273 +531,213 @@ export default function Page() {
)
}
- const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length)
+ const showTabs = createMemo(() => diffs().length > 0 || tabs().all().length > 0)
return (
<div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
- <div class="min-h-0 grow w-full">
- <DragDropProvider
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onDragOver={handleDragOver}
- collisionDetector={closestCenter}
+ <div class="min-h-0 grow w-full flex">
+ {/* Session pane - always visible */}
+ <div
+ class="relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
+ style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
>
- <DragDropSensors />
- <ConstrainDragYAxis />
- <Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}>
- <div class="sticky top-0 shrink-0 flex">
- <Tabs.List>
- <Tabs.Trigger value="chat">
- <div class="flex gap-x-[17px] items-center">
- <div>Session</div>
- <Tooltip
- value={`${new Intl.NumberFormat("en-US", {
- notation: "compact",
- compactDisplay: "short",
- }).format(tokens() ?? 0)} Tokens`}
- class="flex items-center gap-1.5"
- >
- <ProgressCircle percentage={context() ?? 0} />
- <div class="text-14-regular text-text-weak text-left w-7">{context() ?? 0}%</div>
- </Tooltip>
+ <div class="flex-1 min-h-0 overflow-hidden">
+ <Switch>
+ <Match when={params.id}>
+ <div class="flex items-start justify-start h-full min-h-0">
+ <SessionMessageRail
+ messages={visibleUserMessages()}
+ current={activeMessage()}
+ onMessageSelect={setActiveMessage}
+ wide={!showTabs()}
+ />
+ <Show when={activeMessage()}>
+ <SessionTurn
+ sessionID={params.id!}
+ messageID={activeMessage()!.id}
+ stepsExpanded={store.stepsExpanded}
+ onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
+ onUserInteracted={() => setStore("userInteracted", true)}
+ classes={{
+ root: "pb-20 flex-1 min-w-0",
+ content: "pb-20",
+ container:
+ "w-full " +
+ (!showTabs()
+ ? "max-w-200 mx-auto px-6"
+ : visibleUserMessages().length > 1
+ ? "pr-6 pl-18"
+ : "px-6"),
+ }}
+ />
+ </Show>
+ </div>
+ </Match>
+ <Match when={true}>
+ <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>
+ <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>
- </Tabs.Trigger>
- <Show when={layout.review.state() === "tab" && diffs().length}>
- <Tabs.Trigger
- value="review"
- closeButton={
- <Tooltip value="Close tab" placement="bottom">
- <IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
- </Tooltip>
- }
- >
- <div class="flex items-center gap-3">
- <Show when={diffs()}>
- <DiffChanges changes={diffs()} variant="bars" />
- </Show>
- <div class="flex items-center gap-1.5">
- <div>Review</div>
- <Show when={info()?.summary?.files}>
- <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
- {info()?.summary?.files ?? 0}
- </div>
- </Show>
+ <Show when={sync.project}>
+ {(project) => (
+ <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(project().time.updated ?? project().time.created).toRelative()}
+ </span>
+ </div>
</div>
- </div>
- </Tabs.Trigger>
- </Show>
- <SortableProvider ids={tabs().all() ?? []}>
- <For each={tabs().all() ?? []}>
- {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
- </For>
- </SortableProvider>
- <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
- <Tooltip value="Open file" class="flex items-center">
- <IconButton
- icon="plus-small"
- variant="ghost"
- iconSize="large"
- onClick={() => dialog.show(() => <DialogSelectFile />)}
- />
- </Tooltip>
+ )}
+ </Show>
</div>
- </Tabs.List>
+ </Match>
+ </Switch>
+ </div>
+ <div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
+ <div class="w-full max-w-200 px-6">
+ <PromptInput
+ ref={(el) => {
+ inputRef = el
+ }}
+ />
</div>
- <Tabs.Content
- value="chat"
- class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden contain-strict"
+ </div>
+ <Show when={showTabs()}>
+ <ResizeHandle
+ direction="horizontal"
+ size={layout.session.width()}
+ min={450}
+ max={window.innerWidth * 0.45}
+ onResize={layout.session.resize}
+ />
+ </Show>
+ </div>
+
+ {/* Tabs pane - visible when there are diffs or file tabs */}
+ <Show when={showTabs()}>
+ <div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
+ <DragDropProvider
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
+ collisionDetector={closestCenter}
>
- <div
- classList={{
- "w-full flex-1 min-h-0": true,
- grid: layout.review.state() === "tab",
- flex: layout.review.state() === "pane",
- }}
- >
- <div
- classList={{
- "relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true,
- "max-w-146 mx-auto": !wide(),
- }}
- >
- <Switch>
- <Match when={params.id}>
- <div class="flex items-start justify-start h-full min-h-0">
- <SessionMessageRail
- messages={visibleUserMessages()}
- current={activeMessage()}
- onMessageSelect={setActiveMessage}
- wide={wide()}
- />
- <Show when={activeMessage()}>
- <SessionTurn
- sessionID={params.id!}
- messageID={activeMessage()!.id}
- stepsExpanded={store.stepsExpanded}
- onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
- onUserInteracted={() => setStore("userInteracted", true)}
- classes={{
- root: "pb-20 flex-1 min-w-0",
- content: "pb-20",
- container:
- "w-full " +
- (wide()
- ? "max-w-200 mx-auto px-6"
- : visibleUserMessages().length > 1
- ? "pr-6 pl-18"
- : "px-6"),
- }}
- />
- </Show>
- </div>
- </Match>
- <Match when={true}>
- <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>
- <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>
+ <DragDropSensors />
+ <ConstrainDragYAxis />
+ <Tabs value={tabs().active() ?? "review"} onChange={tabs().open}>
+ <div class="sticky top-0 shrink-0 flex">
+ <Tabs.List>
+ <Show when={diffs().length}>
+ <Tabs.Trigger value="review">
+ <div class="flex items-center gap-3">
+ <Show when={diffs()}>
+ <DiffChanges changes={diffs()} variant="bars" />
+ </Show>
+ <div class="flex items-center gap-1.5">
+ <div>Review</div>
+ <Show when={info()?.summary?.files}>
+ <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
+ {info()?.summary?.files ?? 0}
+ </div>
+ </Show>
</div>
</div>
- <Show when={sync.project}>
- {(project) => (
- <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(project().time.updated ?? project().time.created).toRelative()}
- </span>
- </div>
- </div>
- )}
- </Show>
- </div>
- </Match>
- </Switch>
- <div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
- <div class="w-full max-w-200 px-6">
- <PromptInput
- ref={(el) => {
- inputRef = el
+ </Tabs.Trigger>
+ </Show>
+ <SortableProvider ids={tabs().all() ?? []}>
+ <For each={tabs().all() ?? []}>
+ {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
+ </For>
+ </SortableProvider>
+ <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
+ <Tooltip value="Open file" class="flex items-center">
+ <IconButton
+ icon="plus-small"
+ variant="ghost"
+ iconSize="large"
+ onClick={() => dialog.show(() => <DialogSelectFile />)}
+ />
+ </Tooltip>
+ </div>
+ </Tabs.List>
+ </div>
+ <Show when={diffs().length}>
+ <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
+ <div class="relative pt-3 flex-1 min-h-0 overflow-hidden">
+ <SessionReview
+ classes={{
+ root: "pb-40",
+ header: "px-6",
+ container: "px-6",
}}
+ diffs={diffs()}
+ split
/>
</div>
- </div>
- </div>
- <Show when={layout.review.state() === "pane" && diffs().length}>
- <div
- classList={{
- "relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base contain-strict": true,
- }}
- >
- <SessionReview
- classes={{
- root: "pb-20",
- header: "px-6",
- container: "px-6",
- }}
- diffs={diffs()}
- actions={
- <Tooltip value="Open in tab">
- <IconButton
- icon="expand"
- variant="ghost"
- onClick={() => {
- layout.review.tab()
- tabs().setActive("review")
- }}
- />
- </Tooltip>
- }
- />
- </div>
+ </Tabs.Content>
</Show>
- </div>
- </Tabs.Content>
- <Show when={layout.review.state() === "tab" && diffs().length}>
- <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
- <div
- classList={{
- "relative pt-3 flex-1 min-h-0 overflow-hidden": true,
+ <For each={tabs().all()}>
+ {(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 mt-3">
+ <Switch>
+ <Match when={file()}>
+ {(f) => (
+ <Dynamic
+ component={codeComponent}
+ file={{
+ name: f().path,
+ contents: f().content?.content ?? "",
+ cacheKey: checksum(f().content?.content ?? ""),
+ }}
+ overflow="scroll"
+ class="pb-40"
+ />
+ )}
+ </Match>
+ </Switch>
+ </Tabs.Content>
+ )
}}
- >
- <SessionReview
- classes={{
- root: "pb-40",
- header: "px-6",
- container: "px-6",
- }}
- diffs={diffs()}
- split
- />
- </div>
- </Tabs.Content>
- </Show>
- <For each={tabs().all()}>
- {(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 mt-3">
- <Switch>
- <Match when={file()}>
- {(f) => (
- <Dynamic
- component={codeComponent}
- file={{
- name: f().path,
- contents: f().content?.content ?? "",
- cacheKey: checksum(f().content?.content ?? ""),
- }}
- overflow="scroll"
- class="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-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
- <Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
- </div>
- )
- }}
- </Show>
- </DragOverlay>
- </DragDropProvider>
- <Show when={tabs().active()}>
- <div class="absolute inset-x-0 px-6 max-w-200 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
- <PromptInput
- ref={(el) => {
- inputRef = el
- }}
- />
+ </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-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
+ <Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
+ </div>
+ )
+ }}
+ </Show>
+ </DragOverlay>
+ </DragDropProvider>
</div>
</Show>
</div>