From 5a7e20ba343001ed244426e1d6b77fa168c8a888 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:49:37 +0200 Subject: [PATCH 01/18] fix(core): raise busy_timeout for multi-process contention --- packages/core/src/database/database.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index d61adf047eac..f05f16c1b45d 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -26,7 +26,10 @@ const layer = Layer.effect( yield* db.run("PRAGMA journal_mode = WAL") yield* db.run("PRAGMA synchronous = NORMAL") - yield* db.run("PRAGMA busy_timeout = 5000") + // Multiple opencode processes share one DB file, so every lock wait is + // cross-process. 5s was tuned for a single writer and is exceeded by a + // large event write holding the WAL lock, which surfaces as a dead turn. + yield* db.run("PRAGMA busy_timeout = 30000") yield* db.run("PRAGMA cache_size = -64000") yield* db.run("PRAGMA foreign_keys = ON") yield* db.run("PRAGMA wal_checkpoint(PASSIVE)") From 8cb90ded392f8142ae8689a9a6e86978b1d5ac68 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:20:05 +0200 Subject: [PATCH 02/18] chore: generate --- packages/core/schema.json | 239 ++++++++++++++---- .../core/src/database/message-diff.sql.ts | 12 + packages/core/src/database/migration.gen.ts | 1 + .../migration/20260815091902_message_diff.ts | 17 ++ packages/core/src/database/schema.gen.ts | 7 + packages/core/test/database-migration.test.ts | 26 ++ 6 files changed, 254 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/database/message-diff.sql.ts create mode 100644 packages/core/src/database/migration/20260815091902_message_diff.ts diff --git a/packages/core/schema.json b/packages/core/schema.json index d0eeeebd5c41..3f07be0d88a5 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,10 @@ { "version": "7", "dialect": "sqlite", - "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad", - "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"], + "id": "f1de291b-614d-4058-9e1e-cbf1ab34af88", + "prevIds": [ + "f14a9b18-8207-487e-a3d3-227e629ba9ad" + ], "ddl": [ { "name": "workspace", @@ -12,6 +14,10 @@ "name": "data_migration", "entityType": "tables" }, + { + "name": "message_diff", + "entityType": "tables" + }, { "name": "account_state", "entityType": "tables" @@ -180,6 +186,26 @@ "entityType": "columns", "table": "data_migration" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "message_diff" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message_diff" + }, { "type": "integer", "notNull": false, @@ -1481,9 +1507,13 @@ "table": "session_share" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1492,9 +1522,28 @@ "table": "workspace" }, { - "columns": ["active_account_id"], + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_diff_message_id_message_id_fk", + "entityType": "fks", + "table": "message_diff" + }, + { + "columns": [ + "active_account_id" + ], "tableTo": "account", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1503,9 +1552,13 @@ "table": "account_state" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "tableTo": "event_sequence", - "columnsTo": ["aggregate_id"], + "columnsTo": [ + "aggregate_id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1514,9 +1567,13 @@ "table": "event" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1525,9 +1582,13 @@ "table": "permission" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1536,9 +1597,13 @@ "table": "project_directory" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1547,9 +1612,13 @@ "table": "message" }, { - "columns": ["message_id"], + "columns": [ + "message_id" + ], "tableTo": "message", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1558,9 +1627,13 @@ "table": "part" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1569,9 +1642,13 @@ "table": "session_context_epoch" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1580,9 +1657,13 @@ "table": "session_input" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1591,9 +1672,13 @@ "table": "session_message" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1602,9 +1687,13 @@ "table": "session" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1613,9 +1702,13 @@ "table": "todo" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1624,133 +1717,183 @@ "table": "session_share" }, { - "columns": ["email", "url"], + "columns": [ + "email", + "url" + ], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": ["project_id", "directory"], + "columns": [ + "project_id", + "directory" + ], "nameExplicit": false, "name": "project_directory_pk", "entityType": "pks", "table": "project_directory" }, { - "columns": ["session_id", "position"], + "columns": [ + "session_id", + "position" + ], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": ["name"], + "columns": [ + "name" + ], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "message_id" + ], + "nameExplicit": false, + "name": "message_diff_pk", + "table": "message_diff", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "credential_pk", "table": "credential", "entityType": "pks" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "event_pk", "table": "event", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_context_epoch_pk", "table": "session_context_epoch", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_input_pk", "table": "session_input", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", @@ -2068,4 +2211,4 @@ } ], "renames": [] -} +} \ No newline at end of file diff --git a/packages/core/src/database/message-diff.sql.ts b/packages/core/src/database/message-diff.sql.ts new file mode 100644 index 000000000000..7c4cbe7dbaca --- /dev/null +++ b/packages/core/src/database/message-diff.sql.ts @@ -0,0 +1,12 @@ +import { text, sqliteTable } from "drizzle-orm/sqlite-core" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import type { MessageID } from "../v1/session" +import { MessageTable } from "../session/sql" + +export const MessageDiffTable = sqliteTable("message_diff", { + message_id: text() + .$type() + .primaryKey() + .references(() => MessageTable.id, { onDelete: "cascade" }), + data: text({ mode: "json" }).notNull().$type>(), +}) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index e6ea4eaa1477..e1c0ce7148bf 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -40,5 +40,6 @@ export const migrations = ( import("./migration/20260622142730_simplify_session_context_epoch"), import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622202450_simplify_session_input"), + import("./migration/20260815091902_message_diff"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260815091902_message_diff.ts b/packages/core/src/database/migration/20260815091902_message_diff.ts new file mode 100644 index 000000000000..1f3b6dda66c0 --- /dev/null +++ b/packages/core/src/database/migration/20260815091902_message_diff.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260815091902_message_diff", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`message_diff\` ( + \`message_id\` text PRIMARY KEY, + \`data\` text NOT NULL, + CONSTRAINT \`fk_message_diff_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index ed60fde6c55f..18640ca1265e 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -23,6 +23,13 @@ export default { \`time_completed\` integer NOT NULL ); `) + yield* tx.run(` + CREATE TABLE \`message_diff\` ( + \`message_id\` text PRIMARY KEY, + \`data\` text NOT NULL, + CONSTRAINT \`fk_message_diff_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`account_state\` ( \`id\` integer PRIMARY KEY, diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 464ce2695a76..60e18855ade7 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -162,6 +162,32 @@ describe("DatabaseMigration", () => { ) }) + test("creates the message diff side table on a fresh database", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + + const columns = yield* db.all<{ name: string; pk: number }>(sql`PRAGMA table_info(message_diff)`) + expect(columns.map((column) => [column.name, column.pk])).toEqual([ + ["message_id", 1], + ["data", 0], + ]) + + const foreignKeys = yield* db.all<{ table: string; from: string; on_delete: string }>( + sql`SELECT "table", "from", "on_delete" FROM pragma_foreign_key_list('message_diff')`, + ) + expect(foreignKeys).toEqual([ + { + table: "message", + from: "message_id", + on_delete: "CASCADE", + }, + ]) + }), + ) + }) + test("rejects a non-empty database without a session table", async () => { await expect( run( From 181018cc6d114262dbdad204d91a07585e4b33b0 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:42:37 +0200 Subject: [PATCH 03/18] feat(core): store message diffs separately --- packages/core/src/session/message-diff.ts | 54 ++++++ .../core/test/session-message-diff.test.ts | 74 ++++++++ packages/opencode/src/session/summary.ts | 18 +- .../opencode/test/session/summary.test.ts | 171 ++++++++++++++++++ packages/schema/src/v1/session.ts | 13 +- 5 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/session/message-diff.ts create mode 100644 packages/core/test/session-message-diff.test.ts create mode 100644 packages/opencode/test/session/summary.test.ts diff --git a/packages/core/src/session/message-diff.ts b/packages/core/src/session/message-diff.ts new file mode 100644 index 000000000000..6fdd179651c8 --- /dev/null +++ b/packages/core/src/session/message-diff.ts @@ -0,0 +1,54 @@ +export * as MessageDiff from "./message-diff" + +import { eq } from "drizzle-orm" +import { Context, Effect, Layer } from "effect" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { Database } from "../database/database" +import { MessageDiffTable } from "../database/message-diff.sql" +import { makeGlobalNode } from "../effect/app-node" +import { MessageID } from "../v1/session" + +export interface Interface { + readonly put: (input: { + readonly messageID: MessageID + readonly diffs: ReadonlyArray + }) => Effect.Effect + readonly get: (messageID: MessageID) => Effect.Effect | undefined> +} + +export class Service extends Context.Service()("@opencode/v2/MessageDiff") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + + const put = Effect.fn("MessageDiff.put")(function* (input: { + readonly messageID: MessageID + readonly diffs: ReadonlyArray + }) { + const data = [...input.diffs] + yield* db + .insert(MessageDiffTable) + .values({ message_id: input.messageID, data }) + .onConflictDoUpdate({ target: MessageDiffTable.message_id, set: { data } }) + .run() + .pipe(Effect.orDie) + }) + + const get = Effect.fn("MessageDiff.get")(function* (messageID: MessageID) { + const row = yield* db + .select() + .from(MessageDiffTable) + .where(eq(MessageDiffTable.message_id, messageID)) + .get() + .pipe(Effect.orDie) + if (!row) return + return [...row.data] + }) + + return Service.of({ put, get }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/test/session-message-diff.test.ts b/packages/core/test/session-message-diff.test.ts new file mode 100644 index 000000000000..beb81b9d1241 --- /dev/null +++ b/packages/core/test/session-message-diff.test.ts @@ -0,0 +1,74 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq, sql } from "drizzle-orm" +import { Database } from "@opencode-ai/core/database/database" +import { MessageDiffTable } from "@opencode-ai/core/database/message-diff.sql" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionSchema } from "@opencode-ai/core/session/schema" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { MessageDiff } from "@opencode-ai/core/session/message-diff" +import { MessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { testEffect } from "./lib/effect" + +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, MessageDiff.node]))) +const sessionID = SessionSchema.ID.make("ses_message_diff_test") +const messageID = SessionV1.MessageID.make("msg_message_diff_test") +const first: ReadonlyArray = [ + { file: "first.ts", additions: 1, deletions: 2, patch: "first", status: "modified" }, +] +const second: ReadonlyArray = [ + { file: "second.ts", additions: 3, deletions: 4, patch: "second", status: "added" }, +] + +function setup() { + return Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + yield* db.run( + sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (${messageID}, ${sessionID}, ${0}, ${0}, ${"{}"})`, + ) + return db + }) +} + +describe("MessageDiff", () => { + it.effect("returns the latest array after an upsert", () => + Effect.gen(function* () { + yield* setup() + const diffs = yield* MessageDiff.Service + yield* diffs.put({ messageID, diffs: first }) + yield* diffs.put({ messageID, diffs: second }) + expect(yield* diffs.get(messageID)).toEqual(second) + }), + ) + + it.effect("removes a diff when its message is deleted", () => + Effect.gen(function* () { + const db = yield* setup() + yield* db.run("PRAGMA foreign_keys = ON") + const diffs = yield* MessageDiff.Service + yield* diffs.put({ messageID, diffs: first }) + yield* db.delete(MessageTable).where(eq(MessageTable.id, messageID)).run() + expect(yield* diffs.get(messageID)).toBeUndefined() + }), + ) +}) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 6484730d0bd5..e806686d43ce 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -1,6 +1,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer, Context, Schema } from "effect" import { SessionV1 } from "@opencode-ai/core/v1/session" +import { MessageDiff } from "@opencode-ai/core/session/message-diff" import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "@/snapshot" import { Session } from "./session" @@ -75,6 +76,7 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* Session.Service + const messageDiffs = yield* MessageDiff.Service const snapshot = yield* Snapshot.Service const events = yield* EventV2Bridge.Service const config = yield* Config.Service @@ -122,7 +124,17 @@ const layer = Layer.effect( const target = messages.find((m) => m.info.id === input.messageID) if (!target || target.info.role !== "user") return const msgDiffs = yield* computeDiff({ messages }) - target.info.summary = { ...target.info.summary, diffs: msgDiffs } + const totals = msgDiffs.reduce<{ additions: number; deletions: number; files: number }>( + (result, item) => ({ + additions: result.additions + item.additions, + deletions: result.deletions + item.deletions, + files: result.files + 1, + }), + { additions: 0, deletions: 0, files: 0 }, + ) + yield* messageDiffs.put({ messageID: input.messageID, diffs: msgDiffs }) + yield* sessions.setSummary({ sessionID: input.sessionID, summary: totals }) + target.info.summary = { ...target.info.summary, ...totals, diffs: msgDiffs } yield* sessions.updateMessage(target.info) }) @@ -132,7 +144,7 @@ const layer = Layer.effect( (item) => item.info.id === input.messageID, ) if (!message || message.info.role !== "user") return [] - const diffs = message.info.summary?.diffs ?? [] + const diffs = (yield* messageDiffs.get(input.messageID)) ?? message.info.summary?.diffs ?? [] return diffs.map((item) => { if (item.file === undefined) return item const file = unquoteGitPath(item.file) @@ -154,7 +166,7 @@ export type DiffInput = Schema.Schema.Type export const node = LayerNode.make({ service: Service, layer: layer, - deps: [Session.node, Snapshot.node, EventV2Bridge.node, Config.node], + deps: [Session.node, MessageDiff.node, Snapshot.node, EventV2Bridge.node, Config.node], }) export * as SessionSummary from "./summary" diff --git a/packages/opencode/test/session/summary.test.ts b/packages/opencode/test/session/summary.test.ts new file mode 100644 index 000000000000..af5311aa0ff3 --- /dev/null +++ b/packages/opencode/test/session/summary.test.ts @@ -0,0 +1,171 @@ +import { describe, expect } from "bun:test" +import { eq } from "drizzle-orm" +import { Effect, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { MessageDiffTable } from "@opencode-ai/core/database/message-diff.sql" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { MessageDiff } from "@opencode-ai/core/session/message-diff" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Snapshot } from "@/snapshot" +import { Session as SessionNs } from "@/session/session" +import { SessionSummary } from "@/session/summary" +import { MessageID, PartID, type SessionID } from "@/session/schema" +import { testEffect } from "../lib/effect" + +const sideTableDiffs: ReadonlyArray = [ + { file: "side.ts", additions: 3, deletions: 2, patch: "side", status: "modified" }, +] +const legacyDiffs: ReadonlyArray = [ + { file: "legacy.ts", additions: 1, deletions: 4, patch: "legacy", status: "deleted" }, +] + +const snapshot = Layer.succeed(Snapshot.Service, { + init: () => Effect.void, + cleanup: () => Effect.void, + track: () => Effect.succeed(undefined), + patch: () => Effect.succeed({ hash: "", files: [] }), + restore: () => Effect.void, + revert: () => Effect.void, + diff: () => Effect.succeed(""), + diffFull: () => Effect.succeed([...sideTableDiffs]), +}) + +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, MessageDiff.node, SessionNs.node, SessionProjector.node, SessionSummary.node]), + [[Snapshot.node, snapshot]], + ), +) + +const withSession = ( + fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.gen(function* () { + const session = yield* SessionNs.Service + const created = yield* session.create({}) + return { session, sessionID: created.id } + }), + fn, + (input) => input.session.remove(input.sessionID).pipe(Effect.ignore), + ) + +const addUser = Effect.fn("Test.addUser")(function* (input: { + sessionID: SessionID + diffs: ReadonlyArray +}) { + const session = yield* SessionNs.Service + const messageID = MessageID.ascending() + yield* session.updateMessage({ + id: messageID, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: "test", modelID: "test" }, + summary: { diffs: [...input.diffs] }, + tools: {}, + mode: "", + } as unknown as SessionV1.Info) + return messageID +}) + +const addSnapshots = Effect.fn("Test.addSnapshots")(function* (input: { sessionID: SessionID; messageID: MessageID }) { + const session = yield* SessionNs.Service + yield* session.updatePart({ + id: PartID.ascending(), + sessionID: input.sessionID, + messageID: input.messageID, + type: "step-start", + snapshot: "before", + }) + const assistantID = MessageID.ascending() + yield* session.updateMessage({ + id: assistantID, + sessionID: input.sessionID, + role: "assistant", + time: { created: Date.now() }, + parentID: input.messageID, + modelID: ModelV2.ID.make("test"), + providerID: ProviderV2.ID.make("test"), + mode: "", + agent: "test", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as unknown as SessionV1.Info) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID: input.sessionID, + messageID: assistantID, + type: "step-finish", + snapshot: "after", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) +}) + +describe("SessionSummary", () => { + it.instance("returns the stored side-table row", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const messageID = yield* addUser({ sessionID, diffs: legacyDiffs }) + const diffs = yield* MessageDiff.Service + yield* diffs.put({ messageID, diffs: sideTableDiffs }) + const summary = yield* SessionSummary.Service + expect(yield* summary.diff({ sessionID, messageID })).toEqual([...sideTableDiffs]) + }), + ), + ) + + it.instance("ignores conflicting inline diffs when the side-table row exists", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const messageID = yield* addUser({ sessionID, diffs: legacyDiffs }) + const diffs = yield* MessageDiff.Service + yield* diffs.put({ messageID, diffs: sideTableDiffs }) + const summary = yield* SessionSummary.Service + expect(yield* summary.diff({ sessionID, messageID })).toEqual([...sideTableDiffs]) + }), + ), + ) + + it.instance("falls back to inline diffs after the side-table row is deleted", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const messageID = yield* addUser({ sessionID, diffs: legacyDiffs }) + const diffs = yield* MessageDiff.Service + yield* diffs.put({ messageID, diffs: sideTableDiffs }) + const { db } = yield* Database.Service + yield* db.delete(MessageDiffTable).where(eq(MessageDiffTable.message_id, messageID)).run() + const summary = yield* SessionSummary.Service + expect(yield* summary.diff({ sessionID, messageID })).toEqual([...legacyDiffs]) + }), + ), + ) + + it.instance("keeps inline diffs and writes summary counts while dual-writing", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const messageID = yield* addUser({ sessionID, diffs: [] }) + yield* addSnapshots({ sessionID, messageID }) + const summary = yield* SessionSummary.Service + yield* summary.summarize({ sessionID, messageID }) + const session = yield* SessionNs.Service + const updated = (yield* session.messages({ sessionID })).find((item) => item.info.id === messageID) + expect(updated?.info.summary).toMatchObject({ + additions: 3, + deletions: 2, + files: 1, + diffs: sideTableDiffs, + }) + }), + ), + ) +}) diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 75e9282f117c..3e811244ceba 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -337,11 +337,14 @@ export const User = Schema.Struct({ }), format: Schema.optional(Format), summary: Schema.optional( - Schema.Struct({ - title: Schema.optional(Schema.String), - body: Schema.optional(Schema.String), - diffs: Schema.Array(FileDiff.Info), - }), + Schema.Struct({ + title: Schema.optional(Schema.String), + body: Schema.optional(Schema.String), + additions: optional(Schema.Finite), + deletions: optional(Schema.Finite), + files: optional(Schema.Finite), + diffs: Schema.Array(FileDiff.Info), + }), ), agent: Schema.String, model: Schema.Struct({ From 8fd36e79912be078b8b9ce79893546217e2f5482 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:53:40 +0200 Subject: [PATCH 04/18] test(opencode): separate side-table read from precedence coverage --- packages/opencode/test/session/summary.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/session/summary.test.ts b/packages/opencode/test/session/summary.test.ts index af5311aa0ff3..387f1f0f1e3f 100644 --- a/packages/opencode/test/session/summary.test.ts +++ b/packages/opencode/test/session/summary.test.ts @@ -115,7 +115,7 @@ describe("SessionSummary", () => { it.instance("returns the stored side-table row", () => withSession(({ sessionID }) => Effect.gen(function* () { - const messageID = yield* addUser({ sessionID, diffs: legacyDiffs }) + const messageID = yield* addUser({ sessionID, diffs: [] }) const diffs = yield* MessageDiff.Service yield* diffs.put({ messageID, diffs: sideTableDiffs }) const summary = yield* SessionSummary.Service From a63ff3d4d7e2508e92705627744cd011d7c6960b Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:12:30 +0200 Subject: [PATCH 05/18] feat(app): fetch changed message diffs --- .../src/context/global-sync/bootstrap.test.ts | 1 + .../src/context/global-sync/child-store.ts | 1 + packages/app/src/context/global-sync/types.ts | 3 + .../app/src/context/server-session.test.ts | 139 +++++++++++++++++- packages/app/src/context/server-session.ts | 97 +++++++++++- packages/app/src/utils/diffs.ts | 16 +- packages/opencode/src/session/session.ts | 1 + packages/opencode/src/session/summary.ts | 9 +- packages/schema/src/v1/session.ts | 9 ++ packages/schema/test/event-manifest.test.ts | 12 +- packages/sdk/js/src/v2/gen/types.gen.ts | 56 +++++++ packages/session-ui/src/context/data.tsx | 3 + 12 files changed, 335 insertions(+), 12 deletions(-) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index a95706a3dbe5..14e6fe6a0ae2 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -58,6 +58,7 @@ function directoryState() { return this.session_status[id]?.type !== "idle" }, session_diff: {}, + message_diff: {}, todo: {}, permission: {}, question: {}, diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 4eaa785789fc..b5af3dbebd7e 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -234,6 +234,7 @@ export function createChildStoreManager(input: { return (type ?? "idle") !== "idle" }, session_diff: {}, + message_diff: {}, todo: {}, permission: {}, question: {}, diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 74191e10b079..aefb45689e6c 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -51,6 +51,9 @@ export type State = { session_diff: { [sessionID: string]: FileDiffInfo[] } + message_diff: { + [messageID: string]: FileDiffInfo[] | undefined + } todo: { [sessionID: string]: Todo[] } diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 2ebf5f88ef0b..9ec25e8a56e8 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { retry } from "@opencode-ai/core/util/retry" -import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise" +import type { FileDiffInfo, OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise" import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" import { createServerSession } from "./server-session" import type { ServerApi } from "@/utils/server" @@ -26,6 +26,15 @@ type MessageResponse = { response: { headers: Headers } } type SingleMessageResponse = { data: MessageResponse["data"][number] } +type DiffResponse = { data: FileDiffInfo[] } + +const fetchedDiff: FileDiffInfo = { + file: "fetched.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + additions: 1, + deletions: 1, + status: "modified", +} const userMessage = (id: string, input: Partial = {}): UserMessage => ({ id, @@ -70,10 +79,12 @@ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageR const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } }) const deferredResponse = () => Promise.withResolvers() +const deferredDiffResponse = () => Promise.withResolvers() function messageClient(...responses: Array>) { let index = 0 const requests: unknown[] = [] + const diffRequests: unknown[] = [] const waiting = new Map void>() const client = { session: { @@ -84,6 +95,35 @@ function messageClient(...responses: Array { + diffRequests.push(input) + return { data: [fetchedDiff] } + }, + }, + } as unknown as OpencodeClient + return Object.assign(client, { + requests, + diffRequests, + requested(count: number) { + if (requests.length >= count) return Promise.resolve() + return new Promise((resolve) => waiting.set(count, resolve)) + }, + }) +} + +function pendingDiffClient(diff: Promise) { + const requests: unknown[] = [] + const waiting = new Map void>() + const client = { + session: { + get: async () => ({ data: session("child", "root") }), + messages: async () => response(), + diff: (input: unknown) => { + requests.push(input) + waiting.get(requests.length)?.() + waiting.delete(requests.length) + return diff + }, }, } as unknown as OpencodeClient return Object.assign(client, { @@ -344,6 +384,103 @@ describe("server session", () => { expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) }) + test("seeds inline message diffs when reopening without notifications", async () => { + const legacyDiff: FileDiffInfo = { + file: "legacy.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + additions: 1, + deletions: 1, + status: "modified", + } + const user = userMessage("msg_user", { + summary: { additions: 1, deletions: 1, files: 1, diffs: [legacyDiff] } as UserMessage["summary"], + }) + const client = messageClient(response([{ info: user, parts: [] }])) + const store = createServerSession(client) + + await store.sync("child") + + expect(store.data.message_diff[user.id]).toEqual([legacyDiff]) + expect(client.diffRequests).toEqual([]) + }) + + test("fetches diffs for loaded user message count metadata", async () => { + const user = userMessage("msg_user", { + summary: { additions: 1, deletions: 1, files: 1 } as UserMessage["summary"], + }) + const client = messageClient(response([{ info: user, parts: [] }])) + const store = createServerSession(client) + + await store.sync("child") + + expect(client.diffRequests).toEqual([{ sessionID: "child", messageID: user.id }]) + expect(store.data.message_diff[user.id]).toEqual([fetchedDiff]) + }) + + test("refreshes message diffs from a deduplicated update without clearing the previous cache", async () => { + const next: FileDiffInfo = { + file: "updated.ts", + patch: "@@ -1 +1 @@\n-before\n+after", + additions: 2, + deletions: 1, + status: "modified", + } + const pending = deferredDiffResponse() + const client = pendingDiffClient(pending.promise) + const store = createServerSession(client) + const user = userMessage("msg_user", { + summary: { additions: 1, deletions: 1, files: 1 } as UserMessage["summary"], + }) + store.set("message", "child", [user]) + store.set("message_diff", user.id, [fetchedDiff]) + + store.apply({ type: "message.diff.updated", properties: { sessionID: "child", messageID: user.id } }) + store.apply({ type: "message.diff.updated", properties: { sessionID: "child", messageID: user.id } }) + await client.requested(1) + + expect(client.requests).toEqual([{ sessionID: "child", messageID: user.id }]) + expect(store.data.message_diff[user.id]).toEqual([fetchedDiff]) + + pending.resolve({ data: [next] }) + await Promise.resolve() + + expect(store.data.message_diff[user.id]).toEqual([next]) + }) + + test("drops cached message diffs when its message is removed", () => { + const user = userMessage("msg_user") + const store = setup({ child: session("child") }).store + store.set("message", "child", [user]) + store.set("message_diff", user.id, [fetchedDiff]) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: user.id } }) + + expect(store.data.message_diff[user.id]).toBeUndefined() + }) + + test("drops cached message diffs when its session is evicted", () => { + const user = userMessage("msg_user") + const store = setup({ child: session("child") }).store + store.set("message", "child", [user]) + store.set("message_diff", user.id, [fetchedDiff]) + + store.apply({ type: "session.deleted", properties: { sessionID: "child" } }) + + expect(store.data.message_diff[user.id]).toBeUndefined() + }) + + test("drops cached message diffs when a refresh removes their message", async () => { + const user = userMessage("msg_user") + const client = messageClient(response([{ info: user, parts: [] }]), response()) + const store = createServerSession(client) + await store.sync("child") + store.set("message_diff", user.id, [fetchedDiff]) + + await store.sync("child", { force: true }) + + expect(store.data.message_diff[user.id]).toBeUndefined() + }) + test("backfills an assistant-only initial page through its user root", async () => { const user = userMessage("message-1") const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index c5d98682f03c..84408e8966e4 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -43,6 +43,29 @@ function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { return boundary?.type === "assistant" } +function normalizeMessageDiffs( + source: readonly { + file?: string + patch?: string + additions: number + deletions: number + status?: FileDiffInfo["status"] + }[], +) { + return source.flatMap((diff) => { + if (diff.file === undefined || diff.patch === undefined) return [] + return [ + { + file: diff.file, + patch: diff.patch, + additions: diff.additions, + deletions: diff.deletions, + status: diff.status ?? "modified", + }, + ] + }) +} + type OptimisticItem = { message: Message parts: Part[] @@ -197,6 +220,7 @@ export function createServerSession( info: {} as Record, session_status: {} as Record, session_diff: {} as Record, + message_diff: {} as Record, todo: {} as Record, permission: {} as Record, question: {} as Record, @@ -211,6 +235,9 @@ export function createServerSession( const requests = new Map>() const inflight = new Map>() const inflightTodo = new Map>() + const inflightMessageDiff = new Map>() + const messageDiffGenerations = new Map() + const messageDiffSessions = new Map() const optimistic = new Map>() const v2 = createV2SessionReducer() const messageLoads = new Map() @@ -239,6 +266,47 @@ export function createServerSession( generations.set(sessionID, created) return created } + const clearMessageDiff = (messageID: string) => { + messageDiffGenerations.delete(messageID) + messageDiffSessions.delete(messageID) + inflightMessageDiff.delete(messageID) + setData( + produce((draft) => { + delete draft.message_diff[messageID] + }), + ) + } + const seedMessageDiff = (message: Message) => { + if (message.role !== "user") return + const diffs = message.summary?.diffs + if (diffs !== undefined && (diffs.length > 0 || message.summary?.files === undefined || message.summary.files === 0)) { + messageDiffGenerations.set(message.id, {}) + messageDiffSessions.set(message.id, message.sessionID) + setData("message_diff", message.id, normalizeMessageDiffs(diffs)) + return + } + if (message.summary?.files === undefined) return + void fetchMessageDiff(message.sessionID, message.id) + } + const fetchMessageDiff = (sessionID: string, messageID: string) => { + const active = messageDiffGenerations.get(messageID) ?? {} + messageDiffGenerations.set(messageID, active) + messageDiffSessions.set(messageID, sessionID) + return runInflight(inflightMessageDiff, messageID, async () => { + try { + const response = await client.session.diff({ sessionID, messageID }) + if (messageDiffGenerations.get(messageID) !== active) return + setData("message_diff", messageID, normalizeMessageDiffs(response.data ?? [])) + } catch (error) { + if (import.meta.env.DEV) + console.debug("[session] failed to fetch message diff", { + sessionID, + messageID, + error: String(error).slice(0, 256), + }) + } + }) + } const [meta, setMeta] = createStore({ limit: {} as Record, cursor: {} as Record, @@ -494,6 +562,18 @@ export function createServerSession( }) setData( produce((draft) => { + const messageIDs = new Set(messageDiffSessions.keys()) + for (const sessionID of evicted) { + for (const message of draft.message[sessionID] ?? []) messageIDs.add(message.id) + } + for (const messageID of messageIDs) { + const sessionID = messageDiffSessions.get(messageID) + if (sessionID && !evicted.has(sessionID)) continue + messageDiffGenerations.delete(messageID) + messageDiffSessions.delete(messageID) + inflightMessageDiff.delete(messageID) + delete draft.message_diff[messageID] + } dropSessionCaches(draft, sessionIDs) }), ) @@ -609,7 +689,13 @@ export function createServerSession( setData("message", sessionID, reconcile(messages, { key: "id" })) setData( produce((draft) => { - for (const message of dropped) deleteMessageParts(draft, message.id) + for (const message of dropped) { + deleteMessageParts(draft, message.id) + delete draft.message_diff[message.id] + messageDiffGenerations.delete(message.id) + messageDiffSessions.delete(message.id) + inflightMessageDiff.delete(message.id) + } }), ) return messageIDs @@ -715,6 +801,7 @@ export function createServerSession( batch(() => { if (source) setData("session_message", sessionID, reconcile(source)) const messageIDs = replaceMessages(sessionID, messages) + merged.session.forEach(seedMessageDiff) replaceParts(sessionID, merged.part, messageIDs, load) const orphans = orphanParts.get(sessionID) if (cleanupOrphans && page.complete && orphans) { @@ -1048,6 +1135,7 @@ export function createServerSession( const messages = data.message[info.sessionID] if (!messages) { setData("message", info.sessionID, [info]) + seedMessageDiff(info) return } const result = Binary.search(messages, messageKey(info), messageKey) @@ -1058,6 +1146,7 @@ export function createServerSession( next.splice(result.index, 0, info) return next }) + seedMessageDiff(info) return } case "message.removed": { @@ -1079,6 +1168,7 @@ export function createServerSession( removedMessagesForSession.add(props.messageID) removedMessages.set(props.sessionID, removedMessagesForSession) clearOptimistic(props.sessionID, props.messageID) + clearMessageDiff(props.messageID) setData( produce((draft) => { const messages = draft.message[props.sessionID] @@ -1091,6 +1181,11 @@ export function createServerSession( ) return } + case "message.diff.updated": { + const props = event.properties as { sessionID: string; messageID: string } + void fetchMessageDiff(props.sessionID, props.messageID) + return + } case "message.part.updated": { const part = (event.properties as { part: Part }).part if (SKIP_PARTS.has(part.type)) return diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index a8eec75a9af3..c4c2c00eb7d8 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -35,15 +35,29 @@ export function message(value: Message): Message { const title = typeof raw.title === "string" ? raw.title : undefined const body = typeof raw.body === "string" ? raw.body : undefined + const additions = typeof raw.additions === "number" ? raw.additions : undefined + const deletions = typeof raw.deletions === "number" ? raw.deletions : undefined + const files = typeof raw.files === "number" ? raw.files : undefined const next = diffs(raw.diffs) - if (title === raw.title && body === raw.body && next === raw.diffs) return value + if ( + title === raw.title && + body === raw.body && + additions === raw.additions && + deletions === raw.deletions && + files === raw.files && + next === raw.diffs + ) + return value return { ...value, summary: { ...(title === undefined ? {} : { title }), ...(body === undefined ? {} : { body }), + ...(additions === undefined ? {} : { additions }), + ...(deletions === undefined ? {} : { deletions }), + ...(files === undefined ? {} : { files }), diffs: next, }, } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a2a91cd47b5e..50eb21473d26 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -325,6 +325,7 @@ export const Event = { Updated: SessionV1.Event.Updated, Deleted: SessionV1.Event.Deleted, Diff: SessionV1.Event.Diff, + DiffUpdated: SessionV1.Event.DiffUpdated, Error: SessionV1.Event.Error, } diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index e806686d43ce..4f6959c8a8a0 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -133,10 +133,11 @@ const layer = Layer.effect( { additions: 0, deletions: 0, files: 0 }, ) yield* messageDiffs.put({ messageID: input.messageID, diffs: msgDiffs }) - yield* sessions.setSummary({ sessionID: input.sessionID, summary: totals }) - target.info.summary = { ...target.info.summary, ...totals, diffs: msgDiffs } - yield* sessions.updateMessage(target.info) - }) + yield* sessions.setSummary({ sessionID: input.sessionID, summary: totals }) + target.info.summary = { ...target.info.summary, ...totals, diffs: msgDiffs } + yield* sessions.updateMessage(target.info) + yield* events.publish(Session.Event.DiffUpdated, { sessionID: input.sessionID, messageID: input.messageID }) + }) const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { if (!input.messageID) return [] diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 3e811244ceba..eaea50885f21 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -612,6 +612,14 @@ const events = { messageID: MessageID, }, }), + DiffUpdated: define({ + type: "message.diff.updated", + ...options, + schema: { + sessionID: SessionID, + messageID: MessageID, + }, + }), PartUpdated: define({ type: "message.part.updated", ...options, @@ -670,6 +678,7 @@ export const Event = { events.Deleted, events.MessageUpdated, events.MessageRemoved, + events.DiffUpdated, events.PartUpdated, events.PartRemoved, PartDelta, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 5694afdd30df..c0812ccc4630 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -9,22 +9,23 @@ import { WorkspaceEvent } from "../src/workspace-event" describe("public event manifest", () => { test("owns the complete public event surface", () => { - expect(EventManifest.ServerDefinitions.length).toBe(55) - expect(EventManifest.Definitions.length).toBe(85) + expect(EventManifest.ServerDefinitions.length).toBe(59) + expect(EventManifest.Definitions.length).toBe(89) expect(SessionV1.Event.Definitions).toEqual([ SessionV1.Event.Created, SessionV1.Event.Updated, SessionV1.Event.Deleted, SessionV1.Event.MessageUpdated, SessionV1.Event.MessageRemoved, + SessionV1.Event.DiffUpdated, SessionV1.Event.PartUpdated, SessionV1.Event.PartRemoved, SessionV1.Event.PartDelta, SessionV1.Event.Diff, SessionV1.Event.Error, ]) - expect(EventManifest.Latest.size).toBe(85) - expect(EventManifest.Durable.size).toBe(32) + expect(EventManifest.Latest.size).toBe(89) + expect(EventManifest.Durable.size).toBe(36) }) test("uses canonical definitions for current public events", () => { @@ -42,11 +43,12 @@ describe("public event manifest", () => { expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) - expect(EventManifest.Definitions.slice(40, 43)).toEqual([ + expect(EventManifest.Definitions.slice(44, 47)).toEqual([ SessionV1.Event.PartDelta, SessionV1.Event.Diff, SessionV1.Event.Error, ]) + expect(EventManifest.Latest.get("message.diff.updated")).toBe(SessionV1.Event.DiffUpdated) expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 72b5e6f30ace..e09b800c6b57 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -14,6 +14,7 @@ export type Event = | EventSessionDeleted | EventMessageUpdated | EventMessageRemoved + | EventMessageDiffUpdated | EventMessagePartUpdated | EventMessagePartRemoved | EventSessionNextAgentSwitched @@ -247,6 +248,9 @@ export type UserMessage = { summary?: { title?: string body?: string + additions?: number + deletions?: number + files?: number diffs: Array } agent: string @@ -800,6 +804,14 @@ export type GlobalEvent = { messageID: string } } + | { + id: string + type: "message.diff.updated" + properties: { + sessionID: string + messageID: string + } + } | { id: string type: "message.part.updated" @@ -1606,6 +1618,7 @@ export type GlobalEvent = { | SyncEventSessionDeleted | SyncEventMessageUpdated | SyncEventMessageRemoved + | SyncEventMessageDiffUpdated | SyncEventMessagePartUpdated | SyncEventMessagePartRemoved | SyncEventSessionNextAgentSwitched @@ -2862,6 +2875,7 @@ export type V2Event = | SessionDeleted | MessageUpdated | MessageRemoved + | MessageDiffUpdated | MessagePartUpdated | MessagePartRemoved | SessionNextAgentSwitched @@ -3266,6 +3280,21 @@ export type SyncEventMessageRemoved = { } } +export type SyncEventMessageDiffUpdated = { + type: "sync" + id: string + syncEvent: { + type: "message.diff.updated.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + messageID: string + } + } +} + export type SyncEventMessagePartUpdated = { type: "sync" id: string @@ -5179,6 +5208,24 @@ export type MessageRemoved = { } } +export type MessageDiffUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.diff.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + } +} + export type MessagePartUpdated = { id: string metadata?: { @@ -6228,6 +6275,15 @@ export type EventMessageRemoved = { } } +export type EventMessageDiffUpdated = { + id: string + type: "message.diff.updated" + properties: { + sessionID: string + messageID: string + } +} + export type EventMessagePartUpdated = { id: string type: "message.part.updated" diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 6bede42bfda7..1db8ed92bdcf 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -28,6 +28,9 @@ type Data = { session_diff: { [sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[] } + message_diff: { + [messageID: string]: FileDiffInfo[] | undefined + } session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] } From c736a8d12c8940b971e992c8bf41af82eca5ec55 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:13:38 +0200 Subject: [PATCH 06/18] feat(app): resolve expanded diff patches from the side table --- .../regression/message-diff-expansion.spec.ts | 46 ++++++++ packages/app/src/context/directory-sync.ts | 2 + packages/app/src/context/server-session.ts | 1 + packages/app/src/pages/directory-layout.tsx | 1 + .../session/timeline/message-timeline.tsx | 43 ++++++-- packages/app/src/utils/diffs.test.ts | 13 ++- packages/app/src/utils/diffs.ts | 2 +- .../src/components/message-diff.test.ts | 101 ++++++++++++++++++ .../session-ui/src/components/session-diff.ts | 21 ++++ .../src/components/session-turn.tsx | 37 +++++-- packages/session-ui/src/context/data.tsx | 2 + 11 files changed, 247 insertions(+), 22 deletions(-) create mode 100644 packages/app/e2e/regression/message-diff-expansion.spec.ts create mode 100644 packages/session-ui/src/components/message-diff.test.ts diff --git a/packages/app/e2e/regression/message-diff-expansion.spec.ts b/packages/app/e2e/regression/message-diff-expansion.spec.ts new file mode 100644 index 000000000000..09d361274e8f --- /dev/null +++ b/packages/app/e2e/regression/message-diff-expansion.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "@playwright/test" +import { sessionID, setupTimeline, userMessage } from "../performance/timeline-stability/fixture" + +test("loads a patch only when an inline summary diff is expanded", async ({ page }) => { + let requests = 0 + const diffRequests: string[] = [] + page.on("request", (request) => { + if (request.url().includes("diff")) diffRequests.push(request.url()) + }) + await setupTimeline(page, { + messages: [ + userMessage(undefined, { + summary: { + additions: 1, + deletions: 1, + files: 1, + diffs: [{ file: "src/changed.ts", additions: 1, deletions: 1, status: "modified" }], + }, + }), + ], + }) + await page.route("**/*", async (route) => { + if (!new URL(route.request().url()).pathname.endsWith(`/session/${sessionID}/diff`)) return route.fallback() + requests++ + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify([ + { + file: "src/changed.ts", + patch: "@@ -1 +1 @@\n-before\n+from side table\n", + additions: 1, + deletions: 1, + status: "modified", + }, + ]), + }) + }) + + const trigger = page.locator('[data-slot="session-turn-diff-trigger"]').first() + await expect(trigger).toBeVisible() + expect(requests).toBe(0) + + await trigger.click() + await expect.poll(() => ({ requests, diffRequests })).toEqual({ requests: 1, diffRequests: expect.any(Array) }) + await expect(page.getByText("from side table", { exact: true })).toBeVisible() +}) diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index befd5b61e095..dde43920f64a 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -12,6 +12,7 @@ const sessionFields = new Set([ "session_status", "session_working", "session_diff", + "message_diff", "todo", "permission", "question", @@ -118,6 +119,7 @@ export const createDirSyncContext = ( }, todo: serverSync.session.todo, history: serverSync.session.history, + fetchMessageDiff: serverSync.session.fetchMessageDiff, evict(sessionID: string) { serverSync.session.evict(sessionID) }, diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 84408e8966e4..e3c95d9a153b 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -1395,6 +1395,7 @@ export function createServerSession( peek: (sessionID: string) => data.info[sessionID], remember, resolve, + fetchMessageDiff, lineage: { peek: peekLineage, async resolve(sessionID: string) { diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index a0b357639655..4f2bd75d8188 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -66,6 +66,7 @@ export function DirectoryDataProvider( sessionID={params.id} onNavigateToSession={(sessionID: string) => navigate(href(sessionID))} onSessionHref={href} + fetchMessageDiff={sync().session.fetchMessageDiff} > {props.children} diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 18a153d29971..6c5627375ac8 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -52,11 +52,12 @@ import type { ToolPart, UserMessage, } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { showToast } from "@/utils/toast" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" -import { normalize } from "@opencode-ai/session-ui/session-diff" +import { expandMessageDiff, normalize, resolveMessageDiff } from "@opencode-ai/session-ui/session-diff" import { useFileComponent } from "@opencode-ai/ui/context/file" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" @@ -142,8 +143,9 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu ) } -function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { +function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; sessionID: string; messageID: string }) { const language = useLanguage() + const sync = useSync() const maxFiles = 10 const [state, setState] = createStore({ showAll: false, @@ -176,7 +178,20 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { multiple style={{ "--sticky-accordion-offset": "44px" }} value={expanded()} - onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])} + onChange={(value) => { + const next = Array.isArray(value) ? value : value ? [value] : [] + setState("expanded", next) + for (const diff of visible()) { + if (!next.includes(diff.file)) continue + void expandMessageDiff({ + diff, + cache: sync().data.message_diff[props.messageID], + sessionID: props.sessionID, + messageID: props.messageID, + fetch: sync().session.fetchMessageDiff, + }) + } + }} > {(diff) => { @@ -206,7 +221,7 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { - + @@ -224,14 +239,18 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { ) } -function TimelineDiffView(props: { diff: SummaryDiff }) { +function TimelineDiffView(props: { diff: SummaryDiff; cache?: FileDiffInfo[] }) { const fileComponent = useFileComponent() - const view = normalize(props.diff) + const source = createMemo(() => resolveMessageDiff(props.diff, props.cache)) + const view = createMemo(() => normalize(source())) + const loaded = createMemo(() => typeof source().patch === "string") return ( -
- -
+ +
+ +
+
) } @@ -1205,7 +1224,11 @@ export function MessageTimeline(props: { return (
- +
) diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts index a3d25f427959..865ce1c2b35a 100644 --- a/packages/app/src/utils/diffs.test.ts +++ b/packages/app/src/utils/diffs.test.ts @@ -12,6 +12,13 @@ const item = { status: "modified", } satisfies FileDiffInfo & SnapshotFileDiff +const summary = { + file: "src/summary.ts", + additions: 1, + deletions: 1, + status: "modified", +} satisfies SnapshotFileDiff + describe("diffs", () => { test("keeps valid arrays", () => { expect(diffs([item])).toEqual([item]) @@ -25,11 +32,15 @@ describe("diffs", () => { expect(diffs({ a: item })).toEqual([item]) }) + test("keeps patch-less summary entries", () => { + expect(diffs([summary])).toEqual([summary]) + }) + test("drops invalid entries", () => { expect( diffs([ item, - { file: "src/bad.ts", additions: 1, deletions: 1 }, + { file: "src/bad.ts", additions: 1, deletions: 1, status: "invalid" }, { patch: item.patch, additions: 1, deletions: 1 }, ]), ).toEqual([item]) diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index c4c2c00eb7d8..82bfae8efebf 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -7,7 +7,7 @@ type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff function diff(value: unknown): value is Diff { if (!value || typeof value !== "object" || Array.isArray(value)) return false if (!("file" in value) || typeof value.file !== "string") return false - if (!("patch" in value) || typeof value.patch !== "string") return false + if ("patch" in value && typeof value.patch !== "string") return false if (!("additions" in value) || typeof value.additions !== "number") return false if (!("deletions" in value) || typeof value.deletions !== "number") return false if (!("status" in value) || value.status === undefined) return true diff --git a/packages/session-ui/src/components/message-diff.test.ts b/packages/session-ui/src/components/message-diff.test.ts new file mode 100644 index 000000000000..436fb2919672 --- /dev/null +++ b/packages/session-ui/src/components/message-diff.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test" +import { normalize, text } from "./session-diff" +import * as SessionDiff from "./session-diff" + +type Diff = { + file: string + patch?: string + additions: number + deletions: number + status: "added" | "deleted" | "modified" +} + +type ExpandMessageDiff = (input: { + diff: Diff + cache?: Diff[] + sessionID: string + messageID: string + fetch: (sessionID: string, messageID: string) => Promise +}) => Diff + +const summary: Diff = { + file: "changed.ts", + additions: 1, + deletions: 1, + status: "modified", +} + +const sideTable: Diff = { + ...summary, + patch: "@@ -1 +1 @@\n-before\n+from side table\n", +} + +const legacy: Diff = { + ...summary, + patch: "@@ -1 +1 @@\n-before\n+from legacy payload\n", +} + +const expand = () => (SessionDiff as typeof SessionDiff & { expandMessageDiff?: ExpandMessageDiff }).expandMessageDiff + +describe("message diff expansion", () => { + test("uses the side-table patch before an inline summary without one", () => { + let requests = 0 + const diff = expand()?.({ + diff: summary, + cache: [sideTable], + sessionID: "session-1", + messageID: "message-1", + fetch: async () => { + requests++ + }, + }) ?? summary + + expect(text(normalize(diff), "additions")).toBe("from side table\n") + expect(requests).toBe(0) + }) + + test("keeps a legacy inline patch when the side table has no patch", () => { + let requests = 0 + const diff = expand()?.({ + diff: legacy, + cache: [], + sessionID: "session-1", + messageID: "message-1", + fetch: async () => { + requests++ + }, + }) ?? legacy + + expect(text(normalize(diff), "additions")).toBe("from legacy payload\n") + expect(requests).toBe(0) + }) + + test("fetches an uncached patch once and reads it from the cache on the next expansion", async () => { + let cache: Diff[] = [] + let requests = 0 + const fetch = async () => { + requests++ + cache = [sideTable] + } + + const first = expand()?.({ + diff: summary, + cache, + sessionID: "session-1", + messageID: "message-1", + fetch, + }) ?? summary + await Promise.resolve() + const second = expand()?.({ + diff: summary, + cache, + sessionID: "session-1", + messageID: "message-1", + fetch, + }) ?? summary + + expect(first.patch).toBeUndefined() + expect(requests).toBe(1) + expect(text(normalize(second), "additions")).toBe("from side table\n") + }) +}) diff --git a/packages/session-ui/src/components/session-diff.ts b/packages/session-ui/src/components/session-diff.ts index 2fbd022235f9..412c8daf04ff 100644 --- a/packages/session-ui/src/components/session-diff.ts +++ b/packages/session-ui/src/components/session-diff.ts @@ -17,6 +17,8 @@ type SnapshotDiff = SnapshotFileDiff & { file: string } type ReviewDiff = SnapshotDiff | FileDiffInfo | VcsFileDiff | LegacyDiff export type DiffSource = Pick +type MessageDiffSource = Pick + export type ViewDiff = { file: string additions: number @@ -37,6 +39,25 @@ export function resolveFileDiff(diff: DiffSource) { ) } +export function resolveMessageDiff(diff: T, cache?: readonly MessageDiffSource[]) { + const patch = cache?.find((item) => item.file === diff.file)?.patch + if (typeof patch !== "string") return diff + return { ...diff, patch } +} + +export function expandMessageDiff(input: { + diff: T + cache?: readonly MessageDiffSource[] + sessionID: string + messageID: string + fetch?: (sessionID: string, messageID: string) => Promise +}) { + const diff = resolveMessageDiff(input.diff, input.cache) + if (typeof diff.patch === "string") return diff + void input.fetch?.(input.sessionID, input.messageID) + return diff +} + export function normalize(diff: ReviewDiff): ViewDiff { return { file: diff.file, diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index ce5f0b545ee8..7b9e0c694332 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -25,7 +25,7 @@ import { SessionRetry } from "./session-retry" import { TextReveal } from "@opencode-ai/ui/text-reveal" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { useI18n } from "@opencode-ai/ui/context/i18n" -import { normalize } from "./session-diff" +import { expandMessageDiff, normalize, resolveMessageDiff } from "./session-diff" function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) @@ -451,15 +451,30 @@ export function SessionTurn(
- setState("expanded", Array.isArray(value) ? value : value ? [value] : [])} + { + const next = Array.isArray(value) ? value : value ? [value] : [] + setState("expanded", next) + for (const diff of visible()) { + if (!next.includes(diff.file)) continue + void expandMessageDiff({ + diff, + cache: data.store.message_diff[props.messageID], + sessionID: props.sessionID, + messageID: props.messageID, + fetch: data.fetchMessageDiff, + }) + } + }} > {(diff) => { - const view = normalize(diff) + const source = createMemo(() => resolveMessageDiff(diff, data.store.message_diff[props.messageID])) + const view = createMemo(() => normalize(source())) + const loaded = createMemo(() => typeof source().patch === "string") const active = createMemo(() => expanded().includes(diff.file)) const [shown, setShown] = createSignal(false) @@ -507,9 +522,11 @@ export function SessionTurn( -
- -
+ +
+ +
+
diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 1db8ed92bdcf..83ed895b25dc 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -57,6 +57,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ sessionID?: string onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn + fetchMessageDiff?: (sessionID: string, messageID: string) => Promise }) => { return { get store() { @@ -70,6 +71,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, + fetchMessageDiff: props.fetchMessageDiff, } }, }) From c4cc8726eecf81998901cc444b20824191a00cdf Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:20:13 +0200 Subject: [PATCH 07/18] fix(schema): make message.diff.updated ephemeral --- packages/schema/src/v1/session.ts | 5 +- packages/schema/test/event-manifest.test.ts | 4 +- packages/sdk/js/src/v2/gen/types.gen.ts | 90 +++++++++------------ 3 files changed, 43 insertions(+), 56 deletions(-) diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index eaea50885f21..440cff94a483 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -612,9 +612,12 @@ const events = { messageID: MessageID, }, }), + // Ephemeral: the message_diff side table holds the state, so a missed + // notification costs a stale row until the next fetch. Durable would make + // readAfter's decode throw InvalidDurableEventError on any reader whose + // manifest predates this event. DiffUpdated: define({ type: "message.diff.updated", - ...options, schema: { sessionID: SessionID, messageID: MessageID, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index c0812ccc4630..0a543f631fb2 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -9,7 +9,7 @@ import { WorkspaceEvent } from "../src/workspace-event" describe("public event manifest", () => { test("owns the complete public event surface", () => { - expect(EventManifest.ServerDefinitions.length).toBe(59) + expect(EventManifest.ServerDefinitions.length).toBe(58) expect(EventManifest.Definitions.length).toBe(89) expect(SessionV1.Event.Definitions).toEqual([ SessionV1.Event.Created, @@ -25,7 +25,7 @@ describe("public event manifest", () => { SessionV1.Event.Error, ]) expect(EventManifest.Latest.size).toBe(89) - expect(EventManifest.Durable.size).toBe(36) + expect(EventManifest.Durable.size).toBe(35) }) test("uses canonical definitions for current public events", () => { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e09b800c6b57..49c4bda0c5c8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -14,7 +14,6 @@ export type Event = | EventSessionDeleted | EventMessageUpdated | EventMessageRemoved - | EventMessageDiffUpdated | EventMessagePartUpdated | EventMessagePartRemoved | EventSessionNextAgentSwitched @@ -49,6 +48,7 @@ export type Event = | EventSessionNextRevertStaged | EventSessionNextRevertCleared | EventSessionNextRevertCommitted + | EventMessageDiffUpdated | EventMessagePartDelta | EventSessionDiff | EventSessionError @@ -804,14 +804,6 @@ export type GlobalEvent = { messageID: string } } - | { - id: string - type: "message.diff.updated" - properties: { - sessionID: string - messageID: string - } - } | { id: string type: "message.part.updated" @@ -1202,6 +1194,14 @@ export type GlobalEvent = { messageID: string } } + | { + id: string + type: "message.diff.updated" + properties: { + sessionID: string + messageID: string + } + } | { id: string type: "message.part.delta" @@ -1618,7 +1618,6 @@ export type GlobalEvent = { | SyncEventSessionDeleted | SyncEventMessageUpdated | SyncEventMessageRemoved - | SyncEventMessageDiffUpdated | SyncEventMessagePartUpdated | SyncEventMessagePartRemoved | SyncEventSessionNextAgentSwitched @@ -2875,7 +2874,6 @@ export type V2Event = | SessionDeleted | MessageUpdated | MessageRemoved - | MessageDiffUpdated | MessagePartUpdated | MessagePartRemoved | SessionNextAgentSwitched @@ -2910,6 +2908,7 @@ export type V2Event = | SessionNextRevertStaged | SessionNextRevertCleared | SessionNextRevertCommitted + | MessageDiffUpdated | MessagePartDelta | SessionDiff | SessionError @@ -3280,21 +3279,6 @@ export type SyncEventMessageRemoved = { } } -export type SyncEventMessageDiffUpdated = { - type: "sync" - id: string - syncEvent: { - type: "message.diff.updated.1" - id: string - seq: number - aggregateID: string - data: { - sessionID: string - messageID: string - } - } -} - export type SyncEventMessagePartUpdated = { type: "sync" id: string @@ -5208,24 +5192,6 @@ export type MessageRemoved = { } } -export type MessageDiffUpdated = { - id: string - metadata?: { - [key: string]: unknown - } - type: "message.diff.updated" - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - data: { - sessionID: string - messageID: string - } -} - export type MessagePartUpdated = { id: string metadata?: { @@ -5347,6 +5313,24 @@ export type SessionNextCompactionDelta = { } } +export type MessageDiffUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "message.diff.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + messageID: string + } +} + export type MessagePartDelta = { id: string metadata?: { @@ -6275,15 +6259,6 @@ export type EventMessageRemoved = { } } -export type EventMessageDiffUpdated = { - id: string - type: "message.diff.updated" - properties: { - sessionID: string - messageID: string - } -} - export type EventMessagePartUpdated = { id: string type: "message.part.updated" @@ -6708,6 +6683,15 @@ export type EventSessionNextRevertCommitted = { } } +export type EventMessageDiffUpdated = { + id: string + type: "message.diff.updated" + properties: { + sessionID: string + messageID: string + } +} + export type EventMessagePartDelta = { id: string type: "message.part.delta" From 04ec04323d0098f0a240682be524f25388a56cda Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:44:05 +0200 Subject: [PATCH 08/18] feat(opencode): strip patch text from message diff events --- packages/opencode/src/session/summary.ts | 8 +- .../opencode/test/session/summary.test.ts | 83 +++++++++++++++++-- 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 4f6959c8a8a0..1305eacfe32c 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -64,6 +64,10 @@ function unquoteGitPath(input: string) { return Buffer.from(bytes).toString() } +function stripPatches(diffs: ReadonlyArray) { + return diffs.map(({ patch: _, ...diff }) => diff) +} + export interface Interface { readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect @@ -134,7 +138,7 @@ const layer = Layer.effect( ) yield* messageDiffs.put({ messageID: input.messageID, diffs: msgDiffs }) yield* sessions.setSummary({ sessionID: input.sessionID, summary: totals }) - target.info.summary = { ...target.info.summary, ...totals, diffs: msgDiffs } + target.info.summary = { ...target.info.summary, ...totals, diffs: stripPatches(msgDiffs) } yield* sessions.updateMessage(target.info) yield* events.publish(Session.Event.DiffUpdated, { sessionID: input.sessionID, messageID: input.messageID }) }) @@ -145,7 +149,7 @@ const layer = Layer.effect( (item) => item.info.id === input.messageID, ) if (!message || message.info.role !== "user") return [] - const diffs = (yield* messageDiffs.get(input.messageID)) ?? message.info.summary?.diffs ?? [] + const diffs = (yield* messageDiffs.get(input.messageID)) ?? message.info.summary?.diffs ?? [] return diffs.map((item) => { if (item.file === undefined) return item const file = unquoteGitPath(item.file) diff --git a/packages/opencode/test/session/summary.test.ts b/packages/opencode/test/session/summary.test.ts index 387f1f0f1e3f..5786ec2f85f0 100644 --- a/packages/opencode/test/session/summary.test.ts +++ b/packages/opencode/test/session/summary.test.ts @@ -1,10 +1,11 @@ import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" -import { Effect, Layer } from "effect" +import { Deferred, Effect, Layer } from "effect" import { Database } from "@opencode-ai/core/database/database" import { MessageDiffTable } from "@opencode-ai/core/database/message-diff.sql" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" import { SessionProjector } from "@opencode-ai/core/session/projector" import { MessageDiff } from "@opencode-ai/core/session/message-diff" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -12,13 +13,24 @@ import { FileDiff } from "@opencode-ai/schema/file-diff" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { Snapshot } from "@/snapshot" +import { EventV2Bridge } from "@/event-v2-bridge" +import { GlobalBus } from "@/bus/global" import { Session as SessionNs } from "@/session/session" +import { MessageV2 } from "@/session/message-v2" import { SessionSummary } from "@/session/summary" import { MessageID, PartID, type SessionID } from "@/session/schema" import { testEffect } from "../lib/effect" +const distinctivePatch = "distinctive patch text that must not reach message.updated.1" + const sideTableDiffs: ReadonlyArray = [ - { file: "side.ts", additions: 3, deletions: 2, patch: "side", status: "modified" }, + { + file: "side.ts", + additions: 3, + deletions: 2, + patch: distinctivePatch, + status: "modified", + }, ] const legacyDiffs: ReadonlyArray = [ { file: "legacy.ts", additions: 1, deletions: 4, patch: "legacy", status: "deleted" }, @@ -37,7 +49,14 @@ const snapshot = Layer.succeed(Snapshot.Service, { const it = testEffect( AppNodeBuilder.build( - LayerNode.group([Database.node, MessageDiff.node, SessionNs.node, SessionProjector.node, SessionSummary.node]), + LayerNode.group([ + Database.node, + MessageDiff.node, + SessionNs.node, + SessionProjector.node, + EventV2Bridge.node, + SessionSummary.node, + ]), [[Snapshot.node, snapshot]], ), ) @@ -150,20 +169,70 @@ describe("SessionSummary", () => { ), ) - it.instance("keeps inline diffs and writes summary counts while dual-writing", () => + it.instance("keeps a diff skeleton inline and full diffs in the side table while dual-writing", () => withSession(({ sessionID }) => Effect.gen(function* () { const messageID = yield* addUser({ sessionID, diffs: [] }) yield* addSnapshots({ sessionID, messageID }) - const summary = yield* SessionSummary.Service - yield* summary.summarize({ sessionID, messageID }) + const sessionSummary = yield* SessionSummary.Service + yield* sessionSummary.summarize({ sessionID, messageID }) const session = yield* SessionNs.Service const updated = (yield* session.messages({ sessionID })).find((item) => item.info.id === messageID) expect(updated?.info.summary).toMatchObject({ additions: 3, deletions: 2, files: 1, - diffs: sideTableDiffs, + diffs: [{ file: "side.ts", additions: 3, deletions: 2, status: "modified" }], + }) + const diffs = yield* MessageDiff.Service + expect(yield* diffs.get(messageID)).toEqual([...sideTableDiffs]) + }), + ), + ) + + it.instance("publishes a patch-free summary while retaining diff metadata", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const messageID = yield* addUser({ sessionID, diffs: legacyDiffs }) + yield* addSnapshots({ sessionID, messageID }) + const received = yield* Deferred.make<{ + type: "message.updated.1" + data: typeof MessageV2.Event.Updated.data.Type + }>() + const listener = (event: { payload: { type?: string; syncEvent?: EventV2.SerializedEvent } }) => { + const syncEvent = event.payload.syncEvent + if (event.payload.type !== "sync") return + if (syncEvent?.type !== EventV2.versionedType(MessageV2.Event.Updated.type, 1)) return + const messageUpdated = syncEvent as unknown as { + type: "message.updated.1" + data: typeof MessageV2.Event.Updated.data.Type + } + if (messageUpdated.data.sessionID !== sessionID) return + Deferred.doneUnsafe(received, Effect.succeed(messageUpdated)) + } + GlobalBus.on("event", listener) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener))) + + const sessionSummary = yield* SessionSummary.Service + yield* sessionSummary.summarize({ sessionID, messageID }) + const messageUpdated = yield* Effect.race( + Deferred.await(received), + Effect.sleep("2 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error("message.updated.1 was not published")))), + ) + + const messageSummary: unknown = messageUpdated.data.info.summary + expect(typeof messageSummary).toBe("object") + expect(messageSummary).not.toBe(true) + expect(messageSummary).not.toBe(1) + expect(JSON.stringify(messageUpdated)).not.toContain(distinctivePatch) + const messageSummaryInfo = messageUpdated.data.info.summary + if (!messageSummaryInfo || typeof messageSummaryInfo === "boolean") + throw new Error("message summary must be an object") + expect(messageSummaryInfo.diffs[0]).toEqual({ + file: "side.ts", + additions: 3, + deletions: 2, + status: "modified", }) }), ), From f70b65e06596de22d1025082b455148e833c5d62 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:49:30 +0200 Subject: [PATCH 09/18] test(opencode): count the message diff event in the wire manifest --- packages/opencode/test/event-manifest.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts index da0f80cee5d3..86f690e5aa0a 100644 --- a/packages/opencode/test/event-manifest.test.ts +++ b/packages/opencode/test/event-manifest.test.ts @@ -9,7 +9,7 @@ describe("public event manifest", () => { expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) - expect(EventManifest.Latest.size).toBe(88) + expect(EventManifest.Latest.size).toBe(89) expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) expect(EventManifest.Latest.has("ide.installed")).toBe(false) From c3fabddc401b36ebaa2ae2b19affe6be8f913a77 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:58:30 +0200 Subject: [PATCH 10/18] test(opencode): assert the inline diff skeleton exactly --- packages/opencode/test/session/summary.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/session/summary.test.ts b/packages/opencode/test/session/summary.test.ts index 5786ec2f85f0..6587a5a0b094 100644 --- a/packages/opencode/test/session/summary.test.ts +++ b/packages/opencode/test/session/summary.test.ts @@ -182,8 +182,10 @@ describe("SessionSummary", () => { additions: 3, deletions: 2, files: 1, - diffs: [{ file: "side.ts", additions: 3, deletions: 2, status: "modified" }], }) + expect(updated?.info.summary?.diffs).toEqual([ + { file: "side.ts", additions: 3, deletions: 2, status: "modified" }, + ]) const diffs = yield* MessageDiff.Service expect(yield* diffs.get(messageID)).toEqual([...sideTableDiffs]) }), From aad32e576673e34a272564ba0d59ae2498d5c297 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:39:31 +0200 Subject: [PATCH 11/18] feat(opencode): hydrate exported diffs from the side table --- packages/opencode/src/cli/cmd/export.ts | 20 +- packages/opencode/test/cli/export.test.ts | 199 ++++++++++++++++++ .../opencode/test/session/summary.test.ts | 7 +- 3 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/cli/export.test.ts diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 8c3aa1618ae1..f563b699a844 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -1,4 +1,5 @@ import { Session } from "@/session/session" +import { SessionSummary } from "@/session/summary" import { SessionV1 } from "@opencode-ai/core/v1/session" import { MessageV2 } from "../../session/message-v2" import { SessionID } from "../../session/schema" @@ -239,6 +240,7 @@ export const ExportCommand = effectCmd({ const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) { const svc = yield* Session.Service + const summary = yield* SessionSummary.Service let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`) @@ -283,8 +285,24 @@ const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; return yield* Effect.gen(function* () { const sessionInfo = yield* svc.get(sessionID!) const messages = yield* svc.messages({ sessionID: sessionInfo.id }) + const hydrated = yield* Effect.forEach(messages, (message) => { + const info = message.info + if (info.role !== "user" || !info.summary) return Effect.succeed(message) + return summary.diff({ sessionID: sessionInfo.id, messageID: info.id }).pipe( + Effect.map((diffs) => ({ + ...message, + info: { + ...info, + summary: { + ...info.summary, + diffs, + }, + }, + })), + ) + }) - const exportData = { info: sessionInfo, messages } + const exportData = { info: sessionInfo, messages: hydrated } process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2)) process.stdout.write(EOL) diff --git a/packages/opencode/test/cli/export.test.ts b/packages/opencode/test/cli/export.test.ts new file mode 100644 index 000000000000..ce25756482c1 --- /dev/null +++ b/packages/opencode/test/cli/export.test.ts @@ -0,0 +1,199 @@ +import { expect } from "bun:test" +import { Database } from "bun:sqlite" +import { Effect } from "effect" +import { cliIt } from "../lib/cli-process" + +const databaseEnvironment = { + OPENCODE_DB: "export-test.db", + OPENCODE_DISABLE_CHANNEL_DB: "1", +} + +type ExportData = { + info: { summary?: { diffs?: unknown } } + messages: Array<{ info: { id: string; summary?: { diffs?: unknown } } }> +} + +function parseExport(output: string): ExportData { + return JSON.parse(output) as ExportData +} + +function exportMessage(data: ExportData, id: string) { + const found = data.messages.find((item) => item.info.id === id) + if (!found || !found.info.summary) throw new Error("expected an exported message summary") + return { id: found.info.id, summary: found.info.summary } +} + +cliIt.live("hydrates side-table diffs in raw and sanitized exports", ({ opencode }) => + Effect.gen(function* () { + const created = yield* opencode.run("create an export fixture", { + env: databaseEnvironment, + }) + opencode.expectExit(created, 0, "create export fixture") + + const database = yield* opencode.spawn(["db", "path"], { + env: databaseEnvironment, + }) + opencode.expectExit(database, 0, "database path") + const db = new Database(database.stdout.trim()) + const message = db + .query<{ id: string; session_id: string }, []>( + "SELECT id, session_id FROM message WHERE json_extract(data, '$.role') = 'user' LIMIT 1", + ) + .get() + if (!message) throw new Error("expected a user message") + + db.query("UPDATE message SET data = json_set(data, '$.summary', json(?)) WHERE id = ?").run( + JSON.stringify({ + additions: 2, + deletions: 1, + files: 1, + diffs: [{ file: "inline.ts", additions: 2, deletions: 1, status: "modified" }], + }), + message.id, + ) + db.query( + "INSERT INTO message_diff (message_id, data) VALUES (?, json(?)) " + + "ON CONFLICT(message_id) DO UPDATE SET data = excluded.data", + ).run( + message.id, + JSON.stringify([ + { + file: "side-table-secret.ts", + patch: "side-table-secret-patch", + additions: 2, + deletions: 1, + status: "modified", + }, + ]), + ) + db.close() + + const raw = yield* opencode.spawn(["export", message.session_id], { + env: databaseEnvironment, + }) + opencode.expectExit(raw, 0, "raw export") + const rawMessage = exportMessage(parseExport(raw.stdout), message.id) + expect(rawMessage.summary.diffs).toEqual([ + { + file: "side-table-secret.ts", + patch: "side-table-secret-patch", + additions: 2, + deletions: 1, + status: "modified", + }, + ]) + + const sanitized = yield* opencode.spawn(["export", message.session_id, "--sanitize"], { + env: databaseEnvironment, + }) + opencode.expectExit(sanitized, 0, "sanitized export") + const sanitizedMessage = exportMessage(parseExport(sanitized.stdout), message.id) + expect(sanitizedMessage.summary.diffs).toEqual([ + { + file: "[redacted:message-diff-file:0]", + patch: "[redacted:message-diff-patch:0]", + additions: 2, + deletions: 1, + status: "modified", + }, + ]) + }), +) + +cliIt.live("exports legacy inline and session summary diffs", ({ opencode }) => + Effect.gen(function* () { + const created = yield* opencode.run("create a legacy export fixture", { + env: databaseEnvironment, + }) + opencode.expectExit(created, 0, "create legacy export fixture") + + const database = yield* opencode.spawn(["db", "path"], { + env: databaseEnvironment, + }) + opencode.expectExit(database, 0, "database path") + const db = new Database(database.stdout.trim()) + const message = db + .query<{ id: string; session_id: string }, []>( + "SELECT id, session_id FROM message WHERE json_extract(data, '$.role') = 'user' LIMIT 1", + ) + .get() + if (!message) throw new Error("expected a user message") + + db.query("UPDATE message SET data = json_set(data, '$.summary', json(?)) WHERE id = ?").run( + JSON.stringify({ + additions: 3, + deletions: 2, + files: 1, + diffs: [ + { + file: "legacy-inline-secret.ts", + patch: "legacy-inline-secret-patch", + additions: 3, + deletions: 2, + status: "modified", + }, + ], + }), + message.id, + ) + db.query("DELETE FROM message_diff WHERE message_id = ?").run(message.id) + db.query( + "UPDATE session SET summary_additions = ?, summary_deletions = ?, summary_files = ?, summary_diffs = json(?) WHERE id = ?", + ).run( + 4, + 3, + 1, + JSON.stringify([ + { + file: "legacy-session-secret.ts", + patch: "legacy-session-secret-patch", + additions: 4, + deletions: 3, + status: "modified", + }, + ]), + message.session_id, + ) + db.close() + + const raw = yield* opencode.spawn(["export", message.session_id], { + env: databaseEnvironment, + }) + opencode.expectExit(raw, 0, "raw legacy export") + const rawMessage = exportMessage(parseExport(raw.stdout), message.id) + expect(rawMessage.summary.diffs).toEqual([ + { + file: "legacy-inline-secret.ts", + patch: "legacy-inline-secret-patch", + additions: 3, + deletions: 2, + status: "modified", + }, + ]) + + const sanitized = yield* opencode.spawn(["export", message.session_id, "--sanitize"], { + env: databaseEnvironment, + }) + opencode.expectExit(sanitized, 0, "sanitized legacy export") + const sanitizedData = parseExport(sanitized.stdout) + const sanitizedMessage = exportMessage(sanitizedData, message.id) + expect(sanitizedMessage.summary.diffs).toEqual([ + { + file: "[redacted:message-diff-file:0]", + patch: "[redacted:message-diff-patch:0]", + additions: 3, + deletions: 2, + status: "modified", + }, + ]) + expect(sanitizedData.info.summary?.diffs).toEqual([ + { + file: "[redacted:session-diff-file:0]", + patch: "[redacted:session-diff-patch:0]", + additions: 4, + deletions: 3, + status: "modified", + }, + ]) + }), +) diff --git a/packages/opencode/test/session/summary.test.ts b/packages/opencode/test/session/summary.test.ts index 6587a5a0b094..d08d3c616969 100644 --- a/packages/opencode/test/session/summary.test.ts +++ b/packages/opencode/test/session/summary.test.ts @@ -178,12 +178,15 @@ describe("SessionSummary", () => { yield* sessionSummary.summarize({ sessionID, messageID }) const session = yield* SessionNs.Service const updated = (yield* session.messages({ sessionID })).find((item) => item.info.id === messageID) - expect(updated?.info.summary).toMatchObject({ + if (!updated || updated.info.role !== "user" || !updated.info.summary) { + throw new Error("expected a summarized user message") + } + expect(updated.info.summary).toMatchObject({ additions: 3, deletions: 2, files: 1, }) - expect(updated?.info.summary?.diffs).toEqual([ + expect(updated.info.summary.diffs).toEqual([ { file: "side.ts", additions: 3, deletions: 2, status: "modified" }, ]) const diffs = yield* MessageDiff.Service From 34cd63ef2666c7be5a3f9851c71dd8ceeda800d0 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:41:48 +0200 Subject: [PATCH 12/18] test(schema): enumerate the message diff event in the legacy manifest --- packages/schema/test/legacy-event.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/schema/test/legacy-event.test.ts b/packages/schema/test/legacy-event.test.ts index e43c5681f201..fb49706ce1e1 100644 --- a/packages/schema/test/legacy-event.test.ts +++ b/packages/schema/test/legacy-event.test.ts @@ -13,6 +13,7 @@ describe("legacy public event schemas", () => { "session.deleted", "message.updated", "message.removed", + "message.diff.updated", "message.part.updated", "message.part.removed", "message.part.delta", From 7ca3c71bfdf4b5aa33cc24ca0d80d564a7cdb49d Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:59:06 +0200 Subject: [PATCH 13/18] fix(app): clean up message diff cache on the global-sync drop path --- .../context/global-sync/event-reducer.test.ts | 1 + .../context/global-sync/session-cache.test.ts | 7 ++++- .../src/context/global-sync/session-cache.ts | 4 +++ .../app/src/context/server-session.test.ts | 29 +++++++++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) 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 06d536618e05..39fca92a3586 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -72,6 +72,7 @@ const baseState = (input: Partial = {}) => sessionTotal: 0, session_status: {}, session_diff: {}, + message_diff: {}, todo: {}, permission: {}, question: {}, diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 45fbe38abe73..db69a932a161 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -27,6 +27,7 @@ describe("app session cache", () => { const store: { session_status: Record session_diff: Record + message_diff: Record todo: Record message: Record session_message: Record @@ -37,8 +38,9 @@ describe("app session cache", () => { } = { session_status: { ses_1: { type: "busy" } as SessionStatus }, session_diff: { ses_1: [] }, + message_diff: { msg_dropped: [] }, todo: { ses_1: [] as Todo[] }, - message: {}, + message: { ses_1: [msg("msg_dropped", "ses_1")] }, session_message: {}, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, @@ -49,6 +51,7 @@ describe("app session cache", () => { dropSessionCaches(store, ["ses_1"]) expect(store.message.ses_1).toBeUndefined() + expect(store.message_diff.msg_dropped).toBeUndefined() expect(store.part.msg_1).toBeUndefined() expect(store.part_text_accum_delta.prt_1).toBeUndefined() expect(store.todo.ses_1).toBeUndefined() @@ -63,6 +66,7 @@ describe("app session cache", () => { const store: { session_status: Record session_diff: Record + message_diff: Record todo: Record message: Record session_message: Record @@ -73,6 +77,7 @@ describe("app session cache", () => { } = { session_status: {}, session_diff: {}, + message_diff: {}, todo: {}, message: { ses_1: [m] }, session_message: {}, diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 7d684a5a1ab0..4e00358ed199 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -7,6 +7,7 @@ export const SESSION_CACHE_LIMIT = 40 type SessionCache = { session_status: Record session_diff: Record + message_diff: Record todo: Record message: Record session_message: Record @@ -30,6 +31,9 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable { expect(client.diffRequests).toEqual([]) }) + test("defers stripped message diff patches until expansion", async () => { + const strippedDiff = { + file: "post-branch.ts", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const user = userMessage("msg_user", { + summary: { additions: 1, deletions: 1, files: 1, diffs: [strippedDiff] } as UserMessage["summary"], + }) + const client = messageClient(response([{ info: user, parts: [] }])) + const store = createServerSession(client) + + await store.sync("child") + + expect(client.diffRequests).toEqual([]) + + expandMessageDiff({ + diff: strippedDiff, + cache: store.data.message_diff[user.id], + sessionID: user.sessionID, + messageID: user.id, + fetch: store.fetchMessageDiff, + }) + + expect(client.diffRequests).toEqual([{ sessionID: "child", messageID: user.id }]) + }) + test("fetches diffs for loaded user message count metadata", async () => { const user = userMessage("msg_user", { summary: { additions: 1, deletions: 1, files: 1 } as UserMessage["summary"], From e8fa0d9d751fb6139dd4046a2caf095a384c6403 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:58:09 +0200 Subject: [PATCH 14/18] feat(opencode): sweep event history for idle sessions --- packages/core/src/v1/config/config.ts | 7 + .../server/routes/instance/httpapi/server.ts | 2 + .../opencode/src/session/event-retention.ts | 82 +++++++++ .../test/session/event-retention.test.ts | 166 ++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 packages/opencode/src/session/event-retention.ts create mode 100644 packages/opencode/test/session/event-retention.test.ts diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 7ebb4b69b023..6637ea2d41d7 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -166,6 +166,13 @@ export const Info = Schema.Struct({ }), }), ), + retention: Schema.optional( + Schema.Struct({ + event_idle_days: Schema.optional(Schema.Number).annotate({ + description: "Delete durable event history for sessions idle longer than this many days", + }), + }), + ).annotate({ description: "Retention settings for durable event history" }), experimental: Schema.optional( Schema.Struct({ disable_paste_summary: Schema.optional(Schema.Boolean), diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index fb9d2db65621..677c710d5dba 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -29,6 +29,7 @@ import { ProviderAuth } from "@/provider/auth" import { Provider } from "@/provider/provider" import { Question } from "@/question" import { SessionCompaction } from "@/session/compaction" +import { SessionEventRetention } from "@/session/event-retention" import { Instruction } from "@/session/instruction" import { LLM } from "@/session/llm" import { SessionProcessor } from "@/session/processor" @@ -241,6 +242,7 @@ const app = LayerNode.group([ SessionRunState.node, SessionProcessor.node, SessionCompaction.node, + SessionEventRetention.node, SessionRevert.node, SessionSummary.node, SessionPrompt.node, diff --git a/packages/opencode/src/session/event-retention.ts b/packages/opencode/src/session/event-retention.ts new file mode 100644 index 000000000000..d713acd06607 --- /dev/null +++ b/packages/opencode/src/session/event-retention.ts @@ -0,0 +1,82 @@ +import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { EventTable } from "@opencode-ai/core/event/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Config } from "@/config/config" +import { asc, eq, inArray, lt } from "drizzle-orm" +import { Context, Duration, Effect, Layer, Schedule, Scope } from "effect" + +export const BATCH_LIMIT = 200 +const DELETE_BATCH_SIZE = 25 +const DAY_MS = 24 * 60 * 60 * 1000 + +export interface SweepResult { + readonly sessions: number + readonly events: number +} + +export interface Interface { + readonly sweep: (now: number) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SessionEventRetention") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const scope = yield* Scope.Scope + const config = yield* Config.Service + const { db } = yield* Database.Service + const events = yield* EventV2.Service + + const sweep = Effect.fn("SessionEventRetention.sweep")(function* (now: number) { + const days = (yield* config.getGlobal()).retention?.event_idle_days + if (days === undefined || days <= 0) return { sessions: 0, events: 0 } + + const sessions = yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .innerJoin(EventTable, eq(EventTable.aggregate_id, SessionTable.id)) + .where(lt(SessionTable.time_updated, now - days * DAY_MS)) + .groupBy(SessionTable.id) + .orderBy(asc(SessionTable.time_updated)) + .limit(BATCH_LIMIT) + .all() + .pipe(Effect.orDie) + if (!sessions.length) return { sessions: 0, events: 0 } + + const history = yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(inArray(EventTable.aggregate_id, sessions.map((session) => session.id))) + .all() + .pipe(Effect.orDie) + + yield* Effect.forEach( + sessions, + (session, index) => + events.remove(session.id).pipe( + Effect.andThen((index + 1) % DELETE_BATCH_SIZE === 0 ? Effect.sleep(Duration.millis(1)) : Effect.void), + ), + { concurrency: 1 }, + ) + + yield* Effect.logInfo("swept event history", { sessions: sessions.length, events: history.length }) + return { sessions: sessions.length, events: history.length } + }) + + yield* Effect.suspend(() => sweep(Date.now())).pipe( + Effect.catchCause((cause) => Effect.logError("event history retention sweep failed", { cause })), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkIn(scope), + ) + + return Service.of({ sweep }) + }), +) + +export const node = LayerNode.make({ service: Service, layer, deps: [Config.node, Database.node, EventV2.node] }) + +export * as SessionEventRetention from "./event-retention" diff --git a/packages/opencode/test/session/event-retention.test.ts b/packages/opencode/test/session/event-retention.test.ts new file mode 100644 index 000000000000..3d9329bde723 --- /dev/null +++ b/packages/opencode/test/session/event-retention.test.ts @@ -0,0 +1,166 @@ +import { describe, expect } from "bun:test" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { desc, eq, inArray } from "drizzle-orm" +import { Effect, Layer } from "effect" +import { Config } from "@/config/config" +import { EventV2Bridge } from "@/event-v2-bridge" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { SessionEventRetention } from "@/session/event-retention" +import { Session } from "@/session/session" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" + +const day = 24 * 60 * 60 * 1000 + +function layer(retention?: number) { + return AppNodeBuilder.build( + LayerNode.group([ + SessionEventRetention.node, + Session.node, + SessionProjector.node, + Database.node, + EventV2Bridge.node, + CrossSpawnSpawner.node, + ]), + [ + [ + Config.node, + Layer.succeed( + Config.Service, + TestConfig.make({ + getGlobal: () => + Effect.succeed(retention === undefined ? {} : { retention: { event_idle_days: retention } }), + }), + ), + ], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], + ], + ) +} + +const disabled = testEffect(layer()) +const enabled = testEffect(layer(7)) + +function createSession(time_updated: number) { + return Effect.gen(function* () { + const sessions = yield* Session.Service + const { db } = yield* Database.Service + const session = yield* sessions.create() + yield* db.update(SessionTable).set({ time_updated }).where(eq(SessionTable.id, session.id)).run() + return session + }) +} + +function eventCount(sessionID: string) { + return Database.Service.use(({ db }) => + db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, sessionID)) + .all() + .pipe(Effect.map((rows) => rows.length), Effect.orDie), + ) +} + +function hasSequence(sessionID: string) { + return Database.Service.use(({ db }) => + db + .select({ aggregate_id: EventSequenceTable.aggregate_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .get() + .pipe(Effect.map(Boolean), Effect.orDie), + ) +} + +function removeEvents(sessionIDs: ReadonlyArray) { + return EventV2Bridge.Service.use((events) => Effect.forEach(sessionIDs, (sessionID) => events.remove(sessionID))) +} + +describe("session event retention", () => { + disabled.instance("keeps event history when retention is absent", () => + Effect.gen(function* () { + const now = Date.now() + const session = yield* createSession(now - 30 * day) + const retention = yield* SessionEventRetention.Service + + expect(yield* eventCount(session.id)).toBeGreaterThan(0) + expect(yield* retention.sweep(now)).toEqual({ sessions: 0, events: 0 }) + expect(yield* eventCount(session.id)).toBeGreaterThan(0) + expect(yield* hasSequence(session.id)).toBe(true) + yield* removeEvents([session.id]) + }), + ) + + enabled.instance("removes only event history for sessions idle beyond retention", () => + Effect.gen(function* () { + const now = Date.now() + const old = yield* createSession(now - 8 * day) + const recent = yield* createSession(now - 6 * day) + const retention = yield* SessionEventRetention.Service + + const result = yield* retention.sweep(now) + expect(yield* eventCount(old.id)).toBe(0) + expect(yield* hasSequence(old.id)).toBe(false) + expect(yield* eventCount(recent.id)).toBe(1) + expect(yield* hasSequence(recent.id)).toBe(true) + expect(result).toEqual({ sessions: 1, events: 1 }) + yield* removeEvents([old.id, recent.id]) + }), + ) + + enabled.instance("restarts durable event sequence at zero after retention", () => + Effect.gen(function* () { + const now = Date.now() + const session = yield* createSession(now - 8 * day) + const retention = yield* SessionEventRetention.Service + const events = yield* EventV2Bridge.Service + const sessions = yield* Session.Service + + yield* retention.sweep(now) + yield* events.publish(SessionV1.Event.Updated, { sessionID: session.id, info: yield* sessions.get(session.id) }) + + const { db } = yield* Database.Service + const event = yield* db + .select({ seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, session.id)) + .orderBy(desc(EventTable.seq)) + .get() + .pipe(Effect.orDie) + expect(event?.seq).toBe(0) + yield* removeEvents([session.id]) + }), + ) + + enabled.instance("sweeps at most two hundred sessions per run", () => + Effect.gen(function* () { + const now = Date.now() + const sessions = yield* Effect.forEach( + Array.from({ length: 201 }), + () => createSession(now - 10 * day), + { concurrency: 1 }, + ) + const retention = yield* SessionEventRetention.Service + + expect(yield* retention.sweep(now)).toEqual({ sessions: 200, events: 200 }) + + const { db } = yield* Database.Service + const remaining = yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(inArray(EventTable.aggregate_id, sessions.map((session) => session.id))) + .all() + .pipe(Effect.map((rows) => rows.length), Effect.orDie) + expect(remaining).toBe(1) + yield* removeEvents(sessions.map((session) => session.id)) + }), + ) +}) From dd3363051fd10ce63134707e6e3f200b727cea00 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:49:51 +0200 Subject: [PATCH 15/18] fix(app): show message diff load failures --- .../src/context/global-sync/bootstrap.test.ts | 1 + .../src/context/global-sync/child-store.ts | 1 + .../context/global-sync/session-cache.test.ts | 5 ++ .../src/context/global-sync/session-cache.ts | 2 + packages/app/src/context/global-sync/types.ts | 3 ++ .../app/src/context/server-session.test.ts | 49 ++++++++++++++++++- packages/app/src/context/server-session.ts | 12 ++++- .../src/components/session-turn.tsx | 35 +++++++++++-- packages/session-ui/src/context/data.tsx | 3 ++ packages/ui/src/i18n/en.ts | 4 ++ 10 files changed, 107 insertions(+), 8 deletions(-) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 14e6fe6a0ae2..8cf258923212 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -59,6 +59,7 @@ function directoryState() { }, session_diff: {}, message_diff: {}, + message_diff_status: {}, todo: {}, permission: {}, question: {}, diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index b5af3dbebd7e..660e55d499b5 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -235,6 +235,7 @@ export function createChildStoreManager(input: { }, session_diff: {}, message_diff: {}, + message_diff_status: {}, todo: {}, permission: {}, question: {}, diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index db69a932a161..6cae30ef9c37 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -28,6 +28,7 @@ describe("app session cache", () => { session_status: Record session_diff: Record message_diff: Record + message_diff_status: Record todo: Record message: Record session_message: Record @@ -39,6 +40,7 @@ describe("app session cache", () => { session_status: { ses_1: { type: "busy" } as SessionStatus }, session_diff: { ses_1: [] }, message_diff: { msg_dropped: [] }, + message_diff_status: { msg_dropped: "failed" }, todo: { ses_1: [] as Todo[] }, message: { ses_1: [msg("msg_dropped", "ses_1")] }, session_message: {}, @@ -52,6 +54,7 @@ describe("app session cache", () => { expect(store.message.ses_1).toBeUndefined() expect(store.message_diff.msg_dropped).toBeUndefined() + expect(store.message_diff_status.msg_dropped).toBeUndefined() expect(store.part.msg_1).toBeUndefined() expect(store.part_text_accum_delta.prt_1).toBeUndefined() expect(store.todo.ses_1).toBeUndefined() @@ -67,6 +70,7 @@ describe("app session cache", () => { session_status: Record session_diff: Record message_diff: Record + message_diff_status: Record todo: Record message: Record session_message: Record @@ -78,6 +82,7 @@ describe("app session cache", () => { session_status: {}, session_diff: {}, message_diff: {}, + message_diff_status: {}, todo: {}, message: { ses_1: [m] }, session_message: {}, diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 4e00358ed199..c9b5240a06c4 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -8,6 +8,7 @@ type SessionCache = { session_status: Record session_diff: Record message_diff: Record + message_diff_status: Record todo: Record message: Record session_message: Record @@ -33,6 +34,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { +function pendingDiffClient(...diffs: Array>) { + let index = 0 const requests: unknown[] = [] const waiting = new Map void>() const client = { @@ -123,7 +124,7 @@ function pendingDiffClient(diff: Promise) { requests.push(input) waiting.get(requests.length)?.() waiting.delete(requests.length) - return diff + return diffs[index++] }, }, } as unknown as OpencodeClient @@ -433,6 +434,50 @@ describe("server session", () => { expect(client.diffRequests).toEqual([{ sessionID: "child", messageID: user.id }]) }) + test("marks unloaded message diffs unavailable when no stored patch is returned", async () => { + const user = userMessage("msg_user", { + summary: { additions: 1, deletions: 1, files: 1 } as UserMessage["summary"], + }) + const store = createServerSession(pendingDiffClient({ data: [] })) + + store.set("message", "child", [user]) + await store.fetchMessageDiff(user.sessionID, user.id) + + expect(store.data.message_diff_status[user.id]).toBe("absent") + }) + + test("marks failed message diff fetches and retries them", async () => { + const failed = deferredDiffResponse() + const retried = deferredDiffResponse() + const user = userMessage("msg_user", { + summary: { additions: 1, deletions: 1, files: 1 } as UserMessage["summary"], + }) + const client = pendingDiffClient(failed.promise, retried.promise) + const store = createServerSession(client) + + store.set("message", "child", [user]) + const loading = store.fetchMessageDiff(user.sessionID, user.id) + await client.requested(1) + + expect(store.data.message_diff_status[user.id]).toBe("pending") + + failed.reject(new Error("diff unavailable")) + await loading + + expect(store.data.message_diff_status[user.id]).toBe("failed") + + const retrying = store.fetchMessageDiff(user.sessionID, user.id) + await client.requested(2) + + expect(store.data.message_diff_status[user.id]).toBe("pending") + + retried.resolve({ data: [fetchedDiff] }) + await retrying + + expect(store.data.message_diff_status[user.id]).toBeUndefined() + expect(store.data.message_diff[user.id]).toEqual([fetchedDiff]) + }) + test("fetches diffs for loaded user message count metadata", async () => { const user = userMessage("msg_user", { summary: { additions: 1, deletions: 1, files: 1 } as UserMessage["summary"], diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index e3c95d9a153b..e6821f2a5e3f 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -221,6 +221,7 @@ export function createServerSession( session_status: {} as Record, session_diff: {} as Record, message_diff: {} as Record, + message_diff_status: {} as Record, todo: {} as Record, permission: {} as Record, question: {} as Record, @@ -273,6 +274,7 @@ export function createServerSession( setData( produce((draft) => { delete draft.message_diff[messageID] + delete draft.message_diff_status[messageID] }), ) } @@ -283,6 +285,7 @@ export function createServerSession( messageDiffGenerations.set(message.id, {}) messageDiffSessions.set(message.id, message.sessionID) setData("message_diff", message.id, normalizeMessageDiffs(diffs)) + setData("message_diff_status", message.id, undefined) return } if (message.summary?.files === undefined) return @@ -292,12 +295,17 @@ export function createServerSession( const active = messageDiffGenerations.get(messageID) ?? {} messageDiffGenerations.set(messageID, active) messageDiffSessions.set(messageID, sessionID) + setData("message_diff_status", messageID, "pending") return runInflight(inflightMessageDiff, messageID, async () => { try { const response = await client.session.diff({ sessionID, messageID }) if (messageDiffGenerations.get(messageID) !== active) return - setData("message_diff", messageID, normalizeMessageDiffs(response.data ?? [])) + const diffs = normalizeMessageDiffs(response.data ?? []) + setData("message_diff", messageID, diffs) + setData("message_diff_status", messageID, diffs.length ? undefined : "absent") } catch (error) { + if (messageDiffGenerations.get(messageID) !== active) return + setData("message_diff_status", messageID, "failed") if (import.meta.env.DEV) console.debug("[session] failed to fetch message diff", { sessionID, @@ -573,6 +581,7 @@ export function createServerSession( messageDiffSessions.delete(messageID) inflightMessageDiff.delete(messageID) delete draft.message_diff[messageID] + delete draft.message_diff_status[messageID] } dropSessionCaches(draft, sessionIDs) }), @@ -692,6 +701,7 @@ export function createServerSession( for (const message of dropped) { deleteMessageParts(draft, message.id) delete draft.message_diff[message.id] + delete draft.message_diff_status[message.id] messageDiffGenerations.delete(message.id) messageDiffSessions.delete(message.id) inflightMessageDiff.delete(message.id) diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index 7b9e0c694332..21e3e043c511 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -16,6 +16,7 @@ import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part" import { Card } from "@opencode-ai/ui/card" +import { Button } from "@opencode-ai/ui/button" import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { DiffChanges } from "@opencode-ai/ui/diff-changes" @@ -23,6 +24,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { SessionRetry } from "./session-retry" import { TextReveal } from "@opencode-ai/ui/text-reveal" +import { Spinner } from "@opencode-ai/ui/spinner" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { useI18n } from "@opencode-ai/ui/context/i18n" import { expandMessageDiff, normalize, resolveMessageDiff } from "./session-diff" @@ -475,6 +477,7 @@ export function SessionTurn( const source = createMemo(() => resolveMessageDiff(diff, data.store.message_diff[props.messageID])) const view = createMemo(() => normalize(source())) const loaded = createMemo(() => typeof source().patch === "string") + const diffState = createMemo(() => data.store.message_diff_status?.[props.messageID] ?? "absent") const active = createMemo(() => expanded().includes(diff.file)) const [shown, setShown] = createSignal(false) @@ -521,12 +524,34 @@ export function SessionTurn( - - -
- + +
+ +
+
+ +
+ + {i18n.t("ui.sessionTurn.diffs.loading")} +
+
+ + +
+ {i18n.t("ui.sessionTurn.diffs.failed")} +
-
+ + + +
+ {i18n.t("ui.sessionTurn.diffs.unavailable")} +
diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 83ed895b25dc..303615dc5873 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -31,6 +31,9 @@ type Data = { message_diff: { [messageID: string]: FileDiffInfo[] | undefined } + message_diff_status?: { + [messageID: string]: "pending" | "failed" | "absent" | undefined + } session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] } diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 54008fbc8d0b..14f91be6ac01 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -64,6 +64,10 @@ export const dict: Record = { "ui.sessionTurn.diffs.showAll": "Show all", "ui.sessionTurn.diffs.showLess": "Show less", "ui.sessionTurn.diffs.more": "+{{count}} more files", + "ui.sessionTurn.diffs.loading": "Loading diff", + "ui.sessionTurn.diffs.failed": "Couldn't load diff", + "ui.sessionTurn.diffs.retry": "Retry", + "ui.sessionTurn.diffs.unavailable": "Diff is no longer available", "ui.sessionTurn.retry.retrying": "retrying", "ui.sessionTurn.retry.inSeconds": "in {{seconds}}s", From 2b55f8dfa4d2b52f4090b923b9bcf2fd107f6fb9 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:49:57 +0200 Subject: [PATCH 16/18] fix(opencode): count retained events in database --- packages/core/src/v1/config/config.ts | 4 ++-- packages/opencode/src/session/event-retention.ts | 11 ++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 6637ea2d41d7..81b0dca50518 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -169,10 +169,10 @@ export const Info = Schema.Struct({ retention: Schema.optional( Schema.Struct({ event_idle_days: Schema.optional(Schema.Number).annotate({ - description: "Delete durable event history for sessions idle longer than this many days", + description: "Delete durable event history and make event replay unavailable for sessions idle longer than this many days", }), }), - ).annotate({ description: "Retention settings for durable event history" }), + ).annotate({ description: "Settings that delete durable event history and disable replay for swept sessions" }), experimental: Schema.optional( Schema.Struct({ disable_paste_summary: Schema.optional(Schema.Boolean), diff --git a/packages/opencode/src/session/event-retention.ts b/packages/opencode/src/session/event-retention.ts index d713acd06607..02e4298b7c18 100644 --- a/packages/opencode/src/session/event-retention.ts +++ b/packages/opencode/src/session/event-retention.ts @@ -46,11 +46,8 @@ export const layer = Layer.effect( .pipe(Effect.orDie) if (!sessions.length) return { sessions: 0, events: 0 } - const history = yield* db - .select({ id: EventTable.id }) - .from(EventTable) - .where(inArray(EventTable.aggregate_id, sessions.map((session) => session.id))) - .all() + const eventCount = yield* db + .$count(EventTable, inArray(EventTable.aggregate_id, sessions.map((session) => session.id))) .pipe(Effect.orDie) yield* Effect.forEach( @@ -62,8 +59,8 @@ export const layer = Layer.effect( { concurrency: 1 }, ) - yield* Effect.logInfo("swept event history", { sessions: sessions.length, events: history.length }) - return { sessions: sessions.length, events: history.length } + yield* Effect.logInfo("swept event history", { sessions: sessions.length, events: eventCount }) + return { sessions: sessions.length, events: eventCount } }) yield* Effect.suspend(() => sweep(Date.now())).pipe( From e826a39df13765bd3c1e6b962506c9d9479567bc Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:50:09 +0200 Subject: [PATCH 17/18] chore: format diff summary files --- packages/opencode/src/session/summary.ts | 12 ++--- .../src/components/session-turn.tsx | 44 ++++++++++--------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 1305eacfe32c..3701ceaeaa8f 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -137,11 +137,11 @@ const layer = Layer.effect( { additions: 0, deletions: 0, files: 0 }, ) yield* messageDiffs.put({ messageID: input.messageID, diffs: msgDiffs }) - yield* sessions.setSummary({ sessionID: input.sessionID, summary: totals }) - target.info.summary = { ...target.info.summary, ...totals, diffs: stripPatches(msgDiffs) } - yield* sessions.updateMessage(target.info) - yield* events.publish(Session.Event.DiffUpdated, { sessionID: input.sessionID, messageID: input.messageID }) - }) + yield* sessions.setSummary({ sessionID: input.sessionID, summary: totals }) + target.info.summary = { ...target.info.summary, ...totals, diffs: stripPatches(msgDiffs) } + yield* sessions.updateMessage(target.info) + yield* events.publish(Session.Event.DiffUpdated, { sessionID: input.sessionID, messageID: input.messageID }) + }) const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { if (!input.messageID) return [] @@ -149,7 +149,7 @@ const layer = Layer.effect( (item) => item.info.id === input.messageID, ) if (!message || message.info.role !== "user") return [] - const diffs = (yield* messageDiffs.get(input.messageID)) ?? message.info.summary?.diffs ?? [] + const diffs = (yield* messageDiffs.get(input.messageID)) ?? message.info.summary?.diffs ?? [] return diffs.map((item) => { if (item.file === undefined) return item const file = unquoteGitPath(item.file) diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index 21e3e043c511..b53da12e9ffd 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -453,31 +453,35 @@ export function SessionTurn(
- { - const next = Array.isArray(value) ? value : value ? [value] : [] - setState("expanded", next) - for (const diff of visible()) { - if (!next.includes(diff.file)) continue - void expandMessageDiff({ - diff, - cache: data.store.message_diff[props.messageID], - sessionID: props.sessionID, - messageID: props.messageID, - fetch: data.fetchMessageDiff, - }) - } - }} + { + const next = Array.isArray(value) ? value : value ? [value] : [] + setState("expanded", next) + for (const diff of visible()) { + if (!next.includes(diff.file)) continue + void expandMessageDiff({ + diff, + cache: data.store.message_diff[props.messageID], + sessionID: props.sessionID, + messageID: props.messageID, + fetch: data.fetchMessageDiff, + }) + } + }} > {(diff) => { - const source = createMemo(() => resolveMessageDiff(diff, data.store.message_diff[props.messageID])) + const source = createMemo(() => + resolveMessageDiff(diff, data.store.message_diff[props.messageID]), + ) const view = createMemo(() => normalize(source())) const loaded = createMemo(() => typeof source().patch === "string") - const diffState = createMemo(() => data.store.message_diff_status?.[props.messageID] ?? "absent") + const diffState = createMemo( + () => data.store.message_diff_status?.[props.messageID] ?? "absent", + ) const active = createMemo(() => expanded().includes(diff.file)) const [shown, setShown] = createSignal(false) From 3bb648f6ff46acc774367d7b4fffd4f5762adef5 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:07:17 +0200 Subject: [PATCH 18/18] fix(ui): localize diff load states --- .../context/global-sync/event-reducer.test.ts | 1 + .../session-ui/src/components/session-turn.tsx | 18 ++++++++++++++---- packages/ui/src/i18n/am.ts | 2 ++ packages/ui/src/i18n/ar.ts | 2 ++ packages/ui/src/i18n/az.ts | 2 ++ packages/ui/src/i18n/bg.ts | 2 ++ packages/ui/src/i18n/bn.ts | 2 ++ packages/ui/src/i18n/br.ts | 2 ++ packages/ui/src/i18n/bs.ts | 2 ++ packages/ui/src/i18n/ca.ts | 2 ++ packages/ui/src/i18n/cs.ts | 2 ++ packages/ui/src/i18n/da.ts | 2 ++ packages/ui/src/i18n/de.ts | 2 ++ packages/ui/src/i18n/dv.ts | 2 ++ packages/ui/src/i18n/dz.ts | 2 ++ packages/ui/src/i18n/el.ts | 2 ++ packages/ui/src/i18n/en.ts | 6 ++---- packages/ui/src/i18n/es.ts | 2 ++ packages/ui/src/i18n/et.ts | 2 ++ packages/ui/src/i18n/fa.ts | 2 ++ packages/ui/src/i18n/fi.ts | 2 ++ packages/ui/src/i18n/fo.ts | 2 ++ packages/ui/src/i18n/fr.ts | 2 ++ packages/ui/src/i18n/hi.ts | 2 ++ packages/ui/src/i18n/hr.ts | 2 ++ packages/ui/src/i18n/hu.ts | 2 ++ packages/ui/src/i18n/hy.ts | 2 ++ packages/ui/src/i18n/id.ts | 2 ++ packages/ui/src/i18n/is.ts | 2 ++ packages/ui/src/i18n/it.ts | 2 ++ packages/ui/src/i18n/ja.ts | 2 ++ packages/ui/src/i18n/ka.ts | 2 ++ packages/ui/src/i18n/km.ts | 2 ++ packages/ui/src/i18n/ko.ts | 2 ++ packages/ui/src/i18n/lo.ts | 2 ++ packages/ui/src/i18n/lt.ts | 2 ++ packages/ui/src/i18n/lv.ts | 2 ++ packages/ui/src/i18n/mk.ts | 2 ++ packages/ui/src/i18n/mn.ts | 2 ++ packages/ui/src/i18n/ms.ts | 2 ++ packages/ui/src/i18n/my.ts | 2 ++ packages/ui/src/i18n/ne.ts | 2 ++ packages/ui/src/i18n/nl.ts | 2 ++ packages/ui/src/i18n/no.ts | 2 ++ packages/ui/src/i18n/pa.ts | 2 ++ packages/ui/src/i18n/pl.ts | 2 ++ packages/ui/src/i18n/ro.ts | 2 ++ packages/ui/src/i18n/ru.ts | 2 ++ packages/ui/src/i18n/si.ts | 2 ++ packages/ui/src/i18n/sk.ts | 2 ++ packages/ui/src/i18n/sl.ts | 2 ++ packages/ui/src/i18n/sq.ts | 2 ++ packages/ui/src/i18n/sr.ts | 2 ++ packages/ui/src/i18n/sv.ts | 2 ++ packages/ui/src/i18n/tg.ts | 2 ++ packages/ui/src/i18n/th.ts | 2 ++ packages/ui/src/i18n/tk.ts | 2 ++ packages/ui/src/i18n/tr.ts | 2 ++ packages/ui/src/i18n/uk.ts | 2 ++ packages/ui/src/i18n/ur.ts | 2 ++ packages/ui/src/i18n/uz.ts | 2 ++ packages/ui/src/i18n/vi.ts | 2 ++ packages/ui/src/i18n/zh.ts | 2 ++ packages/ui/src/i18n/zht.ts | 2 ++ 64 files changed, 139 insertions(+), 8 deletions(-) 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 39fca92a3586..7f1321dfd2a7 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -73,6 +73,7 @@ const baseState = (input: Partial = {}) => session_status: {}, session_diff: {}, message_diff: {}, + message_diff_status: {}, todo: {}, permission: {}, question: {}, diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index b53da12e9ffd..df7928c0cece 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -536,25 +536,35 @@ export function SessionTurn(
- {i18n.t("ui.sessionTurn.diffs.loading")} + + {i18n.t("ui.fileMedia.state.loading", { + kind: i18n.t("ui.fileMedia.kind.diff"), + })} +
- {i18n.t("ui.sessionTurn.diffs.failed")} + + {i18n.t("ui.fileMedia.state.error", { + kind: i18n.t("ui.fileMedia.kind.diff"), + })} +
- {i18n.t("ui.sessionTurn.diffs.unavailable")} + {i18n.t("ui.fileMedia.state.unavailable", { + kind: i18n.t("ui.fileMedia.kind.diff"), + })}
diff --git a/packages/ui/src/i18n/am.ts b/packages/ui/src/i18n/am.ts index 01d5cd425af1..72e398d56196 100644 --- a/packages/ui/src/i18n/am.ts +++ b/packages/ui/src/i18n/am.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "መስመሮች {{start}}-{{end}}", "ui.fileMedia.kind.image": "ምስል", "ui.fileMedia.kind.audio": "ድምጽ", + "ui.fileMedia.kind.diff": "ልዩነት", "ui.fileMedia.state.removed": "{{kind}}ፋይል ተወግዷል።", "ui.fileMedia.state.loading": "በመጫን ላይ {{kind}}...", "ui.fileMedia.state.error": "{{kind}}ን መጫን አልተቻለም።", @@ -162,6 +163,7 @@ export const dict: Record = { "ui.common.back": "ተመለስ", "ui.common.cancel": "ሰርዝ", "ui.common.confirm": "አረጋግጥ", + "ui.common.retry": "እንደገና ሞክር", "ui.common.dismiss": "አሰናብት", "ui.common.close": "ዝጋ", "ui.common.next": "ቀጣይ", diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index b23c24b5bff7..21b0e86e22bc 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -36,6 +36,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "ستظهر تغييرات المشروع هنا", "ui.fileMedia.kind.image": "صورة", "ui.fileMedia.kind.audio": "صوت", + "ui.fileMedia.kind.diff": "الفروقات", "ui.fileMedia.state.removed": "تمت إزالة {{kind}}", "ui.fileMedia.state.loading": "جارٍ تحميل {{kind}}...", "ui.fileMedia.state.error": "تعذر تحميل {{kind}}.", @@ -185,6 +186,7 @@ export const dict = { "ui.common.back": "رجوع", "ui.common.cancel": "إلغاء", "ui.common.confirm": "تأكيد", + "ui.common.retry": "إعادة المحاولة", "ui.common.dismiss": "إهمال", "ui.common.close": "إغلاق", "ui.common.next": "التالي", diff --git a/packages/ui/src/i18n/az.ts b/packages/ui/src/i18n/az.ts index d5fa9cb250a2..d8d8a13b1eca 100644 --- a/packages/ui/src/i18n/az.ts +++ b/packages/ui/src/i18n/az.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "sətirlər {{start}}-{{end}}", "ui.fileMedia.kind.image": "şəkil", "ui.fileMedia.kind.audio": "səs", + "ui.fileMedia.kind.diff": "fərq", "ui.fileMedia.state.removed": "{{kind}} faylı silindi.", "ui.fileMedia.state.loading": "{{kind}} yüklənir...", "ui.fileMedia.state.error": "{{kind}} yüklənə bilmədi.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Geri", "ui.common.cancel": "Ləğv et", "ui.common.confirm": "Təsdiqlə", + "ui.common.retry": "Yenidən cəhd et", "ui.common.dismiss": "Bağla", "ui.common.close": "Bağla", "ui.common.next": "Növbəti", diff --git a/packages/ui/src/i18n/bg.ts b/packages/ui/src/i18n/bg.ts index ea10ab598739..4c72531ab320 100644 --- a/packages/ui/src/i18n/bg.ts +++ b/packages/ui/src/i18n/bg.ts @@ -36,6 +36,7 @@ export const dict = { "ui.sessionReview.selection.lines": "редове {{start}}-{{end}}", "ui.fileMedia.kind.image": "изображение", "ui.fileMedia.kind.audio": "аудио", + "ui.fileMedia.kind.diff": "разлики", "ui.fileMedia.state.removed": "Премахнат {{kind}} файл.", "ui.fileMedia.state.loading": "Зарежда се {{kind}}...", "ui.fileMedia.state.error": "Не може да се зареди {{kind}}.", @@ -163,6 +164,7 @@ export const dict = { "ui.common.back": "Назад", "ui.common.cancel": "Отказ", "ui.common.confirm": "Потвърдете", + "ui.common.retry": "Опитай отново", "ui.common.dismiss": "Отхвърляне", "ui.common.close": "затвори", "ui.common.next": "Следваща", diff --git a/packages/ui/src/i18n/bn.ts b/packages/ui/src/i18n/bn.ts index 74a6940586f6..c038d8747503 100644 --- a/packages/ui/src/i18n/bn.ts +++ b/packages/ui/src/i18n/bn.ts @@ -38,6 +38,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "লাইন {{start}}-{{end}}", "ui.fileMedia.kind.image": "ইমেজ", "ui.fileMedia.kind.audio": "অডিও", + "ui.fileMedia.kind.diff": "পার্থক্য", "ui.fileMedia.state.removed": "{{kind}} ফাইল সরানো হয়েছে।", "ui.fileMedia.state.loading": "{{kind}} লোড হচ্ছে...", "ui.fileMedia.state.error": "{{kind}} লোড করতে অক্ষম৷", @@ -165,6 +166,7 @@ export const dict: Record = { "ui.common.back": "ব্যাক", "ui.common.cancel": "বাতিল করুন", "ui.common.confirm": "নিশ্চিত করুন", + "ui.common.retry": "পুনরায় চেষ্টা করুন", "ui.common.dismiss": "খারিজ", "ui.common.close": "বন্ধ", "ui.common.next": "পরবর্তী", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 19fc68365530..ff9b99109774 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -36,6 +36,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "As alterações do projeto aparecerão aqui", "ui.fileMedia.kind.image": "imagem", "ui.fileMedia.kind.audio": "áudio", + "ui.fileMedia.kind.diff": "diforc'h", "ui.fileMedia.state.removed": "Arquivo de {{kind}} removido.", "ui.fileMedia.state.loading": "Carregando {{kind}}...", "ui.fileMedia.state.error": "Não foi possível carregar {{kind}}.", @@ -173,6 +174,7 @@ export const dict = { "ui.common.back": "Voltar", "ui.common.cancel": "Cancelar", "ui.common.confirm": "Confirmar", + "ui.common.retry": "Adklask", "ui.common.dismiss": "Descartar", "ui.common.close": "Fechar", "ui.common.next": "Próximo", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 5b33f068b4b9..07e288bbb059 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -40,6 +40,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "Promjene projekta će se prikazati ovdje", "ui.fileMedia.kind.image": "slika", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "razlike", "ui.fileMedia.state.removed": "Uklonjena datoteka vrste {{kind}}.", "ui.fileMedia.state.loading": "Učitavanje datoteke vrste {{kind}}...", "ui.fileMedia.state.error": "Nije moguće učitati datoteku vrste {{kind}}.", @@ -177,6 +178,7 @@ export const dict = { "ui.common.back": "Nazad", "ui.common.cancel": "Otkaži", "ui.common.confirm": "Potvrdi", + "ui.common.retry": "Pokušaj ponovo", "ui.common.dismiss": "Odbaci", "ui.common.close": "Zatvori", "ui.common.next": "Dalje", diff --git a/packages/ui/src/i18n/ca.ts b/packages/ui/src/i18n/ca.ts index 1141fa404e17..1c0bdf2504eb 100644 --- a/packages/ui/src/i18n/ca.ts +++ b/packages/ui/src/i18n/ca.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "línies {{start}}-{{end}}", "ui.fileMedia.kind.image": "imatge", "ui.fileMedia.kind.audio": "àudio", + "ui.fileMedia.kind.diff": "diferències", "ui.fileMedia.state.removed": "S'ha eliminat el fitxer {{kind}}.", "ui.fileMedia.state.loading": "S'està carregant {{kind}}...", "ui.fileMedia.state.error": "No es pot carregar {{kind}}.", @@ -167,6 +168,7 @@ export const dict: Record = { "ui.common.back": "Enrere", "ui.common.cancel": "Cancel·la", "ui.common.confirm": "Confirmeu", + "ui.common.retry": "Torna-ho a provar", "ui.common.dismiss": "Descartar", "ui.common.close": "Tancar", "ui.common.next": "A continuació", diff --git a/packages/ui/src/i18n/cs.ts b/packages/ui/src/i18n/cs.ts index 58f508cd7945..15bd9488c59b 100644 --- a/packages/ui/src/i18n/cs.ts +++ b/packages/ui/src/i18n/cs.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "řádky {{start}}-{{end}}", "ui.fileMedia.kind.image": "obrázek", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "rozdíly", "ui.fileMedia.state.removed": "Byl odstraněn soubor {{kind}}.", "ui.fileMedia.state.loading": "Načítání {{kind}}...", "ui.fileMedia.state.error": "Nelze načíst {{kind}}.", @@ -171,6 +172,7 @@ export const dict: Record = { "ui.common.back": "Zpět", "ui.common.cancel": "Zrušit", "ui.common.confirm": "Potvrďte", + "ui.common.retry": "Zkusit znovu", "ui.common.dismiss": "Odmítnout", "ui.common.close": "Zavřít", "ui.common.next": "Další", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index a3f256c7b4ff..6475ce6d7eb7 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -37,6 +37,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "Projektændringer vises her", "ui.fileMedia.kind.image": "billede", "ui.fileMedia.kind.audio": "lyd", + "ui.fileMedia.kind.diff": "diff", "ui.fileMedia.state.removed": "{{kind}} fjernet", "ui.fileMedia.state.loading": "Indlæser {{kind}}...", "ui.fileMedia.state.error": "Kunne ikke indlæse {{kind}}", @@ -148,6 +149,7 @@ export const dict = { "ui.common.back": "Tilbage", "ui.common.cancel": "Annuller", "ui.common.confirm": "Bekræft", + "ui.common.retry": "Prøv igen", "ui.common.dismiss": "Afvis", "ui.common.close": "Luk", "ui.common.next": "Næste", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index ca86cba28ce7..a790f81adeea 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -42,6 +42,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "Projektänderungen werden hier angezeigt", "ui.fileMedia.kind.image": "Bild", "ui.fileMedia.kind.audio": "Audio", + "ui.fileMedia.kind.diff": "Diff", "ui.fileMedia.state.removed": "{{kind}} entfernt.", "ui.fileMedia.state.loading": "{{kind}} wird geladen…", "ui.fileMedia.state.error": "{{kind}} konnte nicht geladen werden.", @@ -155,6 +156,7 @@ export const dict = { "ui.common.back": "Zurück", "ui.common.cancel": "Abbrechen", "ui.common.confirm": "Bestätigen", + "ui.common.retry": "Erneut versuchen", "ui.common.dismiss": "Verwerfen", "ui.common.close": "Schließen", "ui.common.next": "Weiter", diff --git a/packages/ui/src/i18n/dv.ts b/packages/ui/src/i18n/dv.ts index 9b7f26e8f48c..2131fd5a8a23 100644 --- a/packages/ui/src/i18n/dv.ts +++ b/packages/ui/src/i18n/dv.ts @@ -37,6 +37,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "ލައިންތައް {{start}}-{{end}}", "ui.fileMedia.kind.image": "ފޮޓޯ", "ui.fileMedia.kind.audio": "އޯޑިއޯ އެވެ", + "ui.fileMedia.kind.diff": "ތަފާތު", "ui.fileMedia.state.removed": "{{kind}} ފައިލް ނައްތާލެވިއްޖެއެވެ.", "ui.fileMedia.state.loading": "ލޯޑިންގ {{kind}}...", "ui.fileMedia.state.error": "{{kind}} ލޯޑް ނުކުރެވޭ.", @@ -164,6 +165,7 @@ export const dict: Record = { "ui.common.back": "ފަހަތް", "ui.common.cancel": "ކެންސަލް", "ui.common.confirm": "ޔަޤީންކުރުން", + "ui.common.retry": "އަލުން މަސައްކަތްކުރޭ", "ui.common.dismiss": "ދުރުކޮށްލުން", "ui.common.close": "ލެއްޕުން", "ui.common.next": "ދެން", diff --git a/packages/ui/src/i18n/dz.ts b/packages/ui/src/i18n/dz.ts index 545be671e75e..b39361c172af 100644 --- a/packages/ui/src/i18n/dz.ts +++ b/packages/ui/src/i18n/dz.ts @@ -38,6 +38,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "གྲལ་ཐིག་ {{start}}-{{end}}", "ui.fileMedia.kind.image": "པར་", "ui.fileMedia.kind.audio": "སྒྲ།", + "ui.fileMedia.kind.diff": "ཁྱད་པར།", "ui.fileMedia.state.removed": "{{kind}}ཡིག་སྣོད་རྩ་བསྐྲད་གཏང་ཡོདཔ།", "ui.fileMedia.state.loading": "མངོན་གསལ་འབད་དོ {{kind}}...", "ui.fileMedia.state.error": "{{kind}}མངོན་གསལ་འབད་མ་ཚུགས།", @@ -165,6 +166,7 @@ export const dict: Record = { "ui.common.back": "ལོག་འོང་ནི", "ui.common.cancel": "ཆ་མེད་བཏང་པ་", "ui.common.confirm": "གཏན་འཁེལ་བཟོ་ནི", + "ui.common.retry": "སླར་འབད་བརྩོན།", "ui.common.dismiss": "ཁུངས༌མེད༌བརྩི༌ནི", "ui.common.close": "སྒོ་བསྡམ", "ui.common.next": "ཤུལ༌མའི", diff --git a/packages/ui/src/i18n/el.ts b/packages/ui/src/i18n/el.ts index 0c9971489b3b..e16551c2c380 100644 --- a/packages/ui/src/i18n/el.ts +++ b/packages/ui/src/i18n/el.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "γραμμές {{start}}-{{end}}", "ui.fileMedia.kind.image": "εικόνα", "ui.fileMedia.kind.audio": "ήχος", + "ui.fileMedia.kind.diff": "διαφορές", "ui.fileMedia.state.removed": "Καταργήθηκε {{kind}} αρχείο.", "ui.fileMedia.state.loading": "Φόρτωση {{kind}}...", "ui.fileMedia.state.error": "Δεν είναι δυνατή η φόρτωση του {{kind}}.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Πίσω", "ui.common.cancel": "Ακύρωση", "ui.common.confirm": "Επιβεβαίωση", + "ui.common.retry": "Επανάληψη", "ui.common.dismiss": "Παράβλεψη", "ui.common.close": "Κλείσιμο", "ui.common.next": "Επόμενο", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 14f91be6ac01..0ed6e355f64c 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -38,6 +38,7 @@ export const dict: Record = { "ui.fileMedia.kind.image": "image", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "diff", "ui.fileMedia.state.removed": "Removed {{kind}} file.", "ui.fileMedia.state.loading": "Loading {{kind}}...", "ui.fileMedia.state.error": "Unable to load {{kind}}.", @@ -64,10 +65,6 @@ export const dict: Record = { "ui.sessionTurn.diffs.showAll": "Show all", "ui.sessionTurn.diffs.showLess": "Show less", "ui.sessionTurn.diffs.more": "+{{count}} more files", - "ui.sessionTurn.diffs.loading": "Loading diff", - "ui.sessionTurn.diffs.failed": "Couldn't load diff", - "ui.sessionTurn.diffs.retry": "Retry", - "ui.sessionTurn.diffs.unavailable": "Diff is no longer available", "ui.sessionTurn.retry.retrying": "retrying", "ui.sessionTurn.retry.inSeconds": "in {{seconds}}s", @@ -186,6 +183,7 @@ export const dict: Record = { "ui.common.back": "Back", "ui.common.cancel": "Cancel", "ui.common.confirm": "Confirm", + "ui.common.retry": "Retry", "ui.common.dismiss": "Dismiss", "ui.common.close": "Close", "ui.common.next": "Next", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 07d3f42435d8..790852bfc186 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -36,6 +36,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "Los cambios del proyecto aparecerán aquí", "ui.fileMedia.kind.image": "imagen", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "diff", "ui.fileMedia.state.removed": "Archivo de {{kind}} eliminado", "ui.fileMedia.state.loading": "Cargando archivo de {{kind}}", "ui.fileMedia.state.error": "Error en el archivo de {{kind}}", @@ -173,6 +174,7 @@ export const dict = { "ui.common.back": "Atrás", "ui.common.cancel": "Cancelar", "ui.common.confirm": "Confirmar", + "ui.common.retry": "Reintentar", "ui.common.dismiss": "Descartar", "ui.common.close": "Cerrar", "ui.common.next": "Siguiente", diff --git a/packages/ui/src/i18n/et.ts b/packages/ui/src/i18n/et.ts index 5c548c84ec5e..3ef2add568ee 100644 --- a/packages/ui/src/i18n/et.ts +++ b/packages/ui/src/i18n/et.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "read {{start}}-{{end}}", "ui.fileMedia.kind.image": "pilt", "ui.fileMedia.kind.audio": "heli", + "ui.fileMedia.kind.diff": "erinevused", "ui.fileMedia.state.removed": "Eemaldatud {{kind}} fail.", "ui.fileMedia.state.loading": "Laadimine {{kind}}...", "ui.fileMedia.state.error": "Ei saa laadida {{kind}}.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Tagasi", "ui.common.cancel": "Tühista", "ui.common.confirm": "Kinnita", + "ui.common.retry": "Proovi uuesti", "ui.common.dismiss": "Loobu", "ui.common.close": "Sule", "ui.common.next": "Edasi", diff --git a/packages/ui/src/i18n/fa.ts b/packages/ui/src/i18n/fa.ts index 54b356d10e31..0051726d6efc 100644 --- a/packages/ui/src/i18n/fa.ts +++ b/packages/ui/src/i18n/fa.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "خطوط {{start}}-{{end}}", "ui.fileMedia.kind.image": "تصویر", "ui.fileMedia.kind.audio": "صوتی", + "ui.fileMedia.kind.diff": "تفاوت‌ها", "ui.fileMedia.state.removed": "فایل {{kind}} حذف شد.", "ui.fileMedia.state.loading": "در حال بارگیری {{kind}}...", "ui.fileMedia.state.error": "بارگیری {{kind}} ممکن نیست.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "برگشت", "ui.common.cancel": "لغو کنید", "ui.common.confirm": "تایید کنید", + "ui.common.retry": "تلاش مجدد", "ui.common.dismiss": "رد کردن", "ui.common.close": "بستن", "ui.common.next": "بعدی", diff --git a/packages/ui/src/i18n/fi.ts b/packages/ui/src/i18n/fi.ts index d3a26cb439a8..c7b720e166a0 100644 --- a/packages/ui/src/i18n/fi.ts +++ b/packages/ui/src/i18n/fi.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "rivit {{start}}-{{end}}", "ui.fileMedia.kind.image": "Kuva", "ui.fileMedia.kind.audio": "Ääni", + "ui.fileMedia.kind.diff": "diff", "ui.fileMedia.state.removed": "{{kind}}: tiedosto poistettu.", "ui.fileMedia.state.loading": "{{kind}}: ladataan...", "ui.fileMedia.state.error": "{{kind}}: lataaminen epäonnistui.", @@ -145,6 +146,7 @@ export const dict: Record = { "ui.common.back": "Takaisin", "ui.common.cancel": "Peruuta", "ui.common.confirm": "Vahvista", + "ui.common.retry": "Yritä uudelleen", "ui.common.dismiss": "Hylkää", "ui.common.close": "Sulje", "ui.common.next": "Seuraava", diff --git a/packages/ui/src/i18n/fo.ts b/packages/ui/src/i18n/fo.ts index 098ea8f5b85f..72be4ab3ec05 100644 --- a/packages/ui/src/i18n/fo.ts +++ b/packages/ui/src/i18n/fo.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "linjur {{start}}-{{end}}", "ui.fileMedia.kind.image": "mynd", "ui.fileMedia.kind.audio": "ljóð", + "ui.fileMedia.kind.diff": "diff", "ui.fileMedia.state.removed": "Strikað {{kind}} fíla.", "ui.fileMedia.state.loading": "Heinta {{kind}}...", "ui.fileMedia.state.error": "Ikki ber til at heinta {{kind}}.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Aftur", "ui.common.cancel": "Avlýs", "ui.common.confirm": "Staðfest", + "ui.common.retry": "Royn aftur", "ui.common.dismiss": "Lat aftur", "ui.common.close": "Lat aftur", "ui.common.next": "Næsta", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index c008c2a34fda..28961bab96c3 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -37,6 +37,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "Les modifications du projet apparaîtront ici", "ui.fileMedia.kind.image": "image", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "diff", "ui.fileMedia.state.removed": "Fichier {{kind}} supprimé", "ui.fileMedia.state.loading": "Chargement du fichier {{kind}}", "ui.fileMedia.state.error": "Impossible de charger le fichier {{kind}}", @@ -175,6 +176,7 @@ export const dict = { "ui.common.back": "Retour", "ui.common.cancel": "Annuler", "ui.common.confirm": "Confirmer", + "ui.common.retry": "Réessayer", "ui.common.dismiss": "Ignorer", "ui.common.close": "Fermer", "ui.common.next": "Suivant", diff --git a/packages/ui/src/i18n/hi.ts b/packages/ui/src/i18n/hi.ts index 6da92d275a4a..72c2294ee380 100644 --- a/packages/ui/src/i18n/hi.ts +++ b/packages/ui/src/i18n/hi.ts @@ -37,6 +37,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "लाइनें {{start}}-{{end}}", "ui.fileMedia.kind.image": "छवि", "ui.fileMedia.kind.audio": "ऑडियो", + "ui.fileMedia.kind.diff": "अंतर", "ui.fileMedia.state.removed": "{{kind}} फ़ाइल हटा दी गई।", "ui.fileMedia.state.loading": "{{kind}} लोड हो रहा है...", "ui.fileMedia.state.error": "{{kind}} लोड नहीं किया जा सका।", @@ -165,6 +166,7 @@ export const dict: Record = { "ui.common.back": "पीछे", "ui.common.cancel": "रद्द करें", "ui.common.confirm": "पुष्टि करें", + "ui.common.retry": "पुनः प्रयास करें", "ui.common.dismiss": "हटाएँ", "ui.common.close": "बंद करें", "ui.common.next": "अगला", diff --git a/packages/ui/src/i18n/hr.ts b/packages/ui/src/i18n/hr.ts index 4eb129c9ef44..5bf06e4003e9 100644 --- a/packages/ui/src/i18n/hr.ts +++ b/packages/ui/src/i18n/hr.ts @@ -37,6 +37,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "linije {{start}}-{{end}}", "ui.fileMedia.kind.image": "slika", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "razlike", "ui.fileMedia.state.removed": "Uklonjena {{kind}} datoteka.", "ui.fileMedia.state.loading": "Učitavanje {{kind}}...", "ui.fileMedia.state.error": "Nije moguće učitati {{kind}}.", @@ -168,6 +169,7 @@ export const dict: Record = { "ui.common.back": "Nazad", "ui.common.cancel": "Otkazati", "ui.common.confirm": "Potvrdi", + "ui.common.retry": "Pokušaj ponovno", "ui.common.dismiss": "Odbaciti", "ui.common.close": "Zatvoriti", "ui.common.next": "Sljedeći", diff --git a/packages/ui/src/i18n/hu.ts b/packages/ui/src/i18n/hu.ts index b4d61b4f5e07..e6abfab50c16 100644 --- a/packages/ui/src/i18n/hu.ts +++ b/packages/ui/src/i18n/hu.ts @@ -38,6 +38,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "{{start}}-{{end}}. sor", "ui.fileMedia.kind.image": "kép", "ui.fileMedia.kind.audio": "hang-", + "ui.fileMedia.kind.diff": "eltérések", "ui.fileMedia.state.removed": "{{kind}} fájl eltávolítva.", "ui.fileMedia.state.loading": "{{kind}} betöltése...", "ui.fileMedia.state.error": "Nem sikerült betölteni a {{kind}}-t.", @@ -165,6 +166,7 @@ export const dict: Record = { "ui.common.back": "Vissza", "ui.common.cancel": "Mégsem", "ui.common.confirm": "Erősítse meg", + "ui.common.retry": "Újrapróbálkozás", "ui.common.dismiss": "Elvetés", "ui.common.close": "Bezárás", "ui.common.next": "Következő", diff --git a/packages/ui/src/i18n/hy.ts b/packages/ui/src/i18n/hy.ts index 39f073c6e56c..65f4deab6924 100644 --- a/packages/ui/src/i18n/hy.ts +++ b/packages/ui/src/i18n/hy.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "տողեր {{start}}-{{end}}", "ui.fileMedia.kind.image": "պատկեր", "ui.fileMedia.kind.audio": "ձայն", + "ui.fileMedia.kind.diff": "տարբերություններ", "ui.fileMedia.state.removed": "Հեռացվել է {{kind}} ֆայլ։", "ui.fileMedia.state.loading": "Բեռնվում է {{kind}}...", "ui.fileMedia.state.error": "Անհնար է բեռնել {{kind}}։", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Հետ", "ui.common.cancel": "Չեղարկել", "ui.common.confirm": "Հաստատել", + "ui.common.retry": "Կրկնել", "ui.common.dismiss": "Փակել", "ui.common.close": "Փակել", "ui.common.next": "Հաջորդ", diff --git a/packages/ui/src/i18n/id.ts b/packages/ui/src/i18n/id.ts index 22e4d46223f6..0f71c5bff43f 100644 --- a/packages/ui/src/i18n/id.ts +++ b/packages/ui/src/i18n/id.ts @@ -37,6 +37,7 @@ export const dict: Record = { "ui.fileMedia.kind.image": "gambar", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "diff", "ui.fileMedia.state.removed": "Berkas {{kind}} dihapus.", "ui.fileMedia.state.loading": "Memuat {{kind}}...", "ui.fileMedia.state.error": "Tidak dapat memuat {{kind}}.", @@ -181,6 +182,7 @@ export const dict: Record = { "ui.common.back": "Kembali", "ui.common.cancel": "Batal", "ui.common.confirm": "Konfirmasi", + "ui.common.retry": "Coba lagi", "ui.common.dismiss": "Tutup", "ui.common.close": "Tutup", "ui.common.next": "Berikutnya", diff --git a/packages/ui/src/i18n/is.ts b/packages/ui/src/i18n/is.ts index 7b3bf0fb2e4c..4cbb926e72e5 100644 --- a/packages/ui/src/i18n/is.ts +++ b/packages/ui/src/i18n/is.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "línur {{start}}-{{end}}", "ui.fileMedia.kind.image": "mynd", "ui.fileMedia.kind.audio": "hljóð", + "ui.fileMedia.kind.diff": "mismunur", "ui.fileMedia.state.removed": "Fjarlægði {{kind}} skrá.", "ui.fileMedia.state.loading": "Hleður {{kind}}...", "ui.fileMedia.state.error": "Ekki hægt að hlaða {{kind}}.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Til baka", "ui.common.cancel": "Hætta við", "ui.common.confirm": "Staðfesta", + "ui.common.retry": "Reyna aftur", "ui.common.dismiss": "Loka", "ui.common.close": "Loka", "ui.common.next": "Næst", diff --git a/packages/ui/src/i18n/it.ts b/packages/ui/src/i18n/it.ts index 9d104a677532..b2fb4d45a787 100644 --- a/packages/ui/src/i18n/it.ts +++ b/packages/ui/src/i18n/it.ts @@ -37,6 +37,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "righe {{start}}-{{end}}", "ui.fileMedia.kind.image": "immagine", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "diff", "ui.fileMedia.state.removed": "File {{kind}} rimosso.", "ui.fileMedia.state.loading": "Caricamento di {{kind}}...", "ui.fileMedia.state.error": "Impossibile caricare {{kind}}.", @@ -168,6 +169,7 @@ export const dict: Record = { "ui.common.back": "Indietro", "ui.common.cancel": "Annulla", "ui.common.confirm": "Conferma", + "ui.common.retry": "Riprova", "ui.common.dismiss": "Ignora", "ui.common.close": "Chiudi", "ui.common.next": "Avanti", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 1b32e05debc5..d7c5434a4008 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -37,6 +37,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "プロジェクトの変更がここに表示されます", "ui.fileMedia.kind.image": "画像", "ui.fileMedia.kind.audio": "音声", + "ui.fileMedia.kind.diff": "差分", "ui.fileMedia.state.removed": "{{kind}}は削除されました", "ui.fileMedia.state.loading": "{{kind}}を読み込んでいます...", "ui.fileMedia.state.error": "{{kind}}の読み込みに失敗しました", @@ -168,6 +169,7 @@ export const dict = { "ui.common.back": "戻る", "ui.common.cancel": "キャンセル", "ui.common.confirm": "確認", + "ui.common.retry": "再試行", "ui.common.dismiss": "閉じる", "ui.common.close": "閉じる", "ui.common.next": "次へ", diff --git a/packages/ui/src/i18n/ka.ts b/packages/ui/src/i18n/ka.ts index 68f19bc00d45..a2e2afb38fed 100644 --- a/packages/ui/src/i18n/ka.ts +++ b/packages/ui/src/i18n/ka.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "ხაზები {{start}}-{{end}}", "ui.fileMedia.kind.image": "სურათი", "ui.fileMedia.kind.audio": "აუდიო", + "ui.fileMedia.kind.diff": "განსხვავებები", "ui.fileMedia.state.removed": "ამოღებულია {{kind}} ფაილი.", "ui.fileMedia.state.loading": "იტვირთება {{kind}}...", "ui.fileMedia.state.error": "ვერ იტვირთება {{kind}}.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "უკან", "ui.common.cancel": "გაუქმება", "ui.common.confirm": "დაადასტურეთ", + "ui.common.retry": "ხელახლა ცდა", "ui.common.dismiss": "დატოვება", "ui.common.close": "დახურვა", "ui.common.next": "შემდეგი", diff --git a/packages/ui/src/i18n/km.ts b/packages/ui/src/i18n/km.ts index b50e9c53e7bf..d9b97923ddac 100644 --- a/packages/ui/src/i18n/km.ts +++ b/packages/ui/src/i18n/km.ts @@ -37,6 +37,7 @@ export const dict = { "ui.sessionReview.selection.lines": "បន្ទាត់ {{start}}-{{end}}\nរូបភាព", "ui.fileMedia.kind.image": "រូបភាព", "ui.fileMedia.kind.audio": "សំឡេង", + "ui.fileMedia.kind.diff": "ភាពខុសគ្នា", "ui.fileMedia.state.removed": "បានលុបឯកសារ {{kind}} ។", "ui.fileMedia.state.loading": "កំពុងផ្ទុក {{kind}}...", "ui.fileMedia.state.error": "មិនអាចផ្ទុក {{kind}} បានទេ។", @@ -164,6 +165,7 @@ export const dict = { "ui.common.back": "ត្រឡប់មកវិញ", "ui.common.cancel": "បោះបង់", "ui.common.confirm": "បញ្ជាក់", + "ui.common.retry": "ព្យាយាមម្តងទៀត", "ui.common.dismiss": "ច្រានចោល", "ui.common.close": "បិទ", "ui.common.next": "បន្ទាប់", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 942fa873dd26..fdf3b6349b01 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -18,6 +18,7 @@ export const dict = { "ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링", "ui.fileMedia.kind.image": "이미지", "ui.fileMedia.kind.audio": "오디오", + "ui.fileMedia.kind.diff": "차이점", "ui.fileMedia.state.removed": "{{kind}} 제거됨", "ui.fileMedia.state.loading": "{{kind}} 로드 중...", "ui.fileMedia.state.error": "{{kind}} 로드 오류", @@ -145,6 +146,7 @@ export const dict = { "ui.common.back": "뒤로", "ui.common.cancel": "취소", "ui.common.confirm": "확인", + "ui.common.retry": "다시 시도", "ui.common.dismiss": "닫기", "ui.common.close": "닫기", "ui.common.next": "다음", diff --git a/packages/ui/src/i18n/lo.ts b/packages/ui/src/i18n/lo.ts index 5053377f33d0..75f1f3a30028 100644 --- a/packages/ui/src/i18n/lo.ts +++ b/packages/ui/src/i18n/lo.ts @@ -36,6 +36,7 @@ export const dict = { "ui.sessionReview.selection.lines": "ສາຍ {{start}}-{{end}}", "ui.fileMedia.kind.image": "ຮູບ", "ui.fileMedia.kind.audio": "ສຽງ", + "ui.fileMedia.kind.diff": "ຄວາມແຕກຕ່າງ", "ui.fileMedia.state.removed": "ລຶບໄຟລ໌ {{kind}} ອອກແລ້ວ.", "ui.fileMedia.state.loading": "ກຳລັງໂຫລດ {{kind}}...", "ui.fileMedia.state.error": "ບໍ່ສາມາດໂຫຼດ {{kind}} ໄດ້.", @@ -163,6 +164,7 @@ export const dict = { "ui.common.back": "ກັບ", "ui.common.cancel": "ຍົກເລີກ", "ui.common.confirm": "ຢືນຢັນ", + "ui.common.retry": "ລອງໃໝ່", "ui.common.dismiss": "ປິດ", "ui.common.close": "ປິດ", "ui.common.next": "ຕໍ່ໄປ", diff --git a/packages/ui/src/i18n/lt.ts b/packages/ui/src/i18n/lt.ts index da2e30d96222..2e6b58538ce1 100644 --- a/packages/ui/src/i18n/lt.ts +++ b/packages/ui/src/i18n/lt.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "linijos {{start}}-{{end}}", "ui.fileMedia.kind.image": "vaizdas", "ui.fileMedia.kind.audio": "garso", + "ui.fileMedia.kind.diff": "skirtumai", "ui.fileMedia.state.removed": "Pašalintas {{kind}} failas.", "ui.fileMedia.state.loading": "Įkeliamas {{kind}}...", "ui.fileMedia.state.error": "Nepavyko įkelti {{kind}}.", @@ -171,6 +172,7 @@ export const dict: Record = { "ui.common.back": "Atgal", "ui.common.cancel": "Atšaukti", "ui.common.confirm": "Patvirtinti", + "ui.common.retry": "Bandyti dar kartą", "ui.common.dismiss": "Atsisakyti", "ui.common.close": "Uždaryti", "ui.common.next": "Kitas", diff --git a/packages/ui/src/i18n/lv.ts b/packages/ui/src/i18n/lv.ts index 774b1af1bce7..c19641f7a2bd 100644 --- a/packages/ui/src/i18n/lv.ts +++ b/packages/ui/src/i18n/lv.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "rindas {{start}}–{{end}}", "ui.fileMedia.kind.image": "attēls", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "atšķirības", "ui.fileMedia.state.removed": "Noņemts {{kind}} fails.", "ui.fileMedia.state.loading": "Notiek {{kind}} ielāde...", "ui.fileMedia.state.error": "Neizdevās ielādēt {{kind}}.", @@ -167,6 +168,7 @@ export const dict: Record = { "ui.common.back": "Atpakaļ", "ui.common.cancel": "Atcelt", "ui.common.confirm": "Apstiprināt", + "ui.common.retry": "Mēģināt vēlreiz", "ui.common.dismiss": "Aizvērt", "ui.common.close": "Aizvērt", "ui.common.next": "Tālāk", diff --git a/packages/ui/src/i18n/mk.ts b/packages/ui/src/i18n/mk.ts index d154cac1a9db..61daab0bd5a5 100644 --- a/packages/ui/src/i18n/mk.ts +++ b/packages/ui/src/i18n/mk.ts @@ -36,6 +36,7 @@ export const dict = { "ui.sessionReview.selection.lines": "линии {{start}}-{{end}}", "ui.fileMedia.kind.image": "слика", "ui.fileMedia.kind.audio": "аудио", + "ui.fileMedia.kind.diff": "разлики", "ui.fileMedia.state.removed": "Отстранета {{kind}} датотека.", "ui.fileMedia.state.loading": "Се вчитува {{kind}}...", "ui.fileMedia.state.error": "Не може да се вчита {{kind}}.", @@ -163,6 +164,7 @@ export const dict = { "ui.common.back": "Назад", "ui.common.cancel": "Откажи", "ui.common.confirm": "Потврди", + "ui.common.retry": "Обиди се повторно", "ui.common.dismiss": "Отфрли", "ui.common.close": "Затвори", "ui.common.next": "Следно", diff --git a/packages/ui/src/i18n/mn.ts b/packages/ui/src/i18n/mn.ts index dafe0067da6c..9ca72c535284 100644 --- a/packages/ui/src/i18n/mn.ts +++ b/packages/ui/src/i18n/mn.ts @@ -36,6 +36,7 @@ export const dict = { "ui.sessionReview.selection.lines": "мөр {{start}}-{{end}}", "ui.fileMedia.kind.image": "зураг", "ui.fileMedia.kind.audio": "аудио", + "ui.fileMedia.kind.diff": "ялгаа", "ui.fileMedia.state.removed": "{{kind}} файлыг устгасан.", "ui.fileMedia.state.loading": "{{kind}} ачаалж байна...", "ui.fileMedia.state.error": "{{kind}} ачаалах боломжгүй.", @@ -163,6 +164,7 @@ export const dict = { "ui.common.back": "Буцах", "ui.common.cancel": "Цуцлах", "ui.common.confirm": "Баталгаажуулах", + "ui.common.retry": "Дахин оролдох", "ui.common.dismiss": "Хаах", "ui.common.close": "Хаах", "ui.common.next": "Дараа нь", diff --git a/packages/ui/src/i18n/ms.ts b/packages/ui/src/i18n/ms.ts index f71223f17696..cad174be16b6 100644 --- a/packages/ui/src/i18n/ms.ts +++ b/packages/ui/src/i18n/ms.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "baris {{start}}-{{end}}", "ui.fileMedia.kind.image": "imej", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "perbezaan", "ui.fileMedia.state.removed": "Fail {{kind}} telah dipadam.", "ui.fileMedia.state.loading": "Memuatkan {{kind}}...", "ui.fileMedia.state.error": "Tidak dapat memuatkan {{kind}}.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Kembali", "ui.common.cancel": "Batal", "ui.common.confirm": "Sahkan", + "ui.common.retry": "Cuba lagi", "ui.common.dismiss": "Abaikan", "ui.common.close": "Tutup", "ui.common.next": "Seterusnya", diff --git a/packages/ui/src/i18n/my.ts b/packages/ui/src/i18n/my.ts index eab3b950e6ab..d227418b1396 100644 --- a/packages/ui/src/i18n/my.ts +++ b/packages/ui/src/i18n/my.ts @@ -37,6 +37,7 @@ export const dict = { "ui.sessionReview.selection.lines": "လိုင်းများ {{start}}-{{end}}", "ui.fileMedia.kind.image": "ပုံ", "ui.fileMedia.kind.audio": "အသံ", + "ui.fileMedia.kind.diff": "ကွာခြားချက်", "ui.fileMedia.state.removed": "{{kind}} ဖိုင်ကို ဖယ်ရှားခဲ့သည်။", "ui.fileMedia.state.loading": "{{kind}} ကို ဖွင့်နေသည်...", "ui.fileMedia.state.error": "{{kind}} ကို ဖွင့်၍မရပါ။", @@ -164,6 +165,7 @@ export const dict = { "ui.common.back": "နောက်ပြန်", "ui.common.cancel": "မလုပ်တော့", "ui.common.confirm": "အတည်ပြုပါ။", + "ui.common.retry": "ထပ်ကြိုးစားပါ", "ui.common.dismiss": "ပယ်ရန်", "ui.common.close": "ပိတ်ပါ။", "ui.common.next": "နောက်တစ်ခု", diff --git a/packages/ui/src/i18n/ne.ts b/packages/ui/src/i18n/ne.ts index bc3f8408e583..c28cd3cef468 100644 --- a/packages/ui/src/i18n/ne.ts +++ b/packages/ui/src/i18n/ne.ts @@ -38,6 +38,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "रेखाहरू {{start}}-{{end}}", "ui.fileMedia.kind.image": "छवि", "ui.fileMedia.kind.audio": "अडियो", + "ui.fileMedia.kind.diff": "भिन्नता", "ui.fileMedia.state.removed": "{{kind}} फाइल हटाइयो।", "ui.fileMedia.state.loading": "{{kind}} लोड गर्दै...", "ui.fileMedia.state.error": "{{kind}} लोड गर्न असमर्थ।", @@ -165,6 +166,7 @@ export const dict: Record = { "ui.common.back": "पछाडि", "ui.common.cancel": "रद्द गर्नुहोस्", "ui.common.confirm": "पुष्टि गर्नुहोस्", + "ui.common.retry": "पुन: प्रयास गर्नुहोस्", "ui.common.dismiss": "खारेज गर्नुहोस्", "ui.common.close": "बन्द गर्नुहोस्", "ui.common.next": "अर्को", diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts index 0a5fe58e789b..6ae7a5e09014 100644 --- a/packages/ui/src/i18n/nl.ts +++ b/packages/ui/src/i18n/nl.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "lijnen {{start}}-{{end}}", "ui.fileMedia.kind.image": "afbeelding", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "diff", "ui.fileMedia.state.removed": "{{kind}}-bestand verwijderd.", "ui.fileMedia.state.loading": "{{kind}} laden...", "ui.fileMedia.state.error": "Kan {{kind}} niet laden.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Terug", "ui.common.cancel": "Annuleren", "ui.common.confirm": "Bevestigen", + "ui.common.retry": "Opnieuw proberen", "ui.common.dismiss": "Negeren", "ui.common.close": "Sluiten", "ui.common.next": "Volgende", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 0cdd653908aa..724d7a0a89f6 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -21,6 +21,7 @@ export const dict: Record = { "ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel", "ui.fileMedia.kind.image": "bilde", "ui.fileMedia.kind.audio": "lyd", + "ui.fileMedia.kind.diff": "diff", "ui.fileMedia.state.removed": "Fjernet: {{kind}}", "ui.fileMedia.state.loading": "Laster inn {{kind}}...", "ui.fileMedia.state.error": "Feil ved innlasting: {{kind}}", @@ -146,6 +147,7 @@ export const dict: Record = { "ui.common.back": "Tilbake", "ui.common.cancel": "Avbryt", "ui.common.confirm": "Bekreft", + "ui.common.retry": "Prøv igjen", "ui.common.dismiss": "Avvis", "ui.common.close": "Lukk", "ui.common.next": "Neste", diff --git a/packages/ui/src/i18n/pa.ts b/packages/ui/src/i18n/pa.ts index 5ecb6b67b720..3e135f677084 100644 --- a/packages/ui/src/i18n/pa.ts +++ b/packages/ui/src/i18n/pa.ts @@ -37,6 +37,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "لائناں {{start}}-{{end}}", "ui.fileMedia.kind.image": "تصویر", "ui.fileMedia.kind.audio": "آڈیو", + "ui.fileMedia.kind.diff": "ਅੰਤਰ", "ui.fileMedia.state.removed": "{{kind}} فائل ہٹا دتی گئی۔", "ui.fileMedia.state.loading": "لوڈ ہو رہیا اے {{kind}}...", "ui.fileMedia.state.error": "{{kind}} لوڈ کرن توں قاصر۔", @@ -164,6 +165,7 @@ export const dict: Record = { "ui.common.back": "پچھے", "ui.common.cancel": "منسوخ کرو", "ui.common.confirm": "پکا کرو", + "ui.common.retry": "ਮੁੜ ਕੋਸ਼ਿਸ਼ ਕਰੋ", "ui.common.dismiss": "برخاست کرو", "ui.common.close": "بند کرو", "ui.common.next": "اگلا", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 7181b00806bf..ff336c8d41db 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -37,6 +37,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "Zmiany w projekcie pojawią się tutaj", "ui.fileMedia.kind.image": "obraz", "ui.fileMedia.kind.audio": "dźwięk", + "ui.fileMedia.kind.diff": "różnice", "ui.fileMedia.state.removed": "{{kind}} usunięty", "ui.fileMedia.state.loading": "Wczytywanie: {{kind}}...", "ui.fileMedia.state.error": "Błąd wczytywania: {{kind}}", @@ -175,6 +176,7 @@ export const dict = { "ui.common.back": "Wstecz", "ui.common.cancel": "Anuluj", "ui.common.confirm": "Potwierdź", + "ui.common.retry": "Spróbuj ponownie", "ui.common.dismiss": "Odrzuć", "ui.common.close": "Zamknij", "ui.common.next": "Dalej", diff --git a/packages/ui/src/i18n/ro.ts b/packages/ui/src/i18n/ro.ts index 6d187caf5fd8..f7b80a5570b5 100644 --- a/packages/ui/src/i18n/ro.ts +++ b/packages/ui/src/i18n/ro.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "liniile {{start}}-{{end}}", "ui.fileMedia.kind.image": "imagine", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "diferențe", "ui.fileMedia.state.removed": "Fișier {{kind}} șters.", "ui.fileMedia.state.loading": "Se încarcă {{kind}}...", "ui.fileMedia.state.error": "Nu se poate încărca {{kind}}.", @@ -167,6 +168,7 @@ export const dict: Record = { "ui.common.back": "Înapoi", "ui.common.cancel": "Anulează", "ui.common.confirm": "Confirmă", + "ui.common.retry": "Reîncearcă", "ui.common.dismiss": "Închide", "ui.common.close": "Închide", "ui.common.next": "Următorul", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 2b733166f7f4..3ca155ad5256 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -37,6 +37,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "Изменения проекта появятся здесь", "ui.fileMedia.kind.image": "изображение", "ui.fileMedia.kind.audio": "аудио", + "ui.fileMedia.kind.diff": "различия", "ui.fileMedia.state.removed": "{{kind}} удалено", "ui.fileMedia.state.loading": "Загружается {{kind}}...", "ui.fileMedia.state.error": "Не удалось загрузить {{kind}}", @@ -174,6 +175,7 @@ export const dict = { "ui.common.back": "Назад", "ui.common.cancel": "Отмена", "ui.common.confirm": "Подтвердить", + "ui.common.retry": "Повторить", "ui.common.dismiss": "Закрыть", "ui.common.close": "Закрыть", "ui.common.next": "Далее", diff --git a/packages/ui/src/i18n/si.ts b/packages/ui/src/i18n/si.ts index 3550df3aaa88..0f9b7718db66 100644 --- a/packages/ui/src/i18n/si.ts +++ b/packages/ui/src/i18n/si.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "රේඛා {{start}}-{{end}}", "ui.fileMedia.kind.image": "රූපය", "ui.fileMedia.kind.audio": "ශ්රව්ය", + "ui.fileMedia.kind.diff": "වෙනස්කම්", "ui.fileMedia.state.removed": "{{kind}} ගොනුව ඉවත් කරන ලදී.", "ui.fileMedia.state.loading": "{{kind}} පූරණය වෙමින්...", "ui.fileMedia.state.error": "{{kind}} පූරණය කළ නොහැක.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "ආපසු", "ui.common.cancel": "අවලංගු කරන්න", "ui.common.confirm": "තහවුරු කරන්න", + "ui.common.retry": "නැවත උත්සාහ කරන්න", "ui.common.dismiss": "අස් කරන්න", "ui.common.close": "වසන්න", "ui.common.next": "ඊළඟ", diff --git a/packages/ui/src/i18n/sk.ts b/packages/ui/src/i18n/sk.ts index a8ec929b610d..1c4eb7ed9f8f 100644 --- a/packages/ui/src/i18n/sk.ts +++ b/packages/ui/src/i18n/sk.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "riadky {{start}}–{{end}}", "ui.fileMedia.kind.image": "obrázok", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "rozdiely", "ui.fileMedia.state.removed": "Odstránený súbor typu {{kind}}.", "ui.fileMedia.state.loading": "Načítava sa {{kind}}...", "ui.fileMedia.state.error": "Nepodarilo sa načítať {{kind}}.", @@ -171,6 +172,7 @@ export const dict: Record = { "ui.common.back": "Späť", "ui.common.cancel": "Zrušiť", "ui.common.confirm": "Potvrdiť", + "ui.common.retry": "Skúsiť znova", "ui.common.dismiss": "Zavrieť", "ui.common.close": "Zatvoriť", "ui.common.next": "Ďalej", diff --git a/packages/ui/src/i18n/sl.ts b/packages/ui/src/i18n/sl.ts index 8d5718fc01d7..135a09c7780e 100644 --- a/packages/ui/src/i18n/sl.ts +++ b/packages/ui/src/i18n/sl.ts @@ -37,6 +37,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "linije {{start}}-{{end}}", "ui.fileMedia.kind.image": "slika", "ui.fileMedia.kind.audio": "zvok", + "ui.fileMedia.kind.diff": "razlike", "ui.fileMedia.state.removed": "Odstranjena datoteka {{kind}}.", "ui.fileMedia.state.loading": "Nalaganje {{kind}} ...", "ui.fileMedia.state.error": "Ni mogoče naložiti {{kind}}.", @@ -172,6 +173,7 @@ export const dict: Record = { "ui.common.back": "Nazaj", "ui.common.cancel": "Prekliči", "ui.common.confirm": "Potrdi", + "ui.common.retry": "Poskusi znova", "ui.common.dismiss": "Odpusti", "ui.common.close": "Zapri", "ui.common.next": "Naprej", diff --git a/packages/ui/src/i18n/sq.ts b/packages/ui/src/i18n/sq.ts index 3c1cd24e9f3d..16687c691b79 100644 --- a/packages/ui/src/i18n/sq.ts +++ b/packages/ui/src/i18n/sq.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "linjat {{start}}-{{end}}", "ui.fileMedia.kind.image": "imazh", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "ndryshimet", "ui.fileMedia.state.removed": "Skedari {{kind}} u hoq.", "ui.fileMedia.state.loading": "{{kind}} po ngarkohet...", "ui.fileMedia.state.error": "{{kind}} nuk mund të ngarkohet.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Mbrapa", "ui.common.cancel": "Anulo", "ui.common.confirm": "Konfirmo", + "ui.common.retry": "Riprovo", "ui.common.dismiss": "Largoje", "ui.common.close": "Mbylle", "ui.common.next": "Tjetra", diff --git a/packages/ui/src/i18n/sr.ts b/packages/ui/src/i18n/sr.ts index 9f090cd2e2b0..44a9543cad79 100644 --- a/packages/ui/src/i18n/sr.ts +++ b/packages/ui/src/i18n/sr.ts @@ -37,6 +37,7 @@ export const dict = { "ui.sessionReview.selection.lines": "линије {{start}}-{{end}}", "ui.fileMedia.kind.image": "слика", "ui.fileMedia.kind.audio": "аудио", + "ui.fileMedia.kind.diff": "разлике", "ui.fileMedia.state.removed": "Уклоњена датотека {{kind}}.", "ui.fileMedia.state.loading": "Учитавање {{kind}}...", "ui.fileMedia.state.error": "Није могуће учитати {{kind}}.", @@ -168,6 +169,7 @@ export const dict = { "ui.common.back": "Назад", "ui.common.cancel": "Откажи", "ui.common.confirm": "Потврдите", + "ui.common.retry": "Покушај поново", "ui.common.dismiss": "Одбаци", "ui.common.close": "Затвори", "ui.common.next": "Следеће", diff --git a/packages/ui/src/i18n/sv.ts b/packages/ui/src/i18n/sv.ts index 3fcc6ff76532..0f5da8ab4884 100644 --- a/packages/ui/src/i18n/sv.ts +++ b/packages/ui/src/i18n/sv.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "rader {{start}}-{{end}}", "ui.fileMedia.kind.image": "bild", "ui.fileMedia.kind.audio": "ljud", + "ui.fileMedia.kind.diff": "diff", "ui.fileMedia.state.removed": "Filen av typen {{kind}} har tagits bort.", "ui.fileMedia.state.loading": "Laddar {{kind}}...", "ui.fileMedia.state.error": "Det gick inte att ladda {{kind}}.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Tillbaka", "ui.common.cancel": "Avbryt", "ui.common.confirm": "Bekräfta", + "ui.common.retry": "Försök igen", "ui.common.dismiss": "Avfärda", "ui.common.close": "Stäng", "ui.common.next": "Nästa", diff --git a/packages/ui/src/i18n/tg.ts b/packages/ui/src/i18n/tg.ts index 4c3e824c6c40..5f9e2192d46f 100644 --- a/packages/ui/src/i18n/tg.ts +++ b/packages/ui/src/i18n/tg.ts @@ -36,6 +36,7 @@ export const dict = { "ui.sessionReview.selection.lines": "сатрҳо {{start}}-{{end}}", "ui.fileMedia.kind.image": "тасвир", "ui.fileMedia.kind.audio": "аудио", + "ui.fileMedia.kind.diff": "фарқиятҳо", "ui.fileMedia.state.removed": "Файли {{kind}} хориҷ карда шуд.", "ui.fileMedia.state.loading": "{{kind}} бор шуда истодааст...", "ui.fileMedia.state.error": "Бор кардан ғайриимкон аст {{kind}}.", @@ -163,6 +164,7 @@ export const dict = { "ui.common.back": "Бозгашт", "ui.common.cancel": "Бекор кардан", "ui.common.confirm": "Тасдиқ кунед", + "ui.common.retry": "Аз нав кӯшиш кунед", "ui.common.dismiss": "Рад кардан", "ui.common.close": "Пӯшед", "ui.common.next": "Баъдӣ", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index ae0d820d4eb1..740646355f50 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -36,6 +36,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "การเปลี่ยนแปลงในโปรเจกต์จะแสดงที่นี่", "ui.fileMedia.kind.image": "รูปภาพ", "ui.fileMedia.kind.audio": "เสียง", + "ui.fileMedia.kind.diff": "ความแตกต่าง", "ui.fileMedia.state.removed": "ลบ{{kind}}แล้ว", "ui.fileMedia.state.loading": "กำลังโหลด{{kind}}...", "ui.fileMedia.state.error": "เกิดข้อผิดพลาดในการโหลด{{kind}}", @@ -169,6 +170,7 @@ export const dict = { "ui.common.back": "ย้อนกลับ", "ui.common.cancel": "ยกเลิก", "ui.common.confirm": "ยืนยัน", + "ui.common.retry": "ลองใหม่", "ui.common.dismiss": "ปิด", "ui.common.close": "ปิด", "ui.common.next": "ถัดไป", diff --git a/packages/ui/src/i18n/tk.ts b/packages/ui/src/i18n/tk.ts index e1bf43208f91..49da50f543ec 100644 --- a/packages/ui/src/i18n/tk.ts +++ b/packages/ui/src/i18n/tk.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "setirler {{start}}-{{end}}", "ui.fileMedia.kind.image": "şekil", "ui.fileMedia.kind.audio": "ses", + "ui.fileMedia.kind.diff": "tapawut", "ui.fileMedia.state.removed": "{{kind}} faýly aýryldy.", "ui.fileMedia.state.loading": "{{kind}} ýüklenýär ...", "ui.fileMedia.state.error": "{{kind}} ýükläp bolmaýar.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Yzyna", "ui.common.cancel": "Elatyr", "ui.common.confirm": "Tassykla", + "ui.common.retry": "Gaýtadan synanyş", "ui.common.dismiss": "Işden aýyrmak", "ui.common.close": ".Akyn", "ui.common.next": "Indiki", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index e0b6fd0ccd71..926e3bfd82f2 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -42,6 +42,7 @@ export const dict = { "ui.fileMedia.kind.image": "görsel", "ui.fileMedia.kind.audio": "ses", + "ui.fileMedia.kind.diff": "fark", "ui.fileMedia.state.removed": "{{kind}} dosyası kaldırıldı.", "ui.fileMedia.state.loading": "{{kind}} yükleniyor...", "ui.fileMedia.state.error": "{{kind}} yüklenemedi.", @@ -175,6 +176,7 @@ export const dict = { "ui.common.back": "Geri", "ui.common.cancel": "İptal", "ui.common.confirm": "Onayla", + "ui.common.retry": "Yeniden dene", "ui.common.dismiss": "Kapat", "ui.common.close": "Kapat", "ui.common.next": "İleri", diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index 9643b52a944b..fa14c990bec9 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -38,6 +38,7 @@ export const dict: Record = { "ui.fileMedia.kind.image": "зображення", "ui.fileMedia.kind.audio": "аудіо", + "ui.fileMedia.kind.diff": "різниця", "ui.fileMedia.state.removed": "{{kind}} видалено", "ui.fileMedia.state.loading": "Завантаження {{kind}}...", "ui.fileMedia.state.error": "Не вдалося завантажити {{kind}}", @@ -190,6 +191,7 @@ export const dict: Record = { "ui.common.back": "Назад", "ui.common.cancel": "Скасувати", "ui.common.confirm": "Підтвердити", + "ui.common.retry": "Повторити", "ui.common.dismiss": "Відхилити", "ui.common.close": "Закрити", "ui.common.next": "Далі", diff --git a/packages/ui/src/i18n/ur.ts b/packages/ui/src/i18n/ur.ts index b9aaa429e630..92bf1f5d636d 100644 --- a/packages/ui/src/i18n/ur.ts +++ b/packages/ui/src/i18n/ur.ts @@ -37,6 +37,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "لائنیں {{start}} تا {{end}}", "ui.fileMedia.kind.image": "تصویر", "ui.fileMedia.kind.audio": "آڈیو", + "ui.fileMedia.kind.diff": "فرق", "ui.fileMedia.state.removed": "{{kind}} کی فائل ہٹا دی گئی۔", "ui.fileMedia.state.loading": "{{kind}} لوڈ ہو رہا ہے...", "ui.fileMedia.state.error": "{{kind}} لوڈ کرنے سے قاصر۔", @@ -164,6 +165,7 @@ export const dict: Record = { "ui.common.back": "واپس", "ui.common.cancel": "منسوخ کریں۔", "ui.common.confirm": "تصدیق کریں۔", + "ui.common.retry": "دوبارہ کوشش کریں", "ui.common.dismiss": "خارج کریں", "ui.common.close": "بند کریں", "ui.common.next": "اگلا", diff --git a/packages/ui/src/i18n/uz.ts b/packages/ui/src/i18n/uz.ts index c1a1e671e38b..ec7f1282630b 100644 --- a/packages/ui/src/i18n/uz.ts +++ b/packages/ui/src/i18n/uz.ts @@ -38,6 +38,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "{{start}}-{{end}} chiziqlari", "ui.fileMedia.kind.image": "tasvir", "ui.fileMedia.kind.audio": "audio", + "ui.fileMedia.kind.diff": "farqlar", "ui.fileMedia.state.removed": "{{kind}} fayli olib tashlandi.", "ui.fileMedia.state.loading": "{{kind}} yuklanmoqda...", "ui.fileMedia.state.error": "{{kind}} yuklanmadi.", @@ -166,6 +167,7 @@ export const dict: Record = { "ui.common.back": "Orqaga", "ui.common.cancel": "Bekor qilish", "ui.common.confirm": "Tasdiqlang", + "ui.common.retry": "Qayta urinish", "ui.common.dismiss": "Rad etish", "ui.common.close": "Yopish", "ui.common.next": "Keyingi", diff --git a/packages/ui/src/i18n/vi.ts b/packages/ui/src/i18n/vi.ts index c37ff1a66fa9..ddf69ebfbc49 100644 --- a/packages/ui/src/i18n/vi.ts +++ b/packages/ui/src/i18n/vi.ts @@ -36,6 +36,7 @@ export const dict: Record = { "ui.sessionReview.selection.lines": "dòng {{start}}-{{end}}", "ui.fileMedia.kind.image": "hình ảnh", "ui.fileMedia.kind.audio": "âm thanh", + "ui.fileMedia.kind.diff": "khác biệt", "ui.fileMedia.state.removed": "Đã xóa tệp {{kind}}.", "ui.fileMedia.state.loading": "Đang tải {{kind}}...", "ui.fileMedia.state.error": "Không thể tải {{kind}}.", @@ -163,6 +164,7 @@ export const dict: Record = { "ui.common.back": "Quay lại", "ui.common.cancel": "Hủy", "ui.common.confirm": "Xác nhận", + "ui.common.retry": "Thử lại", "ui.common.dismiss": "Bỏ qua", "ui.common.close": "Đóng", "ui.common.next": "Tiếp theo", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index c5a33efef013..72e72beb8ca8 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -40,6 +40,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "项目变更将显示在此处", "ui.fileMedia.kind.image": "图片", "ui.fileMedia.kind.audio": "音频", + "ui.fileMedia.kind.diff": "差异", "ui.fileMedia.state.removed": "{{kind}}已移除", "ui.fileMedia.state.loading": "正在加载{{kind}}...", "ui.fileMedia.state.error": "加载{{kind}}失败", @@ -171,6 +172,7 @@ export const dict = { "ui.common.back": "返回", "ui.common.cancel": "取消", "ui.common.confirm": "确认", + "ui.common.retry": "重试", "ui.common.dismiss": "忽略", "ui.common.close": "关闭", "ui.common.next": "下一步", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 736a7961b0e6..527697010e74 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -40,6 +40,7 @@ export const dict = { "ui.sessionReviewV2.empty.changes.description": "專案變更會顯示在這裡", "ui.fileMedia.kind.image": "圖片", "ui.fileMedia.kind.audio": "音訊", + "ui.fileMedia.kind.diff": "差異", "ui.fileMedia.state.removed": "{{kind}}已移除", "ui.fileMedia.state.loading": "正在載入{{kind}}...", "ui.fileMedia.state.error": "載入{{kind}}失敗", @@ -171,6 +172,7 @@ export const dict = { "ui.common.back": "返回", "ui.common.cancel": "取消", "ui.common.confirm": "確認", + "ui.common.retry": "重試", "ui.common.dismiss": "忽略", "ui.common.close": "關閉", "ui.common.next": "下一步",