Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
11 changes: 11 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,12 +607,23 @@ export const dict = {
"session.files.all": "All files",
"session.files.empty": "No files",
"session.files.binaryContent": "Binary file (content cannot be displayed)",
"session.files.status.added": "Added",
"session.files.status.updated": "Updated",
"session.files.missing": "Missing",
"session.files.notFound": "File not found",

"session.messages.renderEarlier": "Render earlier messages",
"session.messages.loadingEarlier": "Loading earlier messages...",
"session.messages.loadEarlier": "Load earlier messages",
"session.messages.loading": "Loading messages...",
"session.messages.jumpToLatest": "Jump to latest",
"session.turnChange.undoBlocked": "Undo blocked",
"session.turnChange.redoBlocked": "Redo blocked",
"session.turnChange.blocked.conflict": "The file changed after this turn, so PawWork did not overwrite it.",
"session.turnChange.blocked.unsupportedSize":
"This turn includes a file without restore data, so PawWork did not change anything.",
"session.turnChange.blocked.permissionDenied": "PawWork does not have permission to change this file.",
"session.turnChange.blocked.generic": "PawWork could not apply this change.",

"session.context.addToContext": "Add {{selection}} to context",
"session.todo.title": "Todos",
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,11 +570,21 @@ export const dict = {
"session.files.all": "所有文件",
"session.files.empty": "无文件",
"session.files.binaryContent": "二进制文件(无法显示内容)",
"session.files.status.added": "已添加",
"session.files.status.updated": "已更新",
"session.files.missing": "缺失",
"session.files.notFound": "找不到文件",
"session.messages.renderEarlier": "显示更早的消息",
"session.messages.loadingEarlier": "正在加载更早的消息...",
"session.messages.loadEarlier": "加载更早的消息",
"session.messages.loading": "正在加载消息...",
"session.messages.jumpToLatest": "跳转到最新",
"session.turnChange.undoBlocked": "无法撤销",
"session.turnChange.redoBlocked": "无法重做",
"session.turnChange.blocked.conflict": "此轮之后文件已被修改,爪印没有覆盖它。",
"session.turnChange.blocked.unsupportedSize": "此轮包含没有恢复数据的文件,爪印没有修改任何内容。",
"session.turnChange.blocked.permissionDenied": "爪印没有权限修改此文件。",
"session.turnChange.blocked.generic": "爪印无法应用此更改。",
"session.context.addToContext": "将 {{selection}} 添加到上下文",
"session.todo.title": "待办事项",
"session.todo.collapse": "折叠",
Expand Down
10 changes: 7 additions & 3 deletions packages/app/src/pages/session/files-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,20 @@ export function FilesTab(props: { files: FilesTabEntry[] }) {
<div class="min-w-0 flex-1">
<div class="text-13-medium text-text-strong break-all">{entry.file}</div>
<div class="mt-1 flex items-center gap-2 text-13-regular text-text-weak">
<span>{entry.kind === "added" ? "Added" : "Updated"}</span>
<span>
{entry.kind === "added"
? language.t("session.files.status.added")
: language.t("session.files.status.updated")}
</span>
<span aria-hidden>•</span>
<span>{meta().exists ? formatSize(meta().size) : "Missing"}</span>
<span>{meta().exists ? formatSize(meta().size) : language.t("session.files.missing")}</span>
</div>
</div>
</div>

<Switch>
<Match when={!meta().exists}>
<div class="text-13-regular text-text-weak">File not found</div>
<div class="text-13-regular text-text-weak">{language.t("session.files.notFound")}</div>
</Match>
<Match when={true}>{preview(entry)}</Match>
</Switch>
Expand Down
112 changes: 112 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,87 @@ 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"
? language.t("session.turnChange.undoBlocked")
: language.t("session.turnChange.redoBlocked"),
description:
body?.reason === "conflict"
? language.t("session.turnChange.blocked.conflict")
: body?.reason === "unsupported_size"
? language.t("session.turnChange.blocked.unsupportedSize")
: body?.reason === "permission_denied"
? language.t("session.turnChange.blocked.permissionDenied")
: language.t("session.turnChange.blocked.generic"),
variant: "error",
})
Comment thread
Astro-Han marked this conversation as resolved.
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 +1014,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
const event =
mode === "redo"
? file.status === "added"
? "add"
: file.status === "deleted"
? "unlink"
: "change"
: file.status === "added"
? "unlink"
: file.status === "deleted"
? "add"
: "change"
if (event !== "unlink") yield* bus.publish(File.Event.Edited, { file: file.openPath })
yield* bus.publish(FileWatcher.Event.Updated, { file: file.openPath, event })
if (event !== "unlink") yield* lsp.touchFile(file.openPath, true)
}
})
}

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.
Resolved
)
.get(
"/:sessionID/artifacts",
describeRoute({
Expand Down
Loading
Loading