From b746aec49316d8f83d40aa34fa55bf7cff81c036 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:20:12 -0500 Subject: [PATCH 01/25] chore: storybook tweaks --- .../timeline-playground.stories.tsx | 207 ++++++++++++++++-- 1 file changed, 191 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index aa20ba940a..00d458fa02 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -425,13 +425,60 @@ const TOOL_SAMPLES = { // Fake data generators // --------------------------------------------------------------------------- const SESSION_ID = "playground-session" +const DEFAULT_SESSION = { id: SESSION_ID, title: "Timeline Playground" } -function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts: Part[] } { +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function normalize(raw: unknown) { + if (Array.isArray(raw)) { + const info = raw.find((row) => record(row) && row.type === "session" && record(row.data))?.data + if (!record(info) || typeof info.id !== "string") { + throw new Error("No session found in JSON") + } + + const part = new Map() + const messages = raw.flatMap((row) => { + if (!record(row) || !record(row.data)) return [] + if (row.type === "part" && typeof row.data.messageID === "string") { + const list = part.get(row.data.messageID) ?? [] + list.push(row.data as Part) + part.set(row.data.messageID, list) + return [] + } + if (row.type !== "message" || typeof row.data.id !== "string") return [] + return [{ info: row.data as Message, parts: [] as Part[] }] + }) + + return { + info, + messages: messages.map((msg) => ({ + info: msg.info, + parts: part.get(msg.info.id) ?? [], + })), + } + } + + if (!record(raw) || !record(raw.info) || typeof raw.info.id !== "string" || !Array.isArray(raw.messages)) { + throw new Error("Expected an `opencode export` JSON file") + } + + return { + info: raw.info, + messages: raw.messages.flatMap((row) => { + if (!record(row) || !record(row.info) || typeof row.info.id !== "string") return [] + return [{ info: row.info as Message, parts: Array.isArray(row.parts) ? (row.parts as Part[]) : [] }] + }), + } +} + +function mkUser(text: string, extra: Part[] = [], sessionID = SESSION_ID): { message: UserMessage; parts: Part[] } { const id = uid() return { message: { id, - sessionID: SESSION_ID, + sessionID, role: "user", time: { created: Date.now() }, agent: "code", @@ -445,10 +492,10 @@ function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts } } -function mkAssistant(parentID: string): AssistantMessage { +function mkAssistant(parentID: string, sessionID = SESSION_ID): AssistantMessage { return { id: uid(), - sessionID: SESSION_ID, + sessionID, role: "assistant", time: { created: Date.now(), completed: Date.now() + 3000 }, parentID, @@ -1010,12 +1057,16 @@ function Playground() { messages: [], parts: {}, }) + const [session, setSession] = createSignal({ ...DEFAULT_SESSION }) + const [loaded, setLoaded] = createSignal("") + const [issue, setIssue] = createSignal("") // ---- CSS overrides ---- const [css, setCss] = createStore>({}) const [defaults, setDefaults] = createStore>({}) let styleEl: HTMLStyleElement | undefined let previewRef: HTMLDivElement | undefined + let pick: HTMLInputElement | undefined /** Read computed styles from the DOM to seed slider defaults */ const readDefaults = () => { @@ -1074,10 +1125,10 @@ function Playground() { const userMessages = createMemo(() => state.messages.filter((m): m is UserMessage => m.role === "user")) const data = createMemo(() => ({ - session: [{ id: SESSION_ID }], + session: [session()], session_status: {}, session_diff: {}, - message: { [SESSION_ID]: state.messages }, + message: { [session().id]: state.messages }, part: state.parts, provider: { all: [{ id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }], @@ -1109,8 +1160,8 @@ function Playground() { const id = lastAssistantID() if (id) return id // Create a minimal placeholder turn - const user = mkUser("...") - const asst = mkAssistant(user.message.id) + const user = mkUser("...", [], session().id) + const asst = mkAssistant(user.message.id, session().id) setState( produce((draft) => { draft.messages.push(user.message) @@ -1136,8 +1187,8 @@ function Playground() { // ---- User message helpers ---- const addUser = (variant: keyof typeof USER_VARIANTS) => { const v = USER_VARIANTS[variant] - const user = mkUser(v.text, v.parts) - const asst = mkAssistant(user.message.id) + const user = mkUser(v.text, v.parts, session().id) + const asst = mkAssistant(user.message.id, session().id) setState( produce((draft) => { draft.messages.push(user.message) @@ -1164,8 +1215,8 @@ function Playground() { // ---- Composite helpers (create full turns with user + assistant) ---- const addFullTurn = (userText: string, parts: Part[]) => { - const user = mkUser(userText) - const asst = mkAssistant(user.message.id) + const user = mkUser(userText, [], session().id) + const asst = mkAssistant(user.message.id, session().id) setState( produce((draft) => { draft.messages.push(user.message) @@ -1222,9 +1273,91 @@ function Playground() { addReasoningFullTurn() } + const interrupt = () => { + const user = userMessages().at(-1) + if (!user) return + const now = Date.now() + + setState( + produce((draft) => { + const msg = draft.messages.findLast( + (item): item is AssistantMessage => item.role === "assistant" && item.parentID === user.id, + ) + + if (msg) { + const time = msg.time ?? { created: now } + msg.time = { ...time, completed: time.completed ?? now } + msg.error = { name: "MessageAbortedError", message: "Interrupted" } + return + } + + const asst = mkAssistant(user.id, session().id) + asst.time = { created: now, completed: now } + asst.error = { name: "MessageAbortedError", message: "Interrupted" } + draft.messages.push(asst) + draft.parts[asst.id] = [] + }), + ) + } + + const load = (raw: unknown, name: string) => { + const next = normalize(raw) + const id = typeof next.info.id === "string" && next.info.id ? next.info.id : SESSION_ID + const messages = next.messages.map((msg) => ({ + ...msg.info, + sessionID: typeof msg.info.sessionID === "string" ? msg.info.sessionID : id, + })) + const parts = Object.fromEntries( + next.messages.map((msg, idx) => { + const info = messages[idx] + return [ + info.id, + msg.parts.map((part) => ({ + ...part, + messageID: typeof part.messageID === "string" ? part.messageID : info.id, + sessionID: typeof part.sessionID === "string" ? part.sessionID : info.sessionID, + })), + ] + }), + ) + + batch(() => { + setSession({ + ...DEFAULT_SESSION, + ...next.info, + id, + title: typeof next.info.title === "string" && next.info.title ? next.info.title : name, + }) + setState({ messages, parts }) + setLoaded(name) + setIssue("") + }) + } + + const importFile = async (event: Event) => { + const input = event.currentTarget as HTMLInputElement + const file = input.files?.[0] + if (!file) return + + setIssue("") + + try { + load(JSON.parse(await file.text()), file.name) + } catch (err) { + setIssue(err instanceof Error ? err.message : String(err)) + } finally { + input.value = "" + } + } + const clearAll = () => { - setState({ messages: [], parts: {} }) - seq = 0 + batch(() => { + setState({ messages: [], parts: {} }) + setSession({ ...DEFAULT_SESSION }) + setLoaded("") + setIssue("") + seq = 0 + }) } // ---- CSS export ---- @@ -1393,6 +1526,35 @@ function Playground() {
+ {/* ---- Session import ---- */} +
Import session
+
+ Replaces the current timeline with an `opencode export` JSON file +
+
+ + +
+ +
+ {loaded()} • {session().title || session().id} • {state.messages.length} message + {state.messages.length === 1 ? "" : "s"} +
+
+ +
+ {issue()} +
+
+ {/* ---- User messages ---- */}
User messages
@@ -1407,6 +1569,19 @@ function Playground() { )}
+
+ +
{/* ---- Text and reasoning blocks ---- */}
Text and reasoning blocks
@@ -1716,7 +1891,7 @@ function Playground() { "font-size": "14px", }} > - Click a generator button to add messages + Click a generator button or import a session
} > @@ -1729,7 +1904,7 @@ function Playground() { {(msg) => (
Date: Wed, 25 Mar 2026 21:09:53 +0530 Subject: [PATCH 02/25] feat: restore git-backed review modes with effectful git service (#18900) --- .../app/src/context/global-sync/bootstrap.ts | 2 +- .../context/global-sync/event-reducer.test.ts | 10 +- .../src/context/global-sync/event-reducer.ts | 4 +- packages/app/src/i18n/en.ts | 2 + packages/app/src/pages/session.tsx | 274 +++++++++++++--- .../src/pages/session/session-side-panel.tsx | 58 ++-- .../pages/session/use-session-commands.tsx | 6 +- packages/opencode/src/cli/cmd/github.ts | 10 +- packages/opencode/src/cli/cmd/pr.ts | 8 +- packages/opencode/src/file/index.ts | 14 +- packages/opencode/src/file/watcher.ts | 4 +- packages/opencode/src/git/index.ts | 307 ++++++++++++++++++ packages/opencode/src/project/vcs.ts | 171 ++++++++-- packages/opencode/src/server/server.ts | 31 +- packages/opencode/src/storage/storage.ts | 4 +- packages/opencode/src/util/git.ts | 35 -- packages/opencode/src/worktree/index.ts | 51 +-- packages/opencode/test/git/git.test.ts | 128 ++++++++ packages/opencode/test/project/vcs.test.ts | 112 ++++++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 33 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 23 +- packages/ui/src/components/session-review.tsx | 36 +- packages/ui/src/i18n/en.ts | 2 + 23 files changed, 1094 insertions(+), 231 deletions(-) create mode 100644 packages/opencode/src/git/index.ts delete mode 100644 packages/opencode/src/util/git.ts create mode 100644 packages/opencode/test/git/git.test.ts diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 47be3abcb3..9158fb46e7 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -190,7 +190,7 @@ export async function bootstrapDirectory(input: { input.sdk.vcs.get().then((x) => { const next = x.data ?? input.store.vcs input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) + if (next) input.vcsCache.setStore("value", next) }), ), () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index cf2da135cb..892129788e 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => { }) test("updates vcs branch in store and cache", () => { - const [store, setStore] = createStore(baseState()) - const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] }) + const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } })) + const [cacheStore, setCacheStore] = createStore({ + value: { branch: "main", default_branch: "main" } as State["vcs"], + }) applyDirectoryEvent({ event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } }, @@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => { }, }) - expect(store.vcs).toEqual({ branch: "feature/test" }) - expect(cacheStore.value).toEqual({ branch: "feature/test" }) + expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" }) + expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" }) }) test("routes disposal and lsp events to side-effect handlers", () => { diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5d8b7c4e3d..4af6365535 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: { break } case "vcs.branch.updated": { - const props = event.properties as { branch: string } + const props = event.properties as { branch?: string } if (input.store.vcs?.branch === props.branch) break - const next = { branch: props.branch } + const next = { ...input.store.vcs, branch: props.branch } input.setStore("vcs", next) if (input.vcsCache) input.vcsCache.setStore("value", next) break diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 579b740d3a..6a5d80742c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -535,6 +535,8 @@ export const dict = { "session.review.noVcs.createGit.action": "Create Git repository", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noChanges": "No changes", + "session.review.noUncommittedChanges": "No uncommitted changes yet", + "session.review.noBranchChanges": "No branch changes yet", "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2d3e31355a..8c32a7237f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { Project, UserMessage } from "@opencode-ai/sdk/v2" +import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useMutation } from "@tanstack/solid-query" import { @@ -64,6 +64,9 @@ import { formatServerError } from "@/utils/server-errors" const emptyUserMessages: UserMessage[] = [] const emptyFollowups: (FollowupDraft & { id: string })[] = [] +type ChangeMode = "git" | "branch" | "session" | "turn" +type VcsMode = "git" | "branch" + type SessionHistoryWindowInput = { sessionID: () => string | undefined messagesReady: () => boolean @@ -424,15 +427,16 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) - const hasReview = createMemo(() => reviewCount() > 0) + const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasSessionReview = createMemo(() => sessionCount() > 0) + const canReview = createMemo(() => !!params.id) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, - hasReview, + hasReview: canReview, }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs @@ -455,6 +459,12 @@ export default function Page() { if (!id) return false return sync.session.history.loading(id) }) + const diffsReady = createMemo(() => { + const id = params.id + if (!id) return true + if (!hasSessionReview()) return true + return sync.data.session_diff[id] !== undefined + }) const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], @@ -508,11 +518,22 @@ export default function Page() { const [store, setStore] = createStore({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", - changes: "session" as "session" | "turn", + changes: "git" as ChangeMode, newSessionWorktree: "main", deferRender: false, }) + const [vcs, setVcs] = createStore({ + diff: { + git: [] as FileDiff[], + branch: [] as FileDiff[], + }, + ready: { + git: false, + branch: false, + }, + }) + const [followup, setFollowup] = createStore({ items: {} as Record, failed: {} as Record, @@ -539,6 +560,68 @@ export default function Page() { let refreshTimer: number | undefined let diffFrame: number | undefined let diffTimer: number | undefined + const vcsTask = new Map>() + const vcsRun = new Map() + + const bumpVcs = (mode: VcsMode) => { + const next = (vcsRun.get(mode) ?? 0) + 1 + vcsRun.set(mode, next) + return next + } + + const resetVcs = (mode?: VcsMode) => { + const list = mode ? [mode] : (["git", "branch"] as const) + list.forEach((item) => { + bumpVcs(item) + vcsTask.delete(item) + setVcs("diff", item, []) + setVcs("ready", item, false) + }) + } + + const loadVcs = (mode: VcsMode, force = false) => { + if (sync.project?.vcs !== "git") return Promise.resolve() + if (!force && vcs.ready[mode]) return Promise.resolve() + + if (force) { + if (vcsTask.has(mode)) bumpVcs(mode) + vcsTask.delete(mode) + setVcs("ready", mode, false) + } + + const current = vcsTask.get(mode) + if (current) return current + + const run = bumpVcs(mode) + + const task = sdk.client.vcs + .diff({ mode }) + .then((result) => { + if (vcsRun.get(mode) !== run) return + setVcs("diff", mode, result.data ?? []) + setVcs("ready", mode, true) + }) + .catch((error) => { + if (vcsRun.get(mode) !== run) return + console.debug("[session-review] failed to load vcs diff", { mode, error }) + setVcs("diff", mode, []) + setVcs("ready", mode, true) + }) + .finally(() => { + if (vcsTask.get(mode) === task) vcsTask.delete(mode) + }) + + vcsTask.set(mode, task) + return task + } + + const refreshVcs = () => { + resetVcs() + const mode = untrack(vcsMode) + if (!mode) return + if (!untrack(wantsReview)) return + void loadVcs(mode, true) + } createComputed((prev) => { const open = desktopReviewOpen() @@ -554,7 +637,42 @@ export default function Page() { }, desktopReviewOpen()) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) - const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) + const changesOptions = createMemo(() => { + const list: ChangeMode[] = [] + if (sync.project?.vcs === "git") list.push("git") + if ( + sync.project?.vcs === "git" && + sync.data.vcs?.branch && + sync.data.vcs?.default_branch && + sync.data.vcs.branch !== sync.data.vcs.default_branch + ) { + list.push("branch") + } + list.push("session", "turn") + return list + }) + const vcsMode = createMemo(() => { + if (store.changes === "git" || store.changes === "branch") return store.changes + }) + const reviewDiffs = createMemo(() => { + if (store.changes === "git") return vcs.diff.git + if (store.changes === "branch") return vcs.diff.branch + if (store.changes === "session") return diffs() + return turnDiffs() + }) + const reviewCount = createMemo(() => { + if (store.changes === "git") return vcs.diff.git.length + if (store.changes === "branch") return vcs.diff.branch.length + if (store.changes === "session") return sessionCount() + return turnDiffs().length + }) + const hasReview = createMemo(() => reviewCount() > 0) + const reviewReady = createMemo(() => { + if (store.changes === "git") return vcs.ready.git + if (store.changes === "branch") return vcs.ready.branch + if (store.changes === "session") return !hasSessionReview() || diffsReady() + return true + }) const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" @@ -620,13 +738,7 @@ export default function Page() { scrollToMessage(msgs[targetIndex], "auto") } - const diffsReady = createMemo(() => { - const id = params.id - if (!id) return true - if (!hasReview()) return true - return sync.data.session_diff[id] !== undefined - }) - const reviewEmptyKey = createMemo(() => { + const sessionEmptyKey = createMemo(() => { const project = sync.project if (project && !project.vcs) return "session.review.noVcs" if (sync.data.config.snapshot === false) return "session.review.noSnapshot" @@ -748,13 +860,46 @@ export default function Page() { sessionKey, () => { setStore("messageId", undefined) - setStore("changes", "session") + setStore("changes", "git") setUi("pendingMessage", undefined) }, { defer: true }, ), ) + createEffect( + on( + () => sdk.directory, + () => { + resetVcs() + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const, + (next, prev) => { + if (prev === undefined || same(next, prev)) return + refreshVcs() + }, + { defer: true }, + ), + ) + + const stopVcs = sdk.event.listen((evt) => { + if (evt.details.type !== "file.watcher.updated") return + const props = + typeof evt.details.properties === "object" && evt.details.properties + ? (evt.details.properties as Record) + : undefined + const file = typeof props?.file === "string" ? props.file : undefined + if (!file || file.startsWith(".git/")) return + refreshVcs() + }) + onCleanup(stopVcs) + createEffect( on( () => params.dir, @@ -877,6 +1022,40 @@ export default function Page() { } const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") + const wantsReview = createMemo(() => + isDesktop() + ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") + : store.mobileTab === "changes", + ) + + createEffect(() => { + const list = changesOptions() + if (list.includes(store.changes)) return + const next = list[0] + if (!next) return + setStore("changes", next) + }) + + createEffect(() => { + const mode = vcsMode() + if (!mode) return + if (!wantsReview()) return + void loadVcs(mode) + }) + + createEffect( + on( + () => sync.data.session_status[params.id ?? ""]?.type, + (next, prev) => { + const mode = vcsMode() + if (!mode) return + if (!wantsReview()) return + if (next !== "idle" || prev === undefined || prev === "idle") return + void loadVcs(mode, true) + }, + { defer: true }, + ), + ) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) @@ -923,21 +1102,23 @@ export default function Page() { loadFile: file.load, }) - const changesOptions = ["session", "turn"] as const - const changesOptionsList = [...changesOptions] - const changesTitle = () => { - if (!hasReview()) { + if (!canReview()) { return null } + const label = (option: ChangeMode) => { + if (option === "git") return language.t("ui.sessionReview.title.git") + if (option === "branch") return language.t("ui.sessionReview.title.branch") + if (option === "session") return language.t("ui.sessionReview.title") + return language.t("ui.sessionReview.title.lastTurn") + } + return (