summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-11-06 15:13:02 -0600
committerAdam <[email protected]>2025-11-06 15:13:06 -0600
commit96c57418f39bbf10e4538a6e0652baff7f0932fb (patch)
tree7709020729579f1ec84ce230726504a810d01421 /packages/desktop/src
parentb8c51e307f1e26c4cbc962fca742338fba3c9fe9 (diff)
downloadopencode-96c57418f39bbf10e4538a6e0652baff7f0932fb.tar.gz
opencode-96c57418f39bbf10e4538a6e0652baff7f0932fb.zip
feat(desktop): review flow
Diffstat (limited to 'packages/desktop/src')
-rw-r--r--packages/desktop/src/components/prompt-input.tsx1
-rw-r--r--packages/desktop/src/context/local.tsx17
-rw-r--r--packages/desktop/src/context/session.tsx8
-rw-r--r--packages/desktop/src/context/sync.tsx59
-rw-r--r--packages/desktop/src/pages/layout.tsx4
-rw-r--r--packages/desktop/src/pages/session.tsx299
6 files changed, 319 insertions, 69 deletions
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 74443d6a5..15bc54c49 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -338,6 +338,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// session.layout.copyTabs("", session.id)
}
session.layout.setActiveTab(undefined)
+ session.messages.setActive(undefined)
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const attachments = session.prompt.current().filter((part) => part.type === "file")
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 9dacc7100..1785bdf0c 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -464,9 +464,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
opened: true,
width: 240,
},
+ review: {
+ state: "closed" as "open" | "closed" | "tab",
+ },
}),
{
- name: "layout",
+ name: "default-layout",
},
)
@@ -487,6 +490,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setStore("sidebar", "width", width)
},
},
+ review: {
+ state: createMemo(() => store.review?.state ?? "closed"),
+ open() {
+ setStore("review", "state", "open")
+ },
+ close() {
+ setStore("review", "state", "closed")
+ },
+ tab() {
+ setStore("review", "state", "tab")
+ },
+ },
}
})()
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
index 77ab3bc25..61fed945e 100644
--- a/packages/desktop/src/context/session.tsx
+++ b/packages/desktop/src/context/session.tsx
@@ -80,6 +80,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
const model = createMemo(() =>
last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
)
+ const diffs = createMemo(() => (props.sessionId ? (sync.data.session_diff[props.sessionId] ?? []) : []))
const tokens = createMemo(() => {
if (!last()) return
@@ -98,6 +99,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
id: props.sessionId,
info,
working,
+ diffs,
prompt: {
current: createMemo(() => store.prompt),
cursor: createMemo(() => store.cursorPosition),
@@ -139,8 +141,10 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
if (tab.startsWith("file://")) {
await local.file.open(tab.replace("file://", ""))
}
- if (!store.tabs.opened.includes(tab)) {
- setStore("tabs", "opened", [...store.tabs.opened, tab])
+ if (tab !== "review") {
+ if (!store.tabs.opened.includes(tab)) {
+ setStore("tabs", "opened", [...store.tabs.opened, tab])
+ }
}
setStore("tabs", "active", tab)
},
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
index 1e960397b..c5b169a38 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/packages/desktop/src/context/sync.tsx
@@ -1,4 +1,17 @@
-import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
+import type {
+ Message,
+ Agent,
+ Provider,
+ Session,
+ Part,
+ Config,
+ Path,
+ File,
+ FileNode,
+ Project,
+ FileDiff,
+ Todo,
+} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { createMemo } from "solid-js"
import { Binary } from "@/utils/binary"
@@ -16,8 +29,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
config: Config
path: Path
session: Session[]
+ session_diff: {
+ [sessionID: string]: FileDiff[]
+ }
+ todo: {
+ [sessionID: string]: Todo[]
+ }
limit: number
- more: boolean
message: {
[sessionID: string]: Message[]
}
@@ -34,8 +52,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: [],
provider: [],
session: [],
+ session_diff: {},
+ todo: {},
limit: 10,
- more: false,
message: {},
part: {},
node: [],
@@ -60,6 +79,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
)
break
}
+ case "session.diff":
+ setStore("session_diff", event.properties.sessionID, event.properties.diff)
+ break
+ case "todo.updated":
+ setStore("todo", event.properties.sessionID, event.properties.todos)
+ break
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
@@ -116,7 +141,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.sort((a, b) => a.id.localeCompare(b.id))
.slice(0, store.limit)
setStore("session", sessions)
- setStore("more", sessions.length === store.limit)
}),
config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
@@ -161,22 +185,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index]
return undefined
},
- async sync(sessionID: string, isRetry = false) {
- const [session, messages] = await Promise.all([
- sdk.client.session.get({ path: { id: sessionID } }),
+ async sync(sessionID: string, _isRetry = false) {
+ const [session, messages, todo, diff] = await Promise.all([
+ sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }),
sdk.client.session.messages({ path: { id: sessionID } }),
+ sdk.client.session.todo({ path: { id: sessionID } }),
+ sdk.client.session.diff({ path: { id: sessionID } }),
])
-
- // If no messages and this might be a new session, retry after a delay
- if (!isRetry && messages.data!.length === 0) {
- setTimeout(() => this.sync(sessionID, true), 500)
- return
- }
-
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
- draft.session[match.index] = session.data!
+ if (match.found) draft.session[match.index] = session.data!
+ if (!match.found) draft.session.splice(match.index, 0, session.data!)
+ draft.todo[sessionID] = todo.data ?? []
draft.message[sessionID] = messages
.data!.map((x) => x.info)
.slice()
@@ -187,13 +208,21 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.map(sanitizePart)
.sort((a, b) => a.id.localeCompare(b.id))
}
+ draft.session_diff[sessionID] = diff.data ?? []
}),
)
+
+ // If no messages and this might be a new session, retry after a delay
+ // if (!isRetry && messages.data!.length === 0) {
+ // setTimeout(() => this.sync(sessionID, true), 500)
+ // return
+ // }
},
fetch: async (count = 10) => {
setStore("limit", (x) => x + count)
await load.session()
},
+ more: createMemo(() => store.session.length === store.limit),
},
load,
absolute,
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index c5957a2d2..d88564007 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -80,7 +80,7 @@ export default function Layout(props: ParentProps) {
}}
</For>
</nav>
- <Show when={sync.data.more}>
+ <Show when={sync.session.more()}>
<button
class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong"
onClick={() => sync.session.fetch()}
@@ -95,7 +95,7 @@ export default function Layout(props: ParentProps) {
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
- class="hidden @[4rem]:flex w-full gap-2 text-12-medium text-text-base stroke-[1.5px]"
+ class="hidden @[4rem]:flex w-full text-12-medium text-text-base stroke-[1.5px]"
variant="ghost"
icon="speech-bubble"
>
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 36eed718a..9f72191bc 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -13,6 +13,7 @@ import {
Code,
Tooltip,
ProgressCircle,
+ Button,
} from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import { MessageProgress } from "@/components/message-progress"
@@ -221,9 +222,9 @@ export default function Page() {
</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"
+ class="mt-0.5 opacity-0 group-data-[selected]/tab:opacity-100
+ hover:bg-transparent
+ hover:opacity-100 group-hover/tab:opacity-100"
variant="ghost"
onClick={() => props.onTabClose(props.tab)}
/>
@@ -289,56 +290,101 @@ export default function Page() {
<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> */}
+ <Show when={local.layout.review.state() === "tab" && session.diffs().length}>
+ <Tabs.Trigger value="review" class="flex gap-3 items-center group/tab pr-1">
+ <Show when={session.diffs()}>
+ <DiffChanges changes={session.diffs()} variant="bars" />
+ </Show>
+ <div class="flex items-center gap-1.5">
+ <div>Review</div>
+ <Show when={session.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">
+ {session.info()?.summary?.files ?? 0}
+ </div>
+ </Show>
+ <IconButton
+ icon="close"
+ class="mt-0.5 -ml-1 opacity-0 group-data-[selected]/tab:opacity-100
+ hover:bg-transparent hover:opacity-100 group-hover/tab:opacity-100"
+ variant="ghost"
+ onClick={local.layout.review.close}
+ />
+ </div>
+ </Tabs.Trigger>
+ </Show>
<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)}
- />
+ <Tooltip value="Open file" class="flex items-center">
+ <IconButton
+ icon="plus-small"
+ variant="ghost"
+ iconSize="large"
+ onClick={() => setStore("fileSelectOpen", true)}
+ />
+ </Tooltip>
</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>
+ <Show
+ when={session.id}
+ fallback={
+ <div class="size-full max-w-2xl mx-auto flex flex-col pb-45 px-6 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 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 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>
- }
- >
- {(_) => {
- return (
- <div class="pt-3 flex flex-col flex-1 min-h-0">
- <div class="flex-1 min-h-0">
+ </div>
+ }
+ >
+ {(_) => {
+ return (
+ <div
+ classList={{
+ "w-full grid flex-1 _gap-6 min-h-0": true,
+ "grid-cols-2": local.layout.review.state() === "open",
+ "max-w-2xl mx-auto": local.layout.review.state() !== "open",
+ }}
+ >
+ <div class="relative px-6 py-2 w-full flex flex-col gap-6 flex-1 min-h-0">
+ <div class="h-8 flex shrink-0 self-stretch items-center justify-end">
+ <Show when={local.layout.review.state() === "closed" && session.diffs().length}>
+ <Button icon="layout-right" onClick={local.layout.review.open}>
+ Review
+ </Button>
+ </Show>
+ </div>
+ <div
+ classList={{
+ "flex-1 min-h-0": true,
+ "flex items-start justify-start": local.layout.review.state() === "open",
+ }}
+ >
<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"
+ classList={{
+ "mr-8 shrink-0 flex flex-col items-start": true,
+ "absolute right-full w-60 @7xl:gap-2": local.layout.review.state() !== "open",
+ "": local.layout.review.state() === "open",
+ }}
>
<For each={session.messages.user()}>
{(message) => {
@@ -351,11 +397,43 @@ export default function Page() {
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const working = createMemo(() => !message.summary?.body && !error())
+ const handleClick = () => session.messages.setActive(message.id)
+
return (
- <li class="group/li flex items-center self-stretch">
+ <li
+ classList={{
+ "group/li flex items-center self-stretch justify-end": true,
+ "@7xl:justify-start": local.layout.review.state() !== "open",
+ }}
+ >
+ <Tooltip
+ placement="right"
+ gutter={8}
+ value={
+ <div class="flex items-center gap-2">
+ <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
+ {message.summary?.title}
+ </div>
+ }
+ >
+ <button
+ data-active={session.messages.active()?.id === message.id}
+ onClick={handleClick}
+ classList={{
+ "group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
+ "data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
+ "@7xl:hidden": local.layout.review.state() !== "open",
+ }}
+ >
+ <div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
+ </button>
+ </Tooltip>
<button
- class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
- onClick={() => session.messages.setActive(message.id)}
+ classList={{
+ "hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
+ "@7xl:flex": local.layout.review.state() !== "open",
+ }}
+ onClick={handleClick}
>
<Switch>
<Match when={working()}>
@@ -383,7 +461,7 @@ export default function Page() {
</For>
</ul>
</Show>
- <div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
+ <div ref={messageScrollElement} class="grow w-full 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)
@@ -423,7 +501,7 @@ export default function Page() {
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="flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10 pb-1">
<div class="w-full text-14-medium text-text-strong">
<Show
when={titled()}
@@ -441,7 +519,7 @@ export default function Page() {
</Show>
</div>
</div>
- <div class="-mt-8">
+ <div class="-mt-9">
<Message message={message} parts={parts()} />
</div>
{/* Summary */}
@@ -496,7 +574,7 @@ export default function Page() {
</div>
</Accordion.Trigger>
</Accordion.Header>
- <Accordion.Content class="max-h-[300px] overflow-y-auto no-scrollbar">
+ <Accordion.Content class="max-h-[360px] overflow-y-auto no-scrollbar">
<Diff
before={{
name: diff.file!,
@@ -569,12 +647,135 @@ export default function Page() {
</div>
</div>
</div>
- )
- }}
- </Show>
- </div>
+ <Show when={local.layout.review.state() === "open"}>
+ <div
+ classList={{
+ "relative px-6 py-2 w-full flex flex-col gap-6 flex-1 min-h-0 border-l border-border-weak-base": true,
+ }}
+ >
+ <div class="h-8 w-full flex items-center justify-between shrink-0 self-stretch">
+ <div class="flex items-center gap-x-3">
+ <Tooltip value="Close">
+ <IconButton icon="align-right" variant="ghost" onClick={local.layout.review.close} />
+ </Tooltip>
+ <Tooltip value="Open in tab">
+ <IconButton
+ icon="expand"
+ variant="ghost"
+ onClick={() => {
+ local.layout.review.tab()
+ session.layout.setActiveTab("review")
+ }}
+ />
+ </Tooltip>
+ </div>
+ </div>
+ <div class="text-14-medium text-text-strong">All changes</div>
+ <div class="h-full pb-40 overflow-y-auto no-scrollbar">
+ <Accordion class="w-full" multiple>
+ <For each={session.diffs()}>
+ {(diff) => (
+ <Accordion.Item value={diff.file} defaultOpen>
+ <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 changes={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>
+ </div>
+ </Show>
+ </div>
+ )
+ }}
+ </Show>
</Tabs.Content>
- {/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
+ <Show when={local.layout.review.state() === "tab" && session.diffs().length}>
+ <Tabs.Content value="review" class="select-text">
+ <div
+ classList={{
+ "relative px-6 py-2 w-full flex flex-col gap-6 flex-1 min-h-0": true,
+ }}
+ >
+ <div class="h-8 w-full flex items-center justify-between shrink-0 self-stretch">
+ <div class="flex items-center gap-x-3"></div>
+ </div>
+ <div class="text-14-medium text-text-strong">All changes</div>
+ <div class="h-full pb-40 overflow-y-auto no-scrollbar">
+ <Accordion class="w-full" multiple>
+ <For each={session.diffs()}>
+ {(diff) => (
+ <Accordion.Item value={diff.file} defaultOpen>
+ <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 changes={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>
+ </div>
+ </Tabs.Content>
+ </Show>
<For each={session.layout.tabs.opened}>
{(tab) => {
const [file] = createResource(