Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,26 @@ type UserActions = {
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
}

type TurnChangeDisplay = {
sessionID: string
turnID: string
messageID: string
undoAvailable: boolean
redoAvailable: boolean
files: Array<{
path: string
openPath?: string
status: "added" | "modified" | "deleted"
additions?: number
deletions?: number
patch?: string
sensitive?: boolean
binary?: boolean
large?: boolean
expandable: boolean
}>
}

const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
if (part.type !== "text" || !(part as TextPart).synthetic) return []
Expand Down Expand Up @@ -294,6 +314,82 @@ export function MessageTimeline(props: {
const webSearchToastSurfaced = new Set<string>()
const webSearchPartCursor = new Map<string, number>()
const webSearchPendingParts = new Map<string, Set<string>>()
const [turnChanges, setTurnChanges] = createStore<Record<string, TurnChangeDisplay | null>>({})
const fetchedTurnChanges = new Set<string>()

const authHeaders = () => {
const current = server.current
if (!current?.http.password) return {} as Record<string, string>
return {
Authorization: `Basic ${btoa(`${current.http.username ?? "opencode"}:${current.http.password}`)}`,
}
}

const turnChangeFetch = async (
messageID: string,
action?: "undo" | "redo",
): Promise<TurnChangeDisplay | undefined> => {
const current = server.current
const id = sessionID()
if (!current || !id) return
const url = `${current.http.url}/session/${id}/turn-change/${messageID}${action ? `/${action}` : ""}`
const res = await fetch(url, {
method: action ? "POST" : "GET",
headers: authHeaders(),
})
if (!res.ok) throw new Error(`turn-change ${action ?? "get"} failed: ${res.status}`)
const body = await res.json()
if (!action) {
setTurnChanges(messageID, body ?? null)
return body ?? undefined
}
if (body?.status === "applied") {
setTurnChanges(messageID, body.display)
return body.display
}
showToast({
title: action === "undo" ? "Undo blocked" : "Redo blocked",
description:
body?.reason === "conflict"
? "The file changed after this turn, so PawWork did not overwrite it."
: body?.reason === "unsupported_size"
? "This turn includes a file without restore data, so PawWork did not change anything."
: "PawWork could not apply this change.",
variant: "error",
})
Comment thread
Astro-Han marked this conversation as resolved.
return turnChanges[messageID] ?? undefined
}

createEffect(
on(
() =>
sessionMessages()
.filter((message): message is Extract<MessageType, { role: "assistant" }> => message.role === "assistant")
.filter((message) => typeof message.time.completed === "number")
.map((message) => message.id)
.join(":"),
() => {
const id = sessionID()
if (!id) return
for (const message of sessionMessages()) {
if (message.role !== "assistant" || typeof message.time.completed !== "number") continue
const key = `${id}:${message.id}`
if (fetchedTurnChanges.has(key)) continue
fetchedTurnChanges.add(key)
void turnChangeFetch(message.id)
.then((display) => {
if (display) return
fetchedTurnChanges.delete(key)
setTimeout(() => void turnChangeFetch(message.id).catch(() => undefined), 500)
})
.catch(() => {
fetchedTurnChanges.delete(key)
setTurnChanges(message.id, null)
})
}
},
),
)

onMount(() => {
void emitRendererDiagnostic({
Expand Down Expand Up @@ -913,6 +1009,17 @@ export function MessageTimeline(props: {
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
turnChanges={turnChanges}
turnChangeActions={{
undo: (messageID) => turnChangeFetch(messageID, "undo"),
redo: (messageID) => turnChangeFetch(messageID, "redo"),
openFile: (path) => {
void platform.openPath?.(path)
},
showInFolder: (path) => {
void platform.showItemInFolder?.(path)
},
}}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
CREATE TABLE `turn_change_display` (
`session_id` text NOT NULL,
`message_id` text NOT NULL,
`data` text NOT NULL,
`state` text NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
CONSTRAINT `fk_turn_change_display_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_turn_change_display_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `turn_change_display_session_id_message_id_idx` ON `turn_change_display` (`session_id`,`message_id`);--> statement-breakpoint
CREATE INDEX `turn_change_display_session_id_idx` ON `turn_change_display` (`session_id`);--> statement-breakpoint
CREATE TABLE `turn_change_restore` (
`session_id` text NOT NULL,
`message_id` text NOT NULL,
`file_path` text NOT NULL,
`position` integer NOT NULL,
`data` text NOT NULL,
`finalized` integer DEFAULT false NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
CONSTRAINT `fk_turn_change_restore_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_turn_change_restore_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `turn_change_restore_session_id_message_id_file_path_idx` ON `turn_change_restore` (`session_id`,`message_id`,`file_path`);--> statement-breakpoint
CREATE INDEX `turn_change_restore_session_id_message_id_idx` ON `turn_change_restore` (`session_id`,`message_id`);
135 changes: 135 additions & 0 deletions packages/opencode/src/server/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,38 @@ import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Bus } from "../../bus"
import { NamedError } from "@opencode-ai/util/error"
import { TurnChange, type Display as TurnChangeDisplay } from "@/session/turn-change"
import { FileWatcher } from "@/file/watcher"
import { File } from "@/file"
import { LSP } from "@/lsp"

const log = Log.create({ service: "server" })

function publishTurnChangeFiles(display: TurnChangeDisplay, mode: "undo" | "redo") {
return Effect.gen(function* () {
const bus = yield* Bus.Service
const lsp = yield* LSP.Service
for (const file of display.files) {
if (!file.openPath) continue
yield* bus.publish(File.Event.Edited, { file: file.openPath })
const event =
mode === "redo"
? file.status === "added"
? "add"
: file.status === "deleted"
? "unlink"
: "change"
: file.status === "added"
? "unlink"
: file.status === "deleted"
? "add"
: "change"
yield* bus.publish(FileWatcher.Event.Updated, { file: file.openPath, event })
if (event !== "unlink") yield* lsp.touchFile(file.openPath, true)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
})
}

export const SessionRoutes = lazy(() =>
new Hono()
.get(
Expand Down Expand Up @@ -533,6 +562,112 @@ export const SessionRoutes = lazy(() =>
return c.json(result)
},
)
.get(
"/:sessionID/turn-change/:messageID",
describeRoute({
summary: "Get assistant turn changes",
description: "Get files explicitly changed by PawWork file-writing tools during one assistant turn.",
operationId: "session.turnChange",
responses: {
200: {
description: "Turn changes",
content: {
"application/json": {
schema: resolver(TurnChange.DisplaySchema.nullable()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
),
async (c) => {
const params = c.req.valid("param")
return c.json(TurnChange.get(params) ?? null)
},
)
.post(
"/:sessionID/turn-change/:messageID/undo",
describeRoute({
summary: "Undo assistant turn file changes",
operationId: "session.turnChangeUndo",
responses: {
200: {
description: "Undo result",
content: {
"application/json": {
schema: resolver(TurnChange.MutationResultSchema),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
),
async (c) => {
const params = c.req.valid("param")
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
yield* state.assertNotBusy(params.sessionID)
const result = yield* Effect.promise(() => TurnChange.undo(params))
if (result.status === "applied") yield* publishTurnChangeFiles(result.display, "undo")
return result
}),
)
return c.json(result)
},
)
.post(
"/:sessionID/turn-change/:messageID/redo",
describeRoute({
summary: "Redo assistant turn file changes",
operationId: "session.turnChangeRedo",
responses: {
200: {
description: "Redo result",
content: {
"application/json": {
schema: resolver(TurnChange.MutationResultSchema),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
),
async (c) => {
const params = c.req.valid("param")
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
yield* state.assertNotBusy(params.sessionID)
const result = yield* Effect.promise(() => TurnChange.redo(params))
if (result.status === "applied") yield* publishTurnChangeFiles(result.display, "redo")
return result
}),
)
return c.json(result)
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
.get(
"/:sessionID/artifacts",
describeRoute({
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/session/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Provider } from "../provider/provider"
import { ProviderID, ModelID } from "../provider/schema"
import { Instance } from "../project/instance"
import { Global } from "../global"
import { sanitizeSensitiveDiffs, sanitizeSensitiveToolPart } from "@/tool/sensitive"

export function getRuntimeNamespace(): "pawwork" | "opencode" {
return Runtime.isPawWork() ? "pawwork" : "opencode"
Expand Down Expand Up @@ -48,6 +49,7 @@ export function redactPart(
part: MessageV2.Part,
ctx: { count: { omitted: number } },
): MessageV2.Part {
part = sanitizeSensitiveToolPart(part)
if (part.type === "file") {
const r = redactDataUrl(part.url)
if (!r) return part
Expand Down Expand Up @@ -237,7 +239,7 @@ export namespace Export {
ctx: { count: { omitted: number } },
) {
const messages = yield* svc.messages({ sessionID: info.id })
const diffs = yield* svc.diff(info.id)
const diffs = sanitizeSensitiveDiffs(yield* svc.diff(info.id)) as SnapshotMod.FileDiff[]
const children = yield* svc.children(info.id)
const sorted = [...children].sort((a, b) => {
if (a.time.created !== b.time.created) return a.time.created - b.time.created
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Question } from "@/question"
import { errorMessage } from "@/util/error"
import { Log } from "@opencode-ai/core/util/log"
import { isRecord } from "@/util/record"
import { TurnChange } from "./turn-change"

const log = Log.create({ service: "session.processor" })

Expand Down Expand Up @@ -735,6 +736,7 @@ export const layer: Layer.Layer<
ctx.toolcalls = {}
ctx.assistantMessage.time.completed = Date.now()
yield* session.updateMessage(ctx.assistantMessage)
TurnChange.finalize({ sessionID: ctx.sessionID, messageID: ctx.assistantMessage.id })
})

const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
Expand Down
7 changes: 4 additions & 3 deletions packages/opencode/src/session/revert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { MessageV2 } from "./message-v2"
import { SessionID, MessageID, PartID } from "./schema"
import { SessionRunState } from "./run-state"
import { SessionSummary } from "./summary"
import { sanitizeSensitiveDiffs } from "@/tool/sensitive"

const log = Log.create({ service: "session.revert" })

Expand Down Expand Up @@ -79,15 +80,15 @@ export const layer = Layer.effect(
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
const range = all.filter((msg) => msg.info.id >= rev!.messageID)
const diffs = yield* summary.computeDiff({ messages: range })
const diffs = sanitizeSensitiveDiffs(yield* summary.computeDiff({ messages: range })) as Snapshot.FileDiff[]
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
yield* sessions.setRevert({
sessionID: input.sessionID,
revert: rev,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
additions: diffs.reduce((sum, x) => sum + (x.additions ?? 0), 0),
deletions: diffs.reduce((sum, x) => sum + (x.deletions ?? 0), 0),
files: diffs.length,
},
})
Expand Down
Loading
Loading