diff options
| author | Adam <[email protected]> | 2025-11-06 15:13:02 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-11-06 15:13:06 -0600 |
| commit | 96c57418f39bbf10e4538a6e0652baff7f0932fb (patch) | |
| tree | 7709020729579f1ec84ce230726504a810d01421 /packages/desktop/src | |
| parent | b8c51e307f1e26c4cbc962fca742338fba3c9fe9 (diff) | |
| download | opencode-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.tsx | 1 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 17 | ||||
| -rw-r--r-- | packages/desktop/src/context/session.tsx | 8 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 59 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 4 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 299 |
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 - <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 + <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)}‎ + </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)}‎</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( |
