From c1b5f1b416c2bff799a4be6a75d640e9ba28d42f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 28 Oct 2026 21:30:03 -0700 Subject: [PATCH 01/40] feat(core): add the session_part table and read history from it (#58915) --- packages/core/schema.json | 139 ++++++++++++++++- packages/core/src/database/migration.gen.ts | 2 + .../migration/20261029002625_session_part.ts | 30 ++++ packages/core/src/database/schema.gen.ts | 18 +++ packages/core/src/session/history.ts | 55 ++++++- packages/core/src/session/projector.ts | 70 ++++++++- packages/core/src/session/sql.ts | 24 +++ packages/core/src/session/stats.ts | 33 +++- packages/core/test/session-projector.test.ts | 129 ++++++++++++++++ packages/core/test/session-store.test.ts | 144 ++++++++++++++++++ packages/schema/src/session-legacy.ts | 3 +- 11 files changed, 632 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/database/migration/20261029002625_session_part.ts diff --git a/packages/core/schema.json b/packages/core/schema.json index cb027faaa3ea..c643c298402b 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,8 @@ { "version": "7", "dialect": "sqlite", - "id": "23940db2-8b4e-4a5e-a404-70d785bd788c", - "prevIds": ["44bd8ca5-689a-410e-a46a-958eb5accd97"], + "id": "dc16dd12-cb76-432f-8108-0c3aadaf54bb", + "prevIds": ["23940db2-8b4e-4a5e-a404-70d785bd788c"], "ddl": [ { "name": "account_state", @@ -76,6 +76,10 @@ "name": "session_message", "entityType": "tables" }, + { + "name": "session_part", + "entityType": "tables" + }, { "name": "session_v2", "entityType": "tables" @@ -1298,6 +1302,86 @@ "entityType": "columns", "table": "session_message" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "session_part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ordinal", + "entityType": "columns", + "table": "session_part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "call_id", + "entityType": "columns", + "table": "session_part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_part" + }, { "type": "text", "notNull": false, @@ -1869,6 +1953,28 @@ "entityType": "fks", "table": "session_message" }, + { + "columns": ["message_id"], + "tableTo": "session_message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_part_message_id_session_message_id_fk", + "entityType": "fks", + "table": "session_part" + }, + { + "columns": ["session_id"], + "tableTo": "session_v2", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_part_session_id_session_v2_id_fk", + "entityType": "fks", + "table": "session_part" + }, { "columns": ["project_id"], "tableTo": "project", @@ -1919,6 +2025,13 @@ "entityType": "pks", "table": "instruction_entry" }, + { + "columns": ["message_id", "ordinal"], + "nameExplicit": false, + "name": "session_part_pk", + "entityType": "pks", + "table": "session_part" + }, { "columns": ["project_id", "directory"], "nameExplicit": false, @@ -2276,6 +2389,28 @@ "entityType": "indexes", "table": "session_message" }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "message_id", + "isExpression": false + }, + { + "value": "ordinal", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_part_session_message_idx", + "entityType": "indexes", + "table": "session_part" + }, { "columns": [ { diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 575a1bd64a58..5438f244a777 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -54,6 +54,7 @@ import m51 from "./migration/20261024175301_drop_session_allowance.js" import m52 from "./migration/20261025123711_blob.js" import m53 from "./migration/20261025154125_drop_session_pending.js" import m54 from "./migration/20261026223005_schedule.js" +import m55 from "./migration/20261029002625_session_part.js" export const migrations = [ m00, @@ -111,4 +112,5 @@ export const migrations = [ m52, m53, m54, + m55, ] satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20261029002625_session_part.ts b/packages/core/src/database/migration/20261029002625_session_part.ts new file mode 100644 index 000000000000..87f629128057 --- /dev/null +++ b/packages/core/src/database/migration/20261029002625_session_part.ts @@ -0,0 +1,30 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration.js" + +const migration: DatabaseMigration.Migration = { + id: "20261029002625_session_part", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_part\` ( + \`message_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`ordinal\` integer NOT NULL, + \`type\` text NOT NULL, + \`call_id\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`session_part_pk\` PRIMARY KEY(\`message_id\`, \`ordinal\`), + CONSTRAINT \`fk_session_part_message_id_session_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`session_message\`(\`id\`) ON DELETE CASCADE, + CONSTRAINT \`fk_session_part_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run( + `CREATE INDEX \`session_part_session_message_idx\` ON \`session_part\` (\`session_id\`,\`message_id\`,\`ordinal\`);`, + ) + }) + }, +} + +export default migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 0e09e8306641..6a7ef1601ab1 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -212,6 +212,21 @@ const schema: Omit = { CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`session_part\` ( + \`message_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`ordinal\` integer NOT NULL, + \`type\` text NOT NULL, + \`call_id\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`session_part_pk\` PRIMARY KEY(\`message_id\`, \`ordinal\`), + CONSTRAINT \`fk_session_part_message_id_session_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`session_message\`(\`id\`) ON DELETE CASCADE, + CONSTRAINT \`fk_session_part_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`session_v2\` ( \`id\` text PRIMARY KEY, @@ -299,6 +314,9 @@ const schema: Omit = { `CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`, ) yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`) + yield* tx.run( + `CREATE INDEX \`session_part_session_message_idx\` ON \`session_part\` (\`session_id\`,\`message_id\`,\`ordinal\`);`, + ) yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`) yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`) yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`) diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index 0327d82d4ae1..3c85d5e8d904 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, gte, lt, or, sql } from "drizzle-orm" +import { and, asc, desc, eq, gte, inArray, lt, or, sql } from "drizzle-orm" import { Message } from "@opencode/ai/effect" import { Effect, Option, Schema } from "effect" import type { Model } from "@opencode/schema/model" @@ -11,7 +11,7 @@ import { Instructions } from "../instructions/index.js" import { InstructionState } from "./instruction-state.js" import { SessionBlob } from "./blob.js" import { SessionRecentText } from "./recent-text.js" -import { SessionMessageTable } from "./sql.js" +import { SessionMessageTable, SessionPartTable } from "./sql.js" import { SessionStored } from "./stored.js" type DatabaseService = Database.Interface["db"] @@ -20,6 +20,9 @@ const decode = Schema.decodeUnknownEffect(SessionMessage.Info) const decodeSync = Schema.decodeUnknownSync(SessionMessage.Info) const decodeStoredUser = Schema.decodeUnknownEffect(SessionStored.User) const decodeMedia = Schema.decodeUnknownEffect(SessionMessage.MediaInfo) +const decodePartSync = Schema.decodeUnknownSync(SessionMessage.AssistantContent) +export const encodePart = Schema.encodeSync(SessionMessage.AssistantContent) +const PartQueryBatchSize = 500 /** * Which completed compactions bound a history read. Local summaries always do. Native @@ -84,6 +87,41 @@ const latestCompaction = Effect.fnUntraced(function* ( .pipe(Effect.orDie) }) +export const hasInlineContent = (data: typeof SessionMessageTable.$inferSelect.data): boolean => + "content" in data && Array.isArray(data.content) && data.content.length > 0 + +/** + * Puts each assistant row's `session_part` rows into its `data.content`, in ordinal order. Rows without parts keep + * their inline content, so legacy rows read unchanged. Runs before blob hydration so media in parts restores too. + */ +export const withParts = Effect.fnUntraced(function* ( + db: DatabaseService, + rows: ReadonlyArray, +) { + const assistantIDs = rows.flatMap((row) => (row.type === "assistant" ? [row.id] : [])) + if (assistantIDs.length === 0) return rows + const partsByMessage = new Map>() + for (let index = 0; index < assistantIDs.length; index += PartQueryBatchSize) { + const partRows = yield* db + .select() + .from(SessionPartTable) + .where(inArray(SessionPartTable.message_id, assistantIDs.slice(index, index + PartQueryBatchSize))) + .orderBy(asc(SessionPartTable.message_id), asc(SessionPartTable.ordinal)) + .all() + .pipe(Effect.orDie) + for (const part of partRows) { + const list = partsByMessage.get(part.message_id) + if (list) list.push(part.data) + else partsByMessage.set(part.message_id, [part.data]) + } + } + if (partsByMessage.size === 0) return rows + return rows.map((row) => { + const parts = partsByMessage.get(row.id) + return parts && "content" in row.data ? { ...row, data: { ...row.data, content: parts } } : row + }) +}) + // Every stored message row decodes through here, so rows written in older shapes are upgraded in one place. const rowInput = (row: typeof SessionMessageTable.$inferSelect) => ({ ...SessionLegacy.message(row.type, row.data), @@ -91,6 +129,9 @@ const rowInput = (row: typeof SessionMessageTable.$inferSelect) => ({ type: row.type, }) +export const decodePartRowSync = (row: typeof SessionPartTable.$inferSelect) => + decodePartSync(SessionLegacy.part({ ...row.data, type: row.type })) + export const decodeMessageRowSync = (row: typeof SessionMessageTable.$inferSelect) => decodeSync(rowInput(row)) const decodeError = (row: typeof SessionMessageTable.$inferSelect) => () => @@ -113,7 +154,9 @@ export const decodeMessageRows = Effect.fnUntraced(function* ( db: DatabaseService, rows: ReadonlyArray, ) { - return yield* Effect.forEach(yield* SessionBlob.hydrate(db, rows), decodeMessageRow, { concurrency: 1 }) + return yield* Effect.forEach(yield* SessionBlob.hydrate(db, yield* withParts(db, rows)), decodeMessageRow, { + concurrency: 1, + }) }) /** Decodes rows for clients that fetch media themselves, so files stored in `blob` stay `media:` refs. */ @@ -121,7 +164,7 @@ export const decodeMessageRowsWithRefs = Effect.fnUntraced(function* ( db: DatabaseService, rows: ReadonlyArray, ) { - const kept = yield* SessionBlob.keepRefs(db, rows) + const kept = yield* SessionBlob.keepRefs(db, yield* withParts(db, rows)) return yield* Effect.forEach( kept.rows, (row) => @@ -160,7 +203,7 @@ const messageEntries = Effect.fnUntraced(function* ( .all() .pipe(Effect.orDie) const decoded = yield* Effect.forEach( - yield* SessionBlob.hydrate(db, rows), + yield* SessionBlob.hydrate(db, yield* withParts(db, rows)), (row) => decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))), { concurrency: 1 }, ) @@ -216,7 +259,7 @@ const withRecentText = Effect.fnUntraced(function* ( .all() .pipe(Effect.orDie) // The recent text names attachments by mime and name only, so their bytes are never read. - const kept = yield* Effect.forEach(rows, decodeStoredRow, { concurrency: 1 }) + const kept = yield* Effect.forEach(yield* withParts(db, rows), decodeStoredRow, { concurrency: 1 }) const recent = kept.map(SessionRecentText.messageToText).filter(Boolean).join("\n\n") return [{ ...first, message: { ...compaction, metadata: { ...compaction.metadata, recent } } }, ...entries.slice(1)] }) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 7cce5b808a9b..23bc41d83d9c 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -13,7 +13,7 @@ import { SessionMessageUpdater } from "./message-updater.js" import { SessionInbox } from "./inbox.js" import { InstructionState } from "./instruction-state.js" import { SessionBlob } from "./blob.js" -import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js" +import { SessionInboxTable, SessionMessageTable, SessionPartTable, SessionTable, type SessionPartData } from "./sql.js" import { SessionStored } from "./stored.js" import { InstructionEntry } from "./instruction-entry.js" import { Slug } from "../util/slug.js" @@ -189,6 +189,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( const forkedID = (seq: number) => SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${seq}`) let cursor = -1 while (copiedSeq !== undefined) { + // A parent message whose parts live in session_part is copied with them inline, so the fork reads the same. const rows = yield* db .select() .from(SessionMessageTable) @@ -206,7 +207,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .orderBy(asc(SessionMessageTable.seq)) .limit(ForkBatchSize) .all() - .pipe(Effect.orDie) + .pipe(Effect.orDie, Effect.flatMap((rows) => SessionHistory.withParts(db, rows))) if (rows.length === 0) break const values = yield* Effect.forEach(rows, (row) => @@ -257,17 +258,76 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( function run(db: DatabaseService, event: MessageEvent) { return Effect.gen(function* () { + const partRows = (messageID: SessionMessage.ID) => + db + .select() + .from(SessionPartTable) + .where(eq(SessionPartTable.message_id, messageID)) + .orderBy(asc(SessionPartTable.ordinal)) + .all() + .pipe(Effect.orDie) + // Writes `content` over a message's existing part rows, touching only the rows that changed. + const writeParts = Effect.fnUntraced(function* ( + messageID: SessionMessage.ID, + existing: ReadonlyArray, + content: ReadonlyArray, + ) { + let next = (existing.at(-1)?.ordinal ?? -1) + 1 + for (const [index, part] of content.entries()) { + const current = existing[index] + const columns = { type: part.type, call_id: part.type === "tool" ? part.id : null, data: part } + if (current === undefined) + yield* db + .insert(SessionPartTable) + .values({ + ...columns, + message_id: messageID, + session_id: event.data.sessionID, + ordinal: next++, + time_created: event.created, + time_updated: event.created, + }) + .run() + .pipe(Effect.orDie) + else if (JSON.stringify(current.data) !== JSON.stringify(part)) + yield* db + .update(SessionPartTable) + .set({ ...columns, time_updated: event.created }) + .where(and(eq(SessionPartTable.message_id, messageID), eq(SessionPartTable.ordinal, current.ordinal))) + .run() + .pipe(Effect.orDie) + } + const stale = existing.slice(content.length).map((part) => part.ordinal) + if (stale.length > 0) + yield* db + .delete(SessionPartTable) + .where(and(eq(SessionPartTable.message_id, messageID), inArray(SessionPartTable.ordinal, stale))) + .run() + .pipe(Effect.orDie) + }) + const decodeAssistantRow = Effect.fnUntraced(function* (row: typeof SessionMessageTable.$inferSelect) { + const [withParts = row] = yield* SessionHistory.withParts(db, [row]) + return SessionHistory.decodeMessageRowSync(withParts) + }) const updateMessage = Effect.fnUntraced(function* (message: SessionMessage.Info) { const stored = yield* SessionBlob.externalize(db, message) const { id: _, type, ...data } = encodeStored(stored) + // A newer release may have moved this message's parts to session_part. Reads prefer those rows, so updates + // go there too and the row keeps an empty envelope; messages without part rows are written inline as before. + const parts = message.type === "assistant" && "content" in data ? yield* partRows(message.id) : [] const updated = yield* db .update(SessionMessageTable) - .set({ type, time_created: DateTime.toEpochMillis(message.time.created), data }) + .set({ + type, + time_created: DateTime.toEpochMillis(message.time.created), + data: parts.length > 0 && "content" in data ? { ...data, content: [] } : data, + }) .where(and(eq(SessionMessageTable.id, message.id), eq(SessionMessageTable.session_id, event.data.sessionID))) .returning({ seq: SessionMessageTable.seq }) .get() .pipe(Effect.orDie) if (!updated) return + if (parts.length > 0 && "content" in data) yield* writeParts(message.id, parts, data.content) const owner = { sessionID: event.data.sessionID, messageID: message.id, seq: updated.seq } // Most updates only carry existing refs forward; a content rewrite may also drop some. `externalize` returns // the same message when it extracted nothing, so streaming updates skip the ref tables entirely. @@ -339,7 +399,7 @@ function run(db: DatabaseService, event: MessageEvent) { .get() .pipe(Effect.orDie) if (!row) return - const message = SessionHistory.decodeMessageRowSync(row) + const message = yield* decodeAssistantRow(row) return message.type === "assistant" && !message.time.completed ? message : undefined }) }, @@ -358,7 +418,7 @@ function run(db: DatabaseService, event: MessageEvent) { .get() .pipe(Effect.orDie) if (!row) return - const message = SessionHistory.decodeMessageRowSync(row) + const message = yield* decodeAssistantRow(row) return message.type === "assistant" ? message : undefined }) }, diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index a4d0c3b351fa..0f02d94f38e9 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -23,6 +23,7 @@ type SessionMessageData = DistributiveOmit< Exclude<(typeof SessionMessage.Info)["Encoded"], { readonly type: "user" }> | (typeof SessionStored.User)["Encoded"], "type" | "id" > +export type SessionPartData = SessionMessage.AssistantContentEncoded export const SessionTable = sqliteTable( "session_v2", @@ -102,6 +103,29 @@ export const SessionMessageTable = sqliteTable( ], ) +export const SessionPartTable = sqliteTable( + "session_part", + { + message_id: text() + .$type() + .notNull() + .references(() => SessionMessageTable.id, { onDelete: "cascade" }), + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + ordinal: integer().notNull(), + type: text().$type().notNull(), + call_id: text(), + ...Timestamps, + data: text({ mode: "json" }).notNull().$type(), + }, + (table) => [ + primaryKey({ columns: [table.message_id, table.ordinal] }), + index("session_part_session_message_idx").on(table.session_id, table.message_id, table.ordinal), + ], +) + export const SessionInboxTable = sqliteTable( "session_inbox", { diff --git a/packages/core/src/session/stats.ts b/packages/core/src/session/stats.ts index 4b4418845dc4..741367377f4f 100644 --- a/packages/core/src/session/stats.ts +++ b/packages/core/src/session/stats.ts @@ -12,7 +12,7 @@ import { ToolMode } from "@opencode/schema/session-stats" import { TokenUsage } from "@opencode/schema/token-usage" import { Database } from "../database/database.js" import { EventTable } from "../event/sql.js" -import { SessionMessageTable, SessionTable } from "./sql.js" +import { SessionMessageTable, SessionPartTable, SessionTable } from "./sql.js" type Input = { readonly from?: number @@ -193,6 +193,17 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { .get( sql` WITH calls AS MATERIALIZED ( + SELECT json_extract(part.data, '$.state.status') AS status + FROM ${SessionPartTable} AS part + JOIN ${SessionMessageTable} AS message ON message.id = part.message_id + JOIN ${SessionTable} AS session ON session.id = message.session_id + WHERE message.type = 'assistant' + AND message.time_created >= ${range.from} + AND message.time_created < ${range.to} + AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) + AND part.type = 'tool' + ${project} + UNION ALL SELECT json_extract(content.value, '$.state.status') AS status FROM ${SessionMessageTable} AS message JOIN ${SessionTable} AS session ON session.id = message.session_id, @@ -201,6 +212,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { AND message.time_created >= ${range.from} AND message.time_created < ${range.to} AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) + AND NOT EXISTS (SELECT 1 FROM ${SessionPartTable} AS part WHERE part.message_id = message.id) AND json_extract(content.value, '$.type') = 'tool' ${project} ) @@ -228,6 +240,24 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { return db .all( sql` + SELECT + json_extract(part.data, '$.name') AS name, + json_extract(part.data, '$.state.status') AS status, + CASE + WHEN json_extract(part.data, '$.time.completed') IS NULL THEN NULL + ELSE json_extract(part.data, '$.time.completed') + - coalesce(json_extract(part.data, '$.time.ran'), json_extract(part.data, '$.time.created')) + END AS duration + FROM ${SessionPartTable} AS part + JOIN ${SessionMessageTable} AS message ON message.id = part.message_id + JOIN ${SessionTable} AS session ON session.id = message.session_id + WHERE message.type = 'assistant' + AND message.time_created >= ${range.from} + AND message.time_created < ${range.to} + AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) + AND part.type = 'tool' + ${project} + UNION ALL SELECT json_extract(content.value, '$.name') AS name, json_extract(content.value, '$.state.status') AS status, @@ -243,6 +273,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { AND message.time_created >= ${range.from} AND message.time_created < ${range.to} AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) + AND NOT EXISTS (SELECT 1 FROM ${SessionPartTable} AS part WHERE part.message_id = message.id) AND json_extract(content.value, '$.type') = 'tool' ${project} `, diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index df8780fd18c1..45870f06106d 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -36,6 +36,7 @@ import { BlobTable, SessionInboxTable, SessionMessageTable, + SessionPartTable, SessionTable, } from "@opencode/core/session/sql" import { testEffect } from "./lib/effect" @@ -1559,4 +1560,132 @@ describe("SessionProjector", () => { expect(yield* db.select().from(BlobTable).all().pipe(Effect.orDie)).toEqual([]) }).pipe(Effect.provide(mediaBlob)), ) + + // A newer release wrote this in-progress message's parts as session_part rows, then the user rolled back. + it.effect("keeps projecting into session_part rows a newer release wrote for an in-progress message", () => + Effect.gen(function* () { + const db = yield* seedSession() + const bus = yield* Bus.Service + const store = yield* SessionStore.Service + const assistantMessageID = SessionMessage.ID.make("msg_rolled_back") + yield* db.insert(SessionMessageTable).values(assistantRow(assistantMessageID, 0)).run().pipe(Effect.orDie) + yield* db + .insert(SessionPartTable) + .values([ + { + message_id: assistantMessageID, + session_id: sessionID, + ordinal: 0, + type: "text", + call_id: null, + time_created: 1, + time_updated: 1, + data: { type: "text", text: "before rollback" }, + }, + { + message_id: assistantMessageID, + session_id: sessionID, + ordinal: 1, + type: "tool", + call_id: "call_a", + time_created: 1, + time_updated: 1, + data: { + type: "tool", + id: "call_a", + name: "read", + executed: true, + state: { status: "running", input: { path: "a.ts" }, metadata: {} }, + time: { created: 1, ran: 1 }, + }, + }, + ]) + .run() + .pipe(Effect.orDie) + + yield* TestClock.setTime(5) + yield* bus.publish(SessionEvent.Tool.Success, { + sessionID, + assistantMessageID, + id: "call_a", + content: [{ type: "text", text: "export {}" }], + executed: true, + }) + yield* bus.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID, ordinal: 1 }) + yield* bus.publish(SessionEvent.Text.Ended, { sessionID, assistantMessageID, ordinal: 1, text: "after rollback" }) + yield* bus.publish(SessionEvent.Tool.Input.Started, { sessionID, assistantMessageID, id: "call_b", name: "grep" }) + + expect((yield* store.message(assistantMessageID))?.message).toMatchObject({ + content: [ + { type: "text", text: "before rollback" }, + { type: "tool", id: "call_a", state: { status: "completed", content: [{ type: "text", text: "export {}" }] } }, + { type: "text", text: "after rollback" }, + { type: "tool", id: "call_b", state: { status: "streaming" } }, + ], + }) + const row = yield* db + .select({ data: SessionMessageTable.data }) + .from(SessionMessageTable) + .where(eq(SessionMessageTable.id, assistantMessageID)) + .get() + .pipe(Effect.orDie) + expect(row?.data).toMatchObject({ content: [] }) + expect( + yield* db + .select({ + ordinal: SessionPartTable.ordinal, + type: SessionPartTable.type, + call: SessionPartTable.call_id, + updated: SessionPartTable.time_updated, + }) + .from(SessionPartTable) + .where(eq(SessionPartTable.message_id, assistantMessageID)) + .orderBy(asc(SessionPartTable.ordinal)) + .all() + .pipe(Effect.orDie), + ).toEqual([ + { ordinal: 0, type: "text", call: null, updated: 1 }, + { ordinal: 1, type: "tool", call: "call_a", updated: 5 }, + { ordinal: 2, type: "text", call: null, updated: 5 }, + { ordinal: 3, type: "tool", call: "call_b", updated: 5 }, + ]) + }), + ) + + it.effect("forks a settled message whose parts are in session_part with its content", () => + Effect.gen(function* () { + const db = yield* seedSession() + const bus = yield* Bus.Service + const store = yield* SessionStore.Service + const messageID = SessionMessage.ID.make("msg_split_settled") + const childID = Session.ID.make("ses_split_fork") + yield* db + .insert(SessionMessageTable) + .values(assistantRow(messageID, 0, { created, completed: created })) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionPartTable) + .values({ + message_id: messageID, + session_id: sessionID, + ordinal: 0, + type: "text", + call_id: null, + time_created: 0, + time_updated: 0, + data: { type: "text", text: "split answer" }, + }) + .run() + .pipe(Effect.orDie) + yield* bus.publish(SessionEvent.Forked, { + sessionID: childID, + parentID: sessionID, + boundary: { type: "through", messageID }, + }) + expect(yield* store.messages({ sessionID: childID, order: "asc" })).toMatchObject([ + { type: "assistant", content: [{ type: "text", text: "split answer" }] }, + ]) + }), + ) }) diff --git a/packages/core/test/session-store.test.ts b/packages/core/test/session-store.test.ts index 19292795d146..c30369fb9353 100644 --- a/packages/core/test/session-store.test.ts +++ b/packages/core/test/session-store.test.ts @@ -1,13 +1,19 @@ import { describe, expect } from "bun:test" +import { asc, eq } from "drizzle-orm" import { Effect } from "effect" import { Bus } from "@opencode/core/bus" import { Database } from "@opencode/core/database/database" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { ProjectTable } from "@opencode/core/project/sql" +import { SessionHistory } from "@opencode/core/session/history" import { SessionProjector } from "@opencode/core/session/projector" +import { SessionMessageTable, SessionPartTable } from "@opencode/core/session/sql" import { SessionStore } from "@opencode/core/session/store" +import { Agent } from "@opencode/schema/agent" import { Event } from "@opencode/schema/event" +import { Model } from "@opencode/schema/model" import { Project } from "@opencode/schema/project" +import { Provider } from "@opencode/schema/provider" import { AbsolutePath } from "@opencode/schema/schema" import { Session } from "@opencode/schema/session" import { SessionEvent } from "@opencode/schema/session-event" @@ -187,4 +193,142 @@ describe("SessionStore", () => { ).toEqual([]) }), ) + + it.effect("hydrates assistant parts from session_part when present and falls back to inline content", () => + Effect.gen(function* () { + const sessionID = Session.ID.make("ses_part_read_fallback") + yield* seedSessions([{ id: sessionID, updated: 0 }]) + const database = yield* Database.Service + const store = yield* SessionStore.Service + const agent = Agent.ID.make("build") + const model = { providerID: Provider.ID.make("anthropic"), id: Model.ID.make("claude-sonnet-4-5") } + + const inlineOnlyID = SessionMessage.ID.make("msg_inline_only") + const splitID = SessionMessage.ID.make("msg_split_parts") + + // 1. Assistant message with inline content only (no session_part rows) -> falls back to inline content + yield* database.db + .insert(SessionMessageTable) + .values({ + id: inlineOnlyID, + session_id: sessionID, + seq: 10, + type: "assistant", + time_created: 10, + time_updated: 10, + data: { + agent, + model, + content: [{ type: "text", text: "inline fallback content" }], + time: { created: 10, completed: 11 }, + }, + }) + .run() + + // 2. Assistant message with session_part rows -> hydrates parts in ordinal order + yield* database.db + .insert(SessionMessageTable) + .values({ + id: splitID, + session_id: sessionID, + seq: 20, + type: "assistant", + time_created: 20, + time_updated: 20, + data: { + agent, + model, + content: [], + time: { created: 20, completed: 25 }, + }, + }) + .run() + yield* database.db + .insert(SessionPartTable) + .values([ + { + message_id: splitID, + session_id: sessionID, + ordinal: 0, + type: "reasoning", + call_id: null, + time_created: 20, + time_updated: 21, + data: { type: "reasoning", text: "first reasoning", time: { created: 20, completed: 21 } }, + }, + { + message_id: splitID, + session_id: sessionID, + ordinal: 1, + type: "tool", + call_id: "call_read", + time_created: 21, + time_updated: 23, + data: { + type: "tool", + id: "call_read", + name: "read", + executed: true, + state: { + status: "completed", + input: { path: "src/index.ts" }, + content: [{ type: "text", text: "export {}" }], + }, + time: { created: 21, ran: 22, completed: 23 }, + }, + }, + { + message_id: splitID, + session_id: sessionID, + ordinal: 2, + type: "text", + call_id: null, + time_created: 23, + time_updated: 25, + data: { type: "text", text: "hydrated from session_part" }, + }, + ]) + .run() + + expect(yield* store.message(inlineOnlyID)).toMatchObject({ + sessionID, + message: { + id: inlineOnlyID, + content: [{ type: "text", text: "inline fallback content" }], + }, + }) + expect(yield* store.message(splitID)).toMatchObject({ + sessionID, + message: { + id: splitID, + content: [ + { type: "reasoning", text: "first reasoning" }, + { type: "tool", id: "call_read", state: { status: "completed" } }, + { type: "text", text: "hydrated from session_part" }, + ], + }, + }) + + const hydrated = yield* store.messages({ sessionID, order: "asc" }) + expect(hydrated).toMatchObject([ + { id: inlineOnlyID, content: [{ type: "text", text: "inline fallback content" }] }, + { + id: splitID, + content: [ + { type: "reasoning", text: "first reasoning" }, + { type: "tool", id: "call_read", state: { status: "completed" } }, + { type: "text", text: "hydrated from session_part" }, + ], + }, + ]) + + const rows = yield* database.db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, sessionID)) + .orderBy(asc(SessionMessageTable.seq)) + .all() + expect(yield* SessionHistory.decodeMessageRows(database.db, rows)).toEqual(hydrated) + }), + ) }) diff --git a/packages/schema/src/session-legacy.ts b/packages/schema/src/session-legacy.ts index 434e55b2d817..2fa24856a93b 100644 --- a/packages/schema/src/session-legacy.ts +++ b/packages/schema/src/session-legacy.ts @@ -48,7 +48,8 @@ function content(data: Record) { return { ...data, content: data.content.map(part) } } -function part(value: unknown) { +/** Upgrades an assistant content part written before v2.1. */ +export function part(value: unknown) { if (!isRecord(value)) return value if (value.type === "text" || value.type === "reasoning") return rename(value, "state", "native") // A tool part's own `state` is its lifecycle status, not native data. From ecc075a043e9da9bdd4f77062f237f24f80bd4ae Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:44:28 -0500 Subject: [PATCH 02/40] fix(codemode): visit inherited enumerable keys in for...in (#59933) --- packages/codemode/interpreter-support.md | 8 ++-- .../codemode/src/interpreter/interpreter.ts | 17 ++++++- packages/codemode/test/enumeration.test.ts | 48 +++++++++++++++++++ packages/codemode/test/parity.test.ts | 4 +- 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index b4789e245a97..ba2be07da7e3 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -104,7 +104,9 @@ the ultimate source of truth. Upstream test262 files run verbatim from `test/tes synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`. - [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other non-objects iterate nothing. An un-awaited promise throws rather than iterating. -- [ ] `for...in` over inherited enumerable keys (`Object.create(proto)`), and skipping keys deleted during the loop. +- [x] `for...in` over inherited enumerable keys (`Object.create(proto)`): own keys first, then each prototype's, skipping + keys a nearer level already has, even as a non-enumerable property. +- [ ] `for...in` skipping keys deleted during the loop. - [x] Unlabeled `break` and `continue`. - [x] `try`, `catch`, optional catch bindings, and `finally`. - [x] `throw` with arbitrary values. @@ -338,8 +340,8 @@ reject }` object. (`Object.getPrototypeOf("a") === String.prototype`); `null`, `undefined`, and the two confined symbols throw a `TypeError`. - [x] `Object.create(proto)` with an object or `null` prototype; any other prototype is a `TypeError` - (`Object prototype may only be an Object or null`). Inherited reads, `in`, `hasOwnProperty`, and own-only - `Object.keys` follow the chain as in JS, but `for...in` still enumerates own keys only. A second `properties` + (`Object prototype may only be an Object or null`). Inherited reads, `in`, `hasOwnProperty`, own-only + `Object.keys`, and `for...in` follow the chain as in JS. A second `properties` argument other than `undefined` throws a `TypeError`: property descriptors are not supported (there is no `Object.defineProperty` either). - [x] `Object.freeze`, `Object.seal`, and `Object.preventExtensions`, with `isFrozen`, `isSealed`, and `isExtensible`. diff --git a/packages/codemode/src/interpreter/interpreter.ts b/packages/codemode/src/interpreter/interpreter.ts index 8cdab139da5e..555de4ad2408 100644 --- a/packages/codemode/src/interpreter/interpreter.ts +++ b/packages/codemode/src/interpreter/interpreter.ts @@ -80,6 +80,7 @@ import { IteratorObj, type Cursor, has, + hasOwn, hidden, hasPrototype, keys, @@ -1097,11 +1098,23 @@ class Frame { throw typeError(`${context} must be a function.`, node) } - // for...in over null/undefined iterates nothing, like JS. + // for...in over null/undefined iterates nothing, like JS. After the own keys come each prototype's enumerable + // string keys, skipping any key a nearer level has, even as a non-enumerable property. private enumerableKeys(value: Value, node: AstNode): Array { if (value instanceof ToolReference) return [...this.ctx.tools.keys(value.path)] if (value === null || value === undefined) return [] - return keys(enumerableSource(this.ctx, "for...in", value, node)) + const source = enumerableSource(this.ctx, "for...in", value, node) + const chain = [source] + // A string's characters stand in an array, but its prototype is String.prototype. + let proto = primitivePrototype(this.ctx.builtins, value) ?? source.proto + while (proto !== null) { + chain.push(proto) + proto = proto.proto + } + return chain.flatMap((target, depth) => { + const nearer = chain.slice(0, depth) + return keys(target).filter((key) => !nearer.some((level) => hasOwn(level, key))) + }) } private *evaluateForInStatement(node: ForInStatement, labels?: ReadonlySet): Eval { diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index b9daf647c059..c46b6048d5be 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -176,4 +176,52 @@ describe("for...in", () => { "un-awaited Promise", ) }) + + const forIn = (target: string, setup = "") => + value(`${setup}; const out = []; for (const key in ${target}) out.push(key); return out`) + + test("visits inherited enumerable keys after own keys", async () => { + expect(await forIn("o", "const p = { inh: 1 }; const o = Object.create(p); o.own = 2")).toEqual(["own", "inh"]) + }) + + test("walks every level, skipping keys a nearer level already has", async () => { + expect( + await forIn( + "o", + `const top = { a: 1, top: 1 } + const mid = Object.create(top); mid.a = 2; mid.mid = 2 + const o = Object.create(mid); o.own = 3; o.mid = 3`, + ), + ).toEqual(["own", "mid", "a", "top"]) + }) + + test("orders integer-like keys first on each level", async () => { + expect( + await forIn("o", `const p = { b: 1, 10: 1, 2: 1 }; const o = Object.create(p); o.z = 1; o[5] = 1; o[1] = 1`), + ).toEqual(["1", "5", "z", "2", "10", "b"]) + }) + + test("a non-enumerable nearer property shadows an enumerable inherited one", async () => { + // An array's own length and Array.prototype.push are non-enumerable. + const setup = "Object.prototype.length = 1; Object.prototype.push = 2; Object.prototype.extra = 3" + expect(await forIn("[5]", setup)).toEqual(["0", "extra"]) + expect(await forIn("Object.create([5])", setup)).toEqual(["0", "extra"]) + }) + + test("a null prototype ends the walk", async () => { + expect(await forIn("o", "const o = Object.create(null); o.a = 1")).toEqual(["a"]) + expect(await forIn("Object.create(null)")).toEqual([]) + }) + + test("arrays and strings only visit their indexes, since built-in methods are non-enumerable", async () => { + expect(await forIn("[7, 8]")).toEqual(["0", "1"]) + expect(await forIn(`"ab"`)).toEqual(["0", "1"]) + }) + + test("visits enumerable properties a program adds to a built-in prototype", async () => { + expect(await forIn("[7, 8]", "Array.prototype.extra = 1")).toEqual(["0", "1", "extra"]) + expect(await forIn(`"ab"`, "Array.prototype.extra = 1")).toEqual(["0", "1"]) + expect(await forIn(`"ab"`, "String.prototype.extra = 1")).toEqual(["0", "1", "extra"]) + expect(await forIn("5", "Number.prototype.extra = 1")).toEqual(["extra"]) + }) }) diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 02f5d9eaa24e..72b00a447a53 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -1382,7 +1382,7 @@ describe("Object.getPrototypeOf and Object.create", () => { expect((await error(`Object.getPrototypeOf(Symbol.iterator)`)).message).toContain("cannot convert a symbol") }) - test("Object.create links the prototype: inherited reads, in, and own-only keys", async () => { + test("Object.create links the prototype: inherited reads, in, own-only keys, and inherited for...in", async () => { expect( await value(` const p = { greet(name) { return "hi " + name }, a: 1 } @@ -1392,7 +1392,7 @@ describe("Object.getPrototypeOf and Object.create", () => { for (const key in c) seen.push(key) return ["greet" in c, Object.keys(c), c.hasOwnProperty("a"), c.a, c.greet(c.name), Object.getPrototypeOf(c) === p, Object.getPrototypeOf(Object.create(null)), seen] `), - ).toEqual([true, ["name"], false, 1, "hi x", true, null, ["name"]]) + ).toEqual([true, ["name"], false, 1, "hi x", true, null, ["name", "greet", "a"]]) }) test("Object.create rejects non-object prototypes and property descriptors", async () => { From 0ab13468fd01c539d899f19cddcb3a3e7e4a4284 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:07:40 -0500 Subject: [PATCH 03/40] docs: document the experimental image_generate tool (#60105) --- services/www/src/docs/content/config.mdx | 19 +++++++++++++++++++ services/www/src/docs/content/permissions.mdx | 1 + services/www/src/docs/content/tools.mdx | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/services/www/src/docs/content/config.mdx b/services/www/src/docs/content/config.mdx index ef5ae78742e1..959762904a2f 100644 --- a/services/www/src/docs/content/config.mdx +++ b/services/www/src/docs/content/config.mdx @@ -627,6 +627,7 @@ All experimental settings live under the `experimental` key: "subagent_depth": 2, "portable_shell_scanner": true, "background_compaction": true, + "image_generate": true, "location_idle_timeout": "4 hours", }, } @@ -660,6 +661,24 @@ automatic compaction doesn't pause the session. Defaults to `false`. See } ``` +### Image generation + +Set `image_generate` to `true` to give agents the [`image_generate`](/tools#image-generate) tool for creating and +editing images. It is offered only while a connected provider has an image model. Defaults to `false`. + +To set a default model, use an object with `model` as `providerID/modelID`, optionally with `#variant`. When that model +isn't available or can't handle a request, OpenCode picks another image model automatically. + +```jsonc +{ + "experimental": { + "image_generate": { + "model": "openai/gpt-image-2", + }, + }, +} +``` + ### Location idle timeout `location_idle_timeout` controls how long a project location can go without session activity before OpenCode unloads diff --git a/services/www/src/docs/content/permissions.mdx b/services/www/src/docs/content/permissions.mdx index 31d26e6dcf03..809427337c3c 100644 --- a/services/www/src/docs/content/permissions.mdx +++ b/services/www/src/docs/content/permissions.mdx @@ -118,6 +118,7 @@ currently use these actions and resources: | `question` | `*` | | `webfetch` | Requested URL | | `websearch` | Search query | +| `image_generate` | Selected image model as `providerID/modelID`, plus `#variant` when set | | `external_directory` | Canonical external directory boundary, normally ending in `/*` | | `_` | `*` for an MCP tool; unsupported characters in both names become `_` | | `opencode_list_mcp_resources` | MCP server name | diff --git a/services/www/src/docs/content/tools.mdx b/services/www/src/docs/content/tools.mdx index a42134a1f6d0..88e29cd319fa 100644 --- a/services/www/src/docs/content/tools.mdx +++ b/services/www/src/docs/content/tools.mdx @@ -157,6 +157,25 @@ Search the web for the latest Bun release notes. Permission: `websearch` with the query as its resource. See [Websearch](/websearch) for providers, selection, rate limits, and disabling the tool. +## Images + +### Image generate + +`image_generate` creates a new image from a prompt, or edits existing ones. It +is experimental and off by default; turn it on with +[`experimental.image_generate`](/config#image-generation). It appears only +while a connected provider has an image model. + +```text +Generate a flat app icon of a paper plane on a blue background and save it to assets/icon.png. +``` + +Give it a `prompt`, and optionally `images` to edit or use as references, a +specific `model`, or a `path` to save to. It runs in the background and +notifies the session with the saved path when the image is ready. + +Permission: `image_generate` with the selected model as its resource. + ## Interaction ### Question From 6118c83b0153fc3bf40fb3542e0b88839e69b834 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:09:59 -0500 Subject: [PATCH 04/40] fix(merman): route visible flowchart subgraph edges (#60100) --- packages/merman/src/flowchart/drawing.ts | 8 +- .../merman/src/flowchart/flowchart.test.ts | 107 +++++ packages/merman/src/flowchart/layout.ts | 371 +++++++++++++++--- packages/merman/src/flowchart/parser.ts | 89 ++++- packages/merman/src/flowchart/routing.ts | 299 +++++++++----- packages/merman/src/flowchart/types.ts | 2 + .../merman/src/test/layout-audit/fixtures.ts | 23 ++ .../merman/src/test/layout-audit/harness.ts | 3 +- 8 files changed, 741 insertions(+), 161 deletions(-) diff --git a/packages/merman/src/flowchart/drawing.ts b/packages/merman/src/flowchart/drawing.ts index a3f4422bd498..4b7bbc20f76f 100644 --- a/packages/merman/src/flowchart/drawing.ts +++ b/packages/merman/src/flowchart/drawing.ts @@ -29,6 +29,7 @@ import { import type { FlowchartDiagram, FlowchartEdgeRoute, + FlowchartEndpointBounds, FlowchartNode, FlowchartNodeBounds, FlowchartPoint, @@ -256,7 +257,7 @@ function fadeSourcePath( function drawSourceConnectors( grid: FlowchartGrid, diagram: FlowchartDiagram, - bounds: Map, + endpoints: ReadonlyMap, routes: readonly FlowchartEdgeRoute[], ): void { const nodesById = new Map(diagram.nodes.map((node) => [node.id, node])) @@ -275,7 +276,7 @@ function drawSourceConnectors( } for (const route of routes) { - const from = bounds.get(route.edge.from) + const from = endpoints.get(route.edge.from) const sourcePoint = route.points[0] if (!from || !sourcePoint) continue const styles = sourceFadeStyles(flowchartNodeStyle(nodesById.get(route.edge.from))) @@ -328,7 +329,8 @@ export function drawFlowchartDiagramGrid( const bound = bounds.get(node.id) if (bound) drawNode(grid, node, bound, borderStyle) } - drawSourceConnectors(grid, diagram, bounds, routes) + const endpoints = new Map([...subgraphBounds, ...bounds]) + drawSourceConnectors(grid, diagram, endpoints, routes) for (const subgraph of diagram.subgraphs ?? []) { const bound = subgraphBounds.get(subgraph.id) if (bound) drawSubgraphLabel(grid, bound) diff --git a/packages/merman/src/flowchart/flowchart.test.ts b/packages/merman/src/flowchart/flowchart.test.ts index d208f42e4b27..f45601896516 100644 --- a/packages/merman/src/flowchart/flowchart.test.ts +++ b/packages/merman/src/flowchart/flowchart.test.ts @@ -1596,6 +1596,113 @@ flowchart TD expect(output).toContain("After — merged this week") }) + test("routes visible subgraph-to-subgraph chains without creating phantom nodes", () => { + const content = `flowchart TD + subgraph S1["1. provider.transform (seed)"] + A1["Catalog model → copy catalog variants"] + A2["Custom model → apply package/modelID/limit + seed default variants"] + end + subgraph S2["2. plugin model.transform"] + B["Plugin can replace variants, clear to [], or ignore"] + end + subgraph S3["3. config model.transform (post)"] + C1{"Config sets reasoning or variants?"} + C1 -- yes --> C2["Apply explicit config"] + C1 -- no --> C3{"Config changed package (or recreated removed model)?"} + C3 -- yes --> C4["Respell / reseed variants"] + C3 -- no --> C5["Keep current variants"] + end + S1 --> S2 --> S3` + const diagram = parseMermaidFlowchartDiagram(content) + expect(diagram.nodes.some((node) => node.id === "S1" || node.id === "S2" || node.id === "S3")).toBe(false) + + for (const layoutMaxWidth of [60, 80, 120]) { + const layout = layoutParsedFlowchartDiagram(diagram, { compact: true, layoutMaxWidth }) + const grid = drawParsedFlowchartDiagramGrid(diagram, { compact: true, layoutMaxWidth }) + const s1 = layout.subgraphBounds.get("S1")! + const s2 = layout.subgraphBounds.get("S2")! + const s3 = layout.subgraphBounds.get("S3")! + + expect(s1.top + s1.height).toBeLessThan(s2.top) + expect(s2.top + s2.height).toBeLessThan(s3.top) + + for (const [fromId, toId, fromFrame, toFrame] of [ + ["S1", "S2", s1, s2], + ["S2", "S3", s2, s3], + ] as const) { + const route = layout.routes.find((candidate) => candidate.edge.from === fromId && candidate.edge.to === toId)! + const start = route.points[0]! + const end = route.points.at(-1)! + expect(start.y).toBe(fromFrame.top + fromFrame.height) + expect(end.y).toBe(toFrame.top - 1) + expect(grid.getCell(start.x, fromFrame.top + fromFrame.height - 1)?.char).toBe("┬") + expect(grid.getCell(end.x, end.y)?.char).toBe("▼") + } + } + }) + + test("resolves forward, nested, labeled, and upward subgraph endpoints while keeping explicit same-id nodes", () => { + const forward = parseMermaidFlowchartDiagram(`flowchart TD + Start[Start] --> S1 + S1 -->|done| Done[Done] + subgraph S1["Stage"] + W1[Step 1] --> W2[Step 2] + end`) + expect(forward.nodes.map((node) => node.id)).toEqual(["Start", "Done", "W1", "W2"]) + const forwardOutput = renderFlowchartDiagram(`flowchart TD + Start[Start] --> S1 + S1 -->|done| Done[Done] + subgraph S1["Stage"] + W1[Step 1] --> W2[Step 2] + end`) + expect(forwardOutput).toContain("done") + + const btGrid = drawParsedFlowchartDiagramGrid( + parseMermaidFlowchartDiagram(`flowchart BT + subgraph S1["First group title"] + A1[Alpha] + end + subgraph S2["Second group title"] + B1[Beta] + end + S1 --> S2`), + { compact: true }, + ) + const btText = btGrid.toString({ trimTop: true, trimBottom: true }) + expect(btText).toContain("First group title") + expect(btText).toContain("┴") + expect(btText).toContain("▲") + + const nestedLayout = layoutFlowchartDiagram(`flowchart TD + subgraph Outer["Outer"] + Entry[Entry] --> Inner + subgraph Inner["Inner"] + A[One] --> B[Two] + end + end`) + const outer = nestedLayout.subgraphBounds.get("Outer")! + const entryRoute = nestedLayout.routes.find((route) => route.edge.from === "Entry" && route.edge.to === "Inner")! + expect( + entryRoute.points.every( + (point) => + point.x > outer.left && + point.x < outer.left + outer.width - 1 && + point.y > outer.top && + point.y < outer.top + outer.height - 1, + ), + ).toBe(true) + + const sameId = parseMermaidFlowchartDiagram(`flowchart LR + subgraph API + API[API Gateway] --> DB[(Postgres)] + end`) + expect(sameId.nodes.map((node) => node.id)).toEqual(["API", "DB"]) + const sameIdLayout = layoutParsedFlowchartDiagram(sameId, { compact: true }) + const apiNode = sameIdLayout.bounds.get("API")! + const apiRoute = sameIdLayout.routes[0]! + expect(apiRoute.points[0]!.x).toBe(apiNode.left + apiNode.width) + }) + test("keeps every node and edge in a long chain distinct and in its subgraph", () => { const diagram = parseMermaidFlowchartDiagram(`flowchart LR subgraph Pipeline diff --git a/packages/merman/src/flowchart/layout.ts b/packages/merman/src/flowchart/layout.ts index 32348c8a1c41..f54d8b9f3612 100644 --- a/packages/merman/src/flowchart/layout.ts +++ b/packages/merman/src/flowchart/layout.ts @@ -14,7 +14,7 @@ import { flowchartVerticalBranchLabelGap, } from "./labels.js" import type { FlowchartDiagramRenderOptions } from "./options.js" -import { avoidFlowchartFrameBorders, routeFlowchartEdges } from "./routing.js" +import { avoidFlowchartFrameBorders, flowchartSourceConnector, routeFlowchartEdges } from "./routing.js" import type { FlowchartDiagram, FlowchartDirection, @@ -24,6 +24,7 @@ import type { FlowchartNodeBounds, FlowchartNodeSize, FlowchartPoint, + FlowchartSubgraph, FlowchartSubgraphBounds, } from "./types.js" @@ -37,6 +38,8 @@ export const COMPACT_MIN_VERTICAL_RANK_GAP = 2 const SUBGRAPH_PADDING_X = 2 const SUBGRAPH_PADDING_TOP = 1 const SUBGRAPH_PADDING_BOTTOM = 1 +const MIN_SUBGRAPH_VERTICAL_EDGE_GAP = 3 +const MIN_SUBGRAPH_HORIZONTAL_EDGE_GAP = 4 export interface FlowchartLayout { diagram: FlowchartDiagram @@ -49,6 +52,61 @@ export interface FlowchartLayout { type FlowchartBounds = Pick +function endpointRankSpan( + diagram: FlowchartDiagram, + edge: FlowchartEdge, + normalizedRanks: ReadonlyMap, + nodeIds: ReadonlySet, +): + | { startRank: number; endRank: number; hasSubgraphEndpoint: boolean; unsharedSubgraphs: FlowchartSubgraph[] } + | undefined { + const fromRanks = (nodeIds.has(edge.from) ? [edge.from] : [...collectSubgraphNodeIds(diagram, edge.from)]).flatMap( + (id) => { + const rank = normalizedRanks.get(id) + return rank === undefined ? [] : [rank] + }, + ) + const toRanks = (nodeIds.has(edge.to) ? [edge.to] : [...collectSubgraphNodeIds(diagram, edge.to)]).flatMap((id) => { + const rank = normalizedRanks.get(id) + return rank === undefined ? [] : [rank] + }) + if (fromRanks.length === 0 || toRanks.length === 0) return undefined + const forwardStart = Math.max(...fromRanks) + const forwardEnd = Math.min(...toRanks) + const reverseStart = Math.max(...toRanks) + const reverseEnd = Math.min(...fromRanks) + const span = + forwardStart < forwardEnd + ? { startRank: forwardStart, endRank: forwardEnd } + : reverseStart < reverseEnd + ? { startRank: reverseStart, endRank: reverseEnd } + : undefined + if (!span) return undefined + + const hasSubgraphEndpoint = !nodeIds.has(edge.from) || !nodeIds.has(edge.to) + const subgraphs = diagram.subgraphs ?? [] + const unsharedSubgraphs = hasSubgraphEndpoint + ? subgraphs.filter((subgraph) => { + const containsFrom = subgraph.id === edge.from || subgraphContainsEndpoint(diagram, subgraph, edge.from) + const containsTo = subgraph.id === edge.to || subgraphContainsEndpoint(diagram, subgraph, edge.to) + return containsFrom !== containsTo + }) + : [] + return { ...span, hasSubgraphEndpoint, unsharedSubgraphs } +} + +function ensureRankSpanGap(gaps: number[], startIndex: number, endIndex: number, required: number): void { + const low = Math.min(startIndex, endIndex) + const high = Math.max(startIndex, endIndex) + if (low === high) return + if (high - low === 1) { + gaps[low] = Math.max(gaps[low]!, required) + return + } + const current = gaps.slice(low, high).reduce((sum, gap) => sum + gap, 0) + if (current < required) gaps[high - 1] = gaps[high - 1]! + (required - current) +} + function horizontalRankGaps( diagram: FlowchartDiagram, normalizedRanks: ReadonlyMap, @@ -57,17 +115,37 @@ function horizontalRankGaps( ): number[] { const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback) const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index])) + const nodeIds = new Set(diagram.nodes.map((node) => node.id)) for (const edge of diagram.edges) { - if (!edge.label) continue - const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1) - const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1) - if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue - - const labelGap = flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength)) - for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) { - gaps[index] = Math.max(gaps[index]!, labelGap) + if (edge.orderOnly) continue + const hasSubgraphEndpoint = !nodeIds.has(edge.from) || !nodeIds.has(edge.to) + if (!edge.label && !hasSubgraphEndpoint) continue + + if (!hasSubgraphEndpoint) { + const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1) + const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1) + if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue + const labelGap = flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength)) + for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) { + gaps[index] = Math.max(gaps[index]!, labelGap) + } + continue } + + const span = endpointRankSpan(diagram, edge, normalizedRanks, nodeIds) + if (!span) continue + const startIndex = rankIndexes.get(span.startRank) + const endIndex = rankIndexes.get(span.endRank) + if (startIndex === undefined || endIndex === undefined) continue + const padding = span.unsharedSubgraphs.length * SUBGRAPH_PADDING_X + const clearance = edge.label + ? Math.max( + MIN_SUBGRAPH_HORIZONTAL_EDGE_GAP, + flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength)), + ) + : MIN_SUBGRAPH_HORIZONTAL_EDGE_GAP + ensureRankSpanGap(gaps, startIndex, endIndex, padding + clearance) } return gaps @@ -81,17 +159,37 @@ function verticalRankGaps( ): number[] { const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback) const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index])) + const nodeIds = new Set(diagram.nodes.map((node) => node.id)) for (const edge of diagram.edges) { - if (!edge.label) continue - const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1) - const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1) - if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue - - const labelHeight = splitDiagramLines(edge.label).length - for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) { - gaps[index] = Math.max(gaps[index]!, labelHeight + 2) + if (edge.orderOnly) continue + const hasSubgraphEndpoint = !nodeIds.has(edge.from) || !nodeIds.has(edge.to) + if (!edge.label && !hasSubgraphEndpoint) continue + + if (!hasSubgraphEndpoint) { + const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1) + const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1) + if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue + const labelHeight = splitDiagramLines(edge.label).length + for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) { + gaps[index] = Math.max(gaps[index]!, labelHeight + 2) + } + continue } + + const span = endpointRankSpan(diagram, edge, normalizedRanks, nodeIds) + if (!span) continue + const startIndex = rankIndexes.get(span.startRank) + const endIndex = rankIndexes.get(span.endRank) + if (startIndex === undefined || endIndex === undefined) continue + const padding = span.unsharedSubgraphs.reduce( + (sum, subgraph) => sum + Math.max(SUBGRAPH_PADDING_TOP, splitDiagramLines(subgraph.label).length), + 0, + ) + const clearance = edge.label + ? Math.max(MIN_SUBGRAPH_VERTICAL_EDGE_GAP, splitDiagramLines(edge.label).length + 2) + : MIN_SUBGRAPH_VERTICAL_EDGE_GAP + ensureRankSpanGap(gaps, startIndex, endIndex, padding + clearance) } return gaps @@ -133,10 +231,40 @@ function rankNodes(diagram: FlowchartDiagram): Map { const incomingCounts = new Map(diagram.nodes.map((node) => [node.id, 0])) const nodeIds = new Set(diagram.nodes.map((node) => node.id)) + const subgraphs = diagram.subgraphs ?? [] + const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph])) + const insideSubgraph = (endpointId: string, subgraphId: string): boolean => { + const subgraph = subgraphById.get(subgraphId) + return Boolean(subgraph && subgraphContainsEndpoint(diagram, subgraph, endpointId)) + } + const subgraphExitNodes = (subgraphId: string): string[] => { + const members = collectSubgraphNodeIds(diagram, subgraphId) + const hasInternalOutgoing = new Set() + for (const edge of diagram.edges) { + if (edge.from === edge.to || !insideSubgraph(edge.from, subgraphId) || !insideSubgraph(edge.to, subgraphId)) + continue + const sources = nodeIds.has(edge.from) ? [edge.from] : [...collectSubgraphNodeIds(diagram, edge.from)] + for (const source of sources) hasInternalOutgoing.add(source) + } + const exits = [...members].filter((id) => !hasInternalOutgoing.has(id)) + return exits.length > 0 ? exits : [...members] + } + const subgraphEntryNodes = (subgraphId: string): string[] => { + const members = collectSubgraphNodeIds(diagram, subgraphId) + const hasInternalIncoming = new Set() + for (const edge of diagram.edges) { + if (edge.from === edge.to || !insideSubgraph(edge.from, subgraphId) || !insideSubgraph(edge.to, subgraphId)) + continue + const targets = nodeIds.has(edge.to) ? [edge.to] : [...collectSubgraphNodeIds(diagram, edge.to)] + for (const target of targets) hasInternalIncoming.add(target) + } + const entries = [...members].filter((id) => !hasInternalIncoming.has(id)) + return entries.length > 0 ? entries : [...members] + } const rankEdges = diagram.edges.flatMap((edge) => { - if (!edge.orderOnly) return [edge] - const fromIds = nodeIds.has(edge.from) ? [edge.from] : [...collectSubgraphNodeIds(diagram, edge.from)] - const toIds = nodeIds.has(edge.to) ? [edge.to] : [...collectSubgraphNodeIds(diagram, edge.to)] + if (nodeIds.has(edge.from) && nodeIds.has(edge.to)) return [edge] + const fromIds = nodeIds.has(edge.from) ? [edge.from] : subgraphExitNodes(edge.from) + const toIds = nodeIds.has(edge.to) ? [edge.to] : subgraphEntryNodes(edge.to) return fromIds.flatMap((from) => toIds.map((to) => ({ ...edge, from, to }))) }) @@ -288,7 +416,23 @@ function segmentOverlapsSlot( ) } -function routeOverlapsSlot(route: FlowchartEdgeRoute, slot: FlowchartBounds): boolean { +function routeOverlapsSlot( + route: FlowchartEdgeRoute, + bounds: FlowchartSubgraphBounds, + slot: FlowchartBounds, + nodeIds: ReadonlySet, +): boolean { + if (route.edge.from === bounds.id && !nodeIds.has(bounds.id) && route.points[0]) { + const connector = flowchartSourceConnector(bounds, route.points[0]) + if ( + connector.x >= slot.left && + connector.x < slot.left + slot.width && + connector.y >= slot.top && + connector.y < slot.top + slot.height + ) { + return true + } + } for (let index = 1; index < route.points.length; index++) { if (segmentOverlapsSlot(route.points[index - 1]!, route.points[index]!, slot)) return true } @@ -316,12 +460,13 @@ function routeOverlapsSlot(route: FlowchartEdgeRoute, slot: FlowchartBounds): bo function chooseSubgraphLabelSide( bounds: FlowchartSubgraphBounds, routes: readonly FlowchartEdgeRoute[], + nodeIds: ReadonlySet, ): FlowchartSubgraphBounds["labelSide"] { const topSlot = labelSlot(bounds, "top") - if (!routes.some((route) => routeOverlapsSlot(route, topSlot))) return "top" + if (!routes.some((route) => routeOverlapsSlot(route, bounds, topSlot, nodeIds))) return "top" const bottomSlot = labelSlot(bounds, "bottom") - return routes.some((route) => routeOverlapsSlot(route, bottomSlot)) ? "top" : "bottom" + return routes.some((route) => routeOverlapsSlot(route, bounds, bottomSlot, nodeIds)) ? "top" : "bottom" } function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBounds | undefined { @@ -661,7 +806,12 @@ function layoutLocalSubgraphDirections( const itemByEndpoint = new Map() for (const item of items) { for (const nodeId of item.nodeIds) itemByEndpoint.set(nodeId, item.id) - if (item.childId) itemByEndpoint.set(item.childId, item.id) + if (item.childId) { + itemByEndpoint.set(item.childId, item.id) + for (const nestedId of collectDescendantSubgraphIds(diagram, item.childId)) { + itemByEndpoint.set(nestedId, item.id) + } + } } const nodes = items.map((item): FlowchartNode => ({ id: item.id, label: item.id, shape: "box" })) const localDiagram: FlowchartDiagram = { @@ -757,10 +907,28 @@ function layoutLocalSubgraphDirections( return wrapped } +function collectDescendantSubgraphIds(diagram: FlowchartDiagram, subgraphId: string): Set { + const ids = new Set() + for (const child of diagram.subgraphs ?? []) { + if (child.parentId !== subgraphId) continue + ids.add(child.id) + for (const nested of collectDescendantSubgraphIds(diagram, child.id)) ids.add(nested) + } + return ids +} + +function subgraphContainsEndpoint(diagram: FlowchartDiagram, subgraph: FlowchartSubgraph, endpointId: string): boolean { + return ( + collectSubgraphNodeIds(diagram, subgraph.id).has(endpointId) || + collectDescendantSubgraphIds(diagram, subgraph.id).has(endpointId) + ) +} + function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection { for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) { if (!subgraph.direction) continue - if (subgraph.nodeIds.includes(edge.from) && subgraph.nodeIds.includes(edge.to)) return subgraph.direction + if (subgraphContainsEndpoint(diagram, subgraph, edge.from) && subgraphContainsEndpoint(diagram, subgraph, edge.to)) + return subgraph.direction } return diagram.direction } @@ -896,6 +1064,29 @@ function separateTopLevelItems( if (bounds) translateBounds(bounds, dx, dy) } } + const nodeIds = new Set(diagram.nodes.map((node) => node.id)) + const minSubgraphEdgeGap = (fromItemId: string, toItemId: string): number => { + const matching = diagram.edges.filter( + (edge) => + !edge.orderOnly && + (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) && + itemByEndpoint.get(edge.from) === fromItemId && + itemByEndpoint.get(edge.to) === toItemId, + ) + if (matching.length === 0) return gap + const labelRequirements = matching.map((edge) => + !edge.label + ? 0 + : horizontal + ? flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength)) + : splitDiagramLines(edge.label).length + 2, + ) + return Math.max( + gap, + horizontal ? MIN_SUBGRAPH_HORIZONTAL_EDGE_GAP : MIN_SUBGRAPH_VERTICAL_EDGE_GAP, + ...labelRequirements, + ) + } if (hasLocalDirection) { const outgoing = new Map(items.map((item) => [item.id, new Set()])) for (const edge of diagram.edges) { @@ -913,24 +1104,63 @@ function separateTopLevelItems( (horizontal ? a.bounds.centerX - b.bounds.centerX : a.bounds.centerY - b.bounds.centerY), ) let cursor: number | undefined + let previousId: string | undefined let moved = false for (const item of items) { const start = horizontal ? item.bounds.left : item.bounds.top const size = horizontal ? item.bounds.width : item.bounds.height if (cursor === undefined) { - cursor = start + size + gap + cursor = start + size + previousId = item.id continue } - const shift = cursor - start + const pairGap = previousId + ? Math.max(minSubgraphEdgeGap(previousId, item.id), minSubgraphEdgeGap(item.id, previousId)) + : gap + const shift = cursor + pairGap - start moved ||= shift !== 0 moveItem(item, horizontal ? shift : 0, horizontal ? 0 : shift) - cursor = start + shift + size + gap + cursor = start + shift + size + previousId = item.id } return moved } const rankedItems = items.filter((item) => topLevelIds.has(item.id)) - if (rankedItems.length < 2) return false + const reversed = diagram.direction === "RL" || diagram.direction === "BT" + const primaryStart = (bound: FlowchartBounds): number => { + const start = horizontal ? bound.left : bound.top + const size = horizontal ? bound.width : bound.height + return reversed ? -(start + size) : start + } + const primaryEnd = (bound: FlowchartBounds): number => primaryStart(bound) + (horizontal ? bound.width : bound.height) + const ensureSubgraphEdgeItemClearance = (): boolean => { + const allItemById = new Map(items.map((item) => [item.id, item])) + const endpoints = new Map([...subgraphBounds, ...nodeBounds]) + let adjusted = false + for (const edge of diagram.edges) { + if (edge.orderOnly || (nodeIds.has(edge.from) && nodeIds.has(edge.to))) continue + const fromItem = allItemById.get(itemByEndpoint.get(edge.from) ?? "") + const toItem = allItemById.get(itemByEndpoint.get(edge.to) ?? "") + const fromBound = endpoints.get(edge.from) + const toBound = endpoints.get(edge.to) + if (!fromItem || !toItem || fromItem === toItem || !fromBound || !toBound) continue + if (primaryStart(toBound) <= primaryStart(fromBound)) continue + const required = minSubgraphEdgeGap(fromItem.id, toItem.id) + const deficit = primaryEnd(fromBound) + required - primaryStart(toBound) + if (deficit <= 0) continue + adjusted = true + const toStart = primaryStart(toItem.bounds) + const offset = reversed ? -deficit : deficit + for (const candidate of items) { + if (candidate !== toItem && primaryStart(candidate.bounds) < toStart) continue + if (candidate === fromItem) continue + moveItem(candidate, horizontal ? offset : 0, horizontal ? 0 : offset) + } + } + return adjusted + } + if (rankedItems.length < 2) return ensureSubgraphEdgeItemClearance() const itemById = new Map(rankedItems.map((item) => [item.id, item])) const outgoing = new Map(rankedItems.map((item) => [item.id, new Set()])) @@ -947,27 +1177,25 @@ function separateTopLevelItems( ) for (const item of rankedItems) item.rank = ranks.get(item.id)! - const reversed = diagram.direction === "RL" || diagram.direction === "BT" - const primaryStart = (item: (typeof items)[number]): number => { - const start = horizontal ? item.bounds.left : item.bounds.top - const size = horizontal ? item.bounds.width : item.bounds.height - return reversed ? -(start + size) : start - } const itemsByRank = Map.groupBy(rankedItems, (item) => item.rank) const rankKeys = [...itemsByRank.keys()].sort((a, b) => a - b) - let cursor: number | undefined + let previousEnd: number | undefined + let previousRankItems: (typeof items)[number][] = [] let moved = false for (const rank of rankKeys) { const rankItems = itemsByRank.get(rank)! - const start = Math.min(...rankItems.map(primaryStart)) - const end = Math.max( - ...rankItems.map((item) => primaryStart(item) + (horizontal ? item.bounds.width : item.bounds.height)), - ) - if (cursor === undefined) { - cursor = end + gap + const start = Math.min(...rankItems.map((item) => primaryStart(item.bounds))) + const end = Math.max(...rankItems.map((item) => primaryEnd(item.bounds))) + if (previousEnd === undefined) { + previousEnd = end + previousRankItems = rankItems continue } - const shift = Math.max(0, cursor - start) + const rankGap = Math.max( + gap, + ...previousRankItems.flatMap((prev) => rankItems.map((curr) => minSubgraphEdgeGap(prev.id, curr.id))), + ) + const shift = Math.max(0, previousEnd + rankGap - start) if (shift > 0) { moved = true for (const item of rankItems) { @@ -975,7 +1203,8 @@ function separateTopLevelItems( moveItem(item, horizontal ? offset : 0, horizontal ? 0 : offset) } } - cursor = end + shift + gap + previousEnd = end + shift + previousRankItems = rankItems } for (const rank of rankKeys) { @@ -1027,6 +1256,7 @@ function separateTopLevelItems( } } } + moved = ensureSubgraphEdgeItemClearance() || moved return moved } @@ -1037,6 +1267,7 @@ function layoutSubgraphs( ): Map { const subgraphBounds = new Map() const subgraphs = diagram.subgraphs ?? [] + const nodeIds = new Set(diagram.nodes.map((node) => node.id)) for (const subgraph of [...subgraphs].reverse()) { const children: FlowchartBounds[] = [] @@ -1052,7 +1283,7 @@ function layoutSubgraphs( } if (children.length > 0) { const bound = subgraphBoundFromChildren(subgraph.id, subgraph.label, children) - bound.labelSide = chooseSubgraphLabelSide(bound, routes) + bound.labelSide = chooseSubgraphLabelSide(bound, routes, nodeIds) subgraphBounds.set(subgraph.id, bound) } } @@ -1088,14 +1319,33 @@ function layoutFlowchartWithDirection( const bounds = ranked.bounds const responsive = layoutLocalSubgraphDirections(diagram, bounds, minNodeGap, requestedMinRankGap, targetWidth) const directionAligned = responsiveFallback || responsive || ranked.wrapped - const route = (frames?: ReadonlyMap) => - routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), frames, targetWidth, directionAligned) + const nodeIds = new Set(diagram.nodes.map((node) => node.id)) + const hasSubgraphEndpoints = diagram.edges.some( + (edge) => !edge.orderOnly && (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)), + ) + const memberDiagram = hasSubgraphEndpoints + ? { ...diagram, edges: diagram.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)) } + : diagram + const route = ( + frames?: ReadonlyMap, + targetDiagram: FlowchartDiagram = memberDiagram, + lockedRoutes?: ReadonlyMap, + ) => + routeFlowchartEdges( + targetDiagram, + bounds, + (edge) => edgeDirection(diagram, edge), + frames, + targetWidth, + directionAligned, + lockedRoutes, + ) const subgraphs = diagram.subgraphs ?? [] let subgraphBounds = new Map() let routes: FlowchartEdgeRoute[] if (subgraphs.length === 0) { - routes = route() + routes = route(undefined, diagram) } else { routes = route() subgraphBounds = layoutSubgraphs(diagram, bounds, routes) @@ -1122,6 +1372,31 @@ function layoutFlowchartWithDirection( targetWidth, ) if (expanded) routes = route(subgraphBounds) + if (hasSubgraphEndpoints) { + const locked = new Map(routes.map((memberRoute) => [memberRoute.edge, memberRoute])) + for (const edge of diagram.edges) { + if (edge.orderOnly || nodeIds.has(edge.from)) continue + const sourceFrame = subgraphBounds.get(edge.from) + const targetBound = bounds.get(edge.to) ?? subgraphBounds.get(edge.to) + if (!sourceFrame || !targetBound || sourceFrame.labelSide !== "top") continue + if (!isHorizontalDirection(edgeDirection(diagram, edge)) && targetBound.centerY < sourceFrame.centerY) { + const bottomSlot = labelSlot(sourceFrame, "bottom") + if (!routes.some((route) => routeOverlapsSlot(route, sourceFrame, bottomSlot, nodeIds))) { + sourceFrame.labelSide = "bottom" + } + } + } + routes = route(subgraphBounds, diagram, locked) + let labelSideChanged = false + for (const bound of subgraphBounds.values()) { + const nextSide = chooseSubgraphLabelSide(bound, routes, nodeIds) + if (nextSide !== bound.labelSide) { + bound.labelSide = nextSide + labelSideChanged = true + } + } + if (labelSideChanged) routes = route(subgraphBounds, diagram, locked) + } avoidFlowchartFrameBorders(routes, bounds, subgraphBounds) } freezeRouteLabelPoints(routes) diff --git a/packages/merman/src/flowchart/parser.ts b/packages/merman/src/flowchart/parser.ts index 8f933bcf6984..525f6817c2d3 100644 --- a/packages/merman/src/flowchart/parser.ts +++ b/packages/merman/src/flowchart/parser.ts @@ -274,6 +274,61 @@ function hasInternalStatementSeparator(line: string): boolean { return false } +function resolveSubgraphEndpoints( + nodes: Map, + edges: readonly FlowchartEdge[], + subgraphs: FlowchartSubgraph[], + explicitShapeIds: ReadonlySet, + referencedInOpenSubgraphs: ReadonlyMap>, +): void { + const resolved = new Set() + const descendantsOf = (subgraphId: string): Set => { + const ids = new Set() + for (const child of subgraphs) { + if (child.parentId !== subgraphId) continue + ids.add(child.id) + for (const nested of descendantsOf(child.id)) ids.add(nested) + } + return ids + } + const membersOf = (subgraphId: string, descendants: ReadonlySet): Set => { + const ids = new Set() + for (const candidate of subgraphs) { + if (candidate.id !== subgraphId && !descendants.has(candidate.id)) continue + for (const nodeId of candidate.nodeIds) { + if (nodeId === subgraphId || descendants.has(nodeId) || resolved.has(nodeId)) continue + ids.add(nodeId) + } + } + return ids + } + + for (const subgraph of [...subgraphs].reverse()) { + subgraph.nodeIds = subgraph.nodeIds.filter((id) => !resolved.has(id)) + const descendants = descendantsOf(subgraph.id) + const members = membersOf(subgraph.id, descendants) + const linksSelfOrDescendant = edges.some( + (edge) => + (edge.from === subgraph.id && (edge.to === subgraph.id || members.has(edge.to) || descendants.has(edge.to))) || + (edge.to === subgraph.id && (members.has(edge.from) || descendants.has(edge.from))), + ) + if ( + explicitShapeIds.has(subgraph.id) || + referencedInOpenSubgraphs.get(subgraph.id)?.has(subgraph.id) || + members.size === 0 || + linksSelfOrDescendant + ) { + continue + } + resolved.add(subgraph.id) + nodes.delete(subgraph.id) + } + + for (const subgraph of subgraphs) { + subgraph.nodeIds = subgraph.nodeIds.filter((id) => !resolved.has(id)) + } +} + export function isMermaidFlowchartDiagram(content: string): boolean { return FLOWCHART_HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "") } @@ -283,6 +338,18 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram const edges: FlowchartEdge[] = [] const subgraphs: FlowchartSubgraph[] = [] const subgraphStack: Array<{ subgraph: FlowchartSubgraph; lineNumber: number; sourceLine: string }> = [] + const explicitShapeIds = new Set() + const referencedInOpenSubgraphs = new Map>() + const trackNodeToken = (stripped: string): string => { + const node = ensureNode(nodes, stripped) + if (hasExplicitNodeShape(stripped)) explicitShapeIds.add(node.id) + for (const open of subgraphStack) { + const set = referencedInOpenSubgraphs.get(node.id) ?? new Set() + set.add(open.subgraph.id) + referencedInOpenSubgraphs.set(node.id, set) + } + return node.id + } let direction: FlowchartDirection = DEFAULT_DIRECTION for (const source of meaningfulNumberedMermaidLines(content)) { @@ -348,22 +415,11 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram ].map((group) => splitNodeGroup(group).map(stripNodeToken)) if (nodeGroups.every((group) => group.every((token) => token.length > 0))) { - const unsupportedEndpoint = nodeGroups.find((group, index) => { - const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly - return group.some( - (stripped) => - !(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) && - !isSupportedNodeToken(stripped), - ) - }) + const unsupportedEndpoint = nodeGroups.find((group) => + group.some((stripped) => !isSupportedNodeToken(stripped)), + ) if (unsupportedEndpoint) throw new MermaidSyntaxError("flowchart", source.lineNumber, line) - const chainNodeIds = nodeGroups.map((group, index) => { - const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly - return group.map((stripped) => { - if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped - return ensureNode(nodes, stripped).id - }) - }) + const chainNodeIds = nodeGroups.map((group) => group.map(trackNodeToken)) for (const nodeId of chainNodeIds.flat()) { if (nodes.has(nodeId)) addNodeToSubgraph(currentSubgraph, nodeId) } @@ -389,7 +445,7 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram const nodeGroup = splitNodeGroup(line) if (nodeGroup.every(isSupportedNodeToken)) { - for (const token of nodeGroup) addNodeToSubgraph(currentSubgraph, ensureNode(nodes, stripNodeToken(token)).id) + for (const token of nodeGroup) addNodeToSubgraph(currentSubgraph, trackNodeToken(stripNodeToken(token))) continue } @@ -406,5 +462,6 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram ) } + resolveSubgraphEndpoints(nodes, edges, subgraphs, explicitShapeIds, referencedInOpenSubgraphs) return { direction, nodes: [...nodes.values()], edges, subgraphs } } diff --git a/packages/merman/src/flowchart/routing.ts b/packages/merman/src/flowchart/routing.ts index e158825ee9c8..1bac11d9cf10 100644 --- a/packages/merman/src/flowchart/routing.ts +++ b/packages/merman/src/flowchart/routing.ts @@ -32,6 +32,7 @@ import type { FlowchartDirection, FlowchartEdge, FlowchartEdgeRoute, + FlowchartEndpointBounds, FlowchartNodeBounds, FlowchartPoint, FlowchartSubgraph, @@ -57,13 +58,13 @@ function isVerticalDirection(direction: FlowchartDirection): boolean { return direction === "TB" || direction === "TD" || direction === "BT" } -function verticalTravel(from: FlowchartNodeBounds, to: FlowchartNodeBounds): VerticalTravel { +function verticalTravel(from: FlowchartEndpointBounds, to: FlowchartEndpointBounds): VerticalTravel { return centerCoordinate(to, "y") >= centerCoordinate(from, "y") ? "down" : "up" } function isVerticalBackEdge( - from: FlowchartNodeBounds, - to: FlowchartNodeBounds, + from: FlowchartEndpointBounds, + to: FlowchartEndpointBounds, direction: FlowchartDirection, ): boolean { return direction === "BT" @@ -72,8 +73,8 @@ function isVerticalBackEdge( } function isHorizontalBackEdge( - from: FlowchartNodeBounds, - to: FlowchartNodeBounds, + from: FlowchartEndpointBounds, + to: FlowchartEndpointBounds, direction: FlowchartDirection, ): boolean { return direction === "RL" @@ -82,8 +83,8 @@ function isHorizontalBackEdge( } function horizontalTravel( - from: FlowchartNodeBounds, - to: FlowchartNodeBounds, + from: FlowchartEndpointBounds, + to: FlowchartEndpointBounds, direction: FlowchartDirection, ): HorizontalTravel { const targetIsRight = centerCoordinate(to, "x") > centerCoordinate(from, "x") @@ -91,13 +92,37 @@ function horizontalTravel( return direction === "RL" ? (targetIsRight ? "right" : "left") : targetIsSameOrRight ? "right" : "left" } +function isSubgraphBounds(bounds: FlowchartEndpointBounds): bounds is FlowchartSubgraphBounds { + return "labelSide" in bounds +} + +function clearSubgraphTitlePort(bounds: FlowchartEndpointBounds, port: FlowchartPoint, role: PortRole): FlowchartPoint { + if (role !== "source" || !isSubgraphBounds(bounds) || bounds.label.length === 0) return port + const connector = flowchartSourceConnector(bounds, port) + const title = subgraphTitleBounds(bounds) + const overlapsTitle = + connector.y >= title.top && + connector.y < title.top + title.height && + connector.x >= title.left && + connector.x < title.left + title.width + if (!overlapsTitle) return port + const afterTitleX = title.left + title.width + if (afterTitleX <= bounds.left + bounds.width - 2) return { x: afterTitleX, y: port.y } + if (title.left - 1 >= bounds.left + 1) return { x: title.left - 1, y: port.y } + return port +} + +function endpointSidePoint(bounds: FlowchartEndpointBounds, side: DiagramSide, role: PortRole): FlowchartPoint { + return clearSubgraphTitlePort(bounds, boundsSidePoint(bounds, side), role) +} + function verticalBackEdgePath( - from: FlowchartNodeBounds, - to: FlowchartNodeBounds, + from: FlowchartEndpointBounds, + to: FlowchartEndpointBounds, leftBoundary?: number, ): FlowchartPoint[] { - const start = boundsSidePoint(from, "left") - const end = boundsSidePoint(to, "left") + const start = endpointSidePoint(from, "left", "source") + const end = endpointSidePoint(to, "left", "target") const busX = Math.min( afterFarthestCoordinate([start, end], "x", "left", BUS_CLEARANCE), leftBoundary === undefined ? Number.POSITIVE_INFINITY : leftBoundary - BUS_CLEARANCE * 2, @@ -105,27 +130,32 @@ function verticalBackEdgePath( return pathViaLane(start, lane("x", busX), end) } -function verticalForwardEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] { +function verticalForwardEdgePath(from: FlowchartEndpointBounds, to: FlowchartEndpointBounds): FlowchartPoint[] { const travel = verticalTravel(from, to) const startSide = sideForDirection(travel) const endSide = oppositeSide(startSide) const sourceCenter = boundsCenter(from) const targetCenter = boundsCenter(to) - const start = withCoordinate(boundsSidePoint(from, startSide), "x", snapCoordinate(sourceCenter.x, targetCenter.x, 1)) - const end = boundsSidePoint(to, endSide) + const snapped = withCoordinate( + boundsSidePoint(from, startSide), + "x", + snapCoordinate(sourceCenter.x, targetCenter.x, 1), + ) + const start = clearSubgraphTitlePort(from, snapped, "source") + const end = endpointSidePoint(to, endSide, "target") return orthogonalPath(start, end, { preferredAxis: "y" }) } -function horizontalBackEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] { - const start = boundsSidePoint(from, "top") - const end = boundsSidePoint(to, "top") +function horizontalBackEdgePath(from: FlowchartEndpointBounds, to: FlowchartEndpointBounds): FlowchartPoint[] { + const start = endpointSidePoint(from, "top", "source") + const end = endpointSidePoint(to, "top", "target") const busY = afterFarthestCoordinate([start, end], "y", "up", BUS_CLEARANCE) return pathViaLane(start, lane("y", busY), end) } function horizontalEdgePath( - from: FlowchartNodeBounds, - to: FlowchartNodeBounds, + from: FlowchartEndpointBounds, + to: FlowchartEndpointBounds, direction: FlowchartDirection, ): FlowchartPoint[] { const overlapsHorizontally = from.left < to.left + to.width && to.left < from.left + from.width @@ -135,33 +165,37 @@ function horizontalEdgePath( const travel = horizontalTravel(from, to, direction) const startSide = sideForDirection(travel) - return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)), { - preferredAxis: "x", - }) + return orthogonalPath( + endpointSidePoint(from, startSide, "source"), + endpointSidePoint(to, oppositeSide(startSide), "target"), + { + preferredAxis: "x", + }, + ) } -function selfEdgePath(bounds: FlowchartNodeBounds, laneOffset = 0): FlowchartPoint[] { - const start = boundsSidePoint(bounds, "right") - const end = boundsSidePoint(bounds, "bottom") +function selfEdgePath(bounds: FlowchartEndpointBounds, laneOffset = 0): FlowchartPoint[] { + const start = endpointSidePoint(bounds, "right", "source") + const end = endpointSidePoint(bounds, "bottom", "target") const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE + laneOffset const bottomLaneY = bounds.top + bounds.height + 1 + laneOffset return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end] } function parallelEdgePath( - from: FlowchartNodeBounds, - to: FlowchartNodeBounds, + from: FlowchartEndpointBounds, + to: FlowchartEndpointBounds, axis: DiagramAxis, laneCoordinate: number, ): FlowchartPoint[] { if (axis === "y") { - const start = boundsSidePoint(from, "bottom") - const end = boundsSidePoint(to, "bottom") + const start = endpointSidePoint(from, "bottom", "source") + const end = endpointSidePoint(to, "bottom", "target") return pathViaLane(start, lane("y", laneCoordinate), end) } - const start = boundsSidePoint(from, "right") - const end = boundsSidePoint(to, "right") + const start = endpointSidePoint(from, "right", "source") + const end = endpointSidePoint(to, "right", "target") return pathViaLane(start, lane("x", laneCoordinate), end) } @@ -178,13 +212,13 @@ function rightRenderExtent(route: FlowchartEdgeRoute): number { return right } -function parallelLaneAxis(from: FlowchartNodeBounds, to: FlowchartNodeBounds): DiagramAxis { +function parallelLaneAxis(from: FlowchartEndpointBounds, to: FlowchartEndpointBounds): DiagramAxis { return Math.abs(to.centerX - from.centerX) >= Math.abs(to.centerY - from.centerY) ? "y" : "x" } function edgePath( - from: FlowchartNodeBounds, - to: FlowchartNodeBounds, + from: FlowchartEndpointBounds, + to: FlowchartEndpointBounds, direction: FlowchartDirection, leftBoundary?: number, ): FlowchartPoint[] { @@ -194,8 +228,8 @@ function edgePath( if (overlapsVertically) { const travel: HorizontalTravel = centerCoordinate(to, "x") >= centerCoordinate(from, "x") ? "right" : "left" return orthogonalPath( - boundsSidePoint(from, sideForDirection(travel)), - boundsSidePoint(to, oppositeSide(sideForDirection(travel))), + endpointSidePoint(from, sideForDirection(travel), "source"), + endpointSidePoint(to, oppositeSide(sideForDirection(travel)), "target"), ) } return isVerticalBackEdge(from, to, direction) @@ -273,21 +307,36 @@ function targetFanInLane( return keepBefore(unclamped, advanceCoordinate(targetCoordinate, travel, -1), travel) } -function portForTravel(bounds: FlowchartNodeBounds, travel: DiagramDirection, role: PortRole): FlowchartPoint { +function portForTravel(bounds: FlowchartEndpointBounds, travel: DiagramDirection, role: PortRole): FlowchartPoint { const side = role === "source" ? sideForDirection(travel) : oppositeSide(sideForDirection(travel)) - return boundsSidePoint(bounds, side) + return endpointSidePoint(bounds, side, role) +} + +function subgraphContainsMember( + subgraphs: readonly FlowchartSubgraph[], + subgraph: FlowchartSubgraph, + endpointId: string, +): boolean { + return ( + subgraph.nodeIds.includes(endpointId) || + subgraphs.some( + (child) => + child.parentId === subgraph.id && + (child.id === endpointId || subgraphContainsMember(subgraphs, child, endpointId)), + ) + ) } function horizontalForwardRecords( edges: FlowchartEdge[], - bounds: Map, + endpoints: ReadonlyMap, direction: FlowchartDirection, ): EdgeRecord[] { const travel = direction === "RL" ? "left" : "right" const records: EdgeRecord[] = [] for (const edge of edges) { - const source = bounds.get(edge.from) - const target = bounds.get(edge.to) + const source = endpoints.get(edge.from) + const target = endpoints.get(edge.to) if (!source || !target) continue const forward = direction === "RL" @@ -305,14 +354,14 @@ function horizontalForwardRecords( function verticalForwardRecords( edges: FlowchartEdge[], - bounds: Map, + endpoints: ReadonlyMap, direction: FlowchartDirection, ): EdgeRecord[] { const travel = direction === "BT" ? "up" : "down" const records: EdgeRecord[] = [] for (const edge of edges) { - const source = bounds.get(edge.from) - const target = bounds.get(edge.to) + const source = endpoints.get(edge.from) + const target = endpoints.get(edge.to) if (!source || !target) continue const forward = direction === "BT" @@ -329,17 +378,23 @@ function verticalForwardRecords( } function horizontalExitSubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined { - for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) { + const subgraphs = diagram.subgraphs ?? [] + for (const subgraph of [...subgraphs].reverse()) { if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue - if (subgraph.nodeIds.includes(edge.from) && !subgraph.nodeIds.includes(edge.to)) return subgraph + if (subgraph.id === edge.from || subgraph.id === edge.to) continue + if (subgraphContainsMember(subgraphs, subgraph, edge.from) && !subgraphContainsMember(subgraphs, subgraph, edge.to)) + return subgraph } return undefined } function horizontalEntrySubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined { - for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) { + const subgraphs = diagram.subgraphs ?? [] + for (const subgraph of [...subgraphs].reverse()) { if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue - if (subgraph.nodeIds.includes(edge.to) && !subgraph.nodeIds.includes(edge.from)) return subgraph + if (subgraph.id === edge.from || subgraph.id === edge.to) continue + if (subgraphContainsMember(subgraphs, subgraph, edge.to) && !subgraphContainsMember(subgraphs, subgraph, edge.from)) + return subgraph } return undefined } @@ -416,7 +471,7 @@ function alignClusteredVerticalSources(records: readonly EdgeRecord[]): EdgeReco function routeHorizontalFanOut( records: readonly EdgeRecord[], - bounds: ReadonlyMap, + endpoints: ReadonlyMap, direction: FlowchartDirection, handled: Set, routes: FlowchartEdgeRoute[], @@ -433,7 +488,7 @@ function routeHorizontalFanOut( for (const record of sourceRecords) { const targetOwner = targetOwners.get(record.edge.to) targetOwners.set(record.edge.to, targetOwner ?? sourceId) - const target = bounds.get(record.edge.to) + const target = endpoints.get(record.edge.to) if (!targetOwner || targetOwner === sourceId || !target) { routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX))) handled.add(record.edge) @@ -441,7 +496,7 @@ function routeHorizontalFanOut( } const targetSide = sourcePort.y < record.targetPort.y ? "top" : "bottom" - const targetPoint = boundsSidePoint(target, targetSide) + const targetPoint = endpointSidePoint(target, targetSide, "target") const approach = shiftPoint(targetPoint, targetSide === "top" ? "up" : "down") routes.push({ edge: record.edge, @@ -523,7 +578,7 @@ function routeVerticalFanIn( function routeParallelEdges( diagram: FlowchartDiagram, - bounds: Map, + endpoints: ReadonlyMap, directionForEdge: (edge: FlowchartEdge) => FlowchartDirection, directionAligned: boolean, handled: Set, @@ -532,8 +587,8 @@ function routeParallelEdges( const groups = groupRecords(diagram.edges, (edge) => `${edge.from}:${edge.to}`) for (const edges of groups.values()) { if (edges.length < 2) continue - const from = bounds.get(edges[0]!.from) - const to = bounds.get(edges[0]!.to) + const from = endpoints.get(edges[0]!.from) + const to = endpoints.get(edges[0]!.to) if (!from || !to) continue if (from.id === to.id) { let laneOffset = 0 @@ -571,19 +626,22 @@ function routeParallelEdges( function routeHorizontalSubgraphExitFanIn( diagram: FlowchartDiagram, - bounds: Map, + endpoints: ReadonlyMap, subgraphBounds: ReadonlyMap | undefined, handled: Set, routes: FlowchartEdgeRoute[], ): void { if (!subgraphBounds) return - const groups = new Map() + const groups = new Map< + string, + { edge: FlowchartEdge; subgraph: FlowchartSubgraph; source: FlowchartEndpointBounds }[] + >() for (const edge of diagram.edges) { if (handled.has(edge)) continue const subgraph = horizontalExitSubgraph(diagram, edge) - const source = bounds.get(edge.from) - const target = bounds.get(edge.to) + const source = endpoints.get(edge.from) + const target = endpoints.get(edge.to) if (!subgraph || !source || !target) continue const key = `${subgraph.id}:${edge.to}` @@ -595,7 +653,7 @@ function routeHorizontalSubgraphExitFanIn( for (const group of groups.values()) { const subgraph = group[0]!.subgraph const subgraphBound = subgraphBounds.get(subgraph.id) - const target = bounds.get(group[0]!.edge.to) + const target = endpoints.get(group[0]!.edge.to) if (!subgraphBound || !target) continue const travel: HorizontalTravel = subgraph.direction === "RL" ? "left" : "right" @@ -608,7 +666,7 @@ function routeHorizontalSubgraphExitFanIn( const targetBelow = target.centerY >= subgraphBound.centerY const targetPort = targetSubgraph ? portForTravel(target, horizontalSubgraphEntryTravel(targetSubgraph), "target") - : boundsSidePoint(target, targetBelow ? "top" : "bottom") + : endpointSidePoint(target, targetBelow ? "top" : "bottom", "target") const joinY = targetSubgraphBound ? horizontalSubgraphJoinY(subgraphBound, targetSubgraphBound) : horizontalSubgraphExitJoinY(subgraphBound, targetPort, targetBelow) @@ -637,7 +695,7 @@ function routeHorizontalSubgraphExitFanIn( function routeHorizontalSubgraphEntries( diagram: FlowchartDiagram, - bounds: Map, + endpoints: ReadonlyMap, subgraphBounds: ReadonlyMap | undefined, handled: Set, routes: FlowchartEdgeRoute[], @@ -648,8 +706,8 @@ function routeHorizontalSubgraphEntries( if (handled.has(edge)) continue const subgraph = horizontalEntrySubgraph(diagram, edge) const subgraphBound = subgraph ? subgraphBounds.get(subgraph.id) : undefined - const from = bounds.get(edge.from) - const to = bounds.get(edge.to) + const from = endpoints.get(edge.from) + const to = endpoints.get(edge.to) if (!subgraph || !subgraphBound || !from || !to) continue const targetPort = portForTravel(to, horizontalSubgraphEntryTravel(subgraph), "target") @@ -875,7 +933,8 @@ function subgraphTitleBounds(bounds: FlowchartSubgraphBounds): { function avoidNodeObstacles( route: FlowchartEdgeRoute, routes: readonly FlowchartEdgeRoute[], - bounds: Map, + bounds: ReadonlyMap, + endpoints: ReadonlyMap, subgraphBounds: ReadonlyMap | undefined, routeIndex: number, diagram: FlowchartDiagram, @@ -883,16 +942,19 @@ function avoidNodeObstacles( const allNodeBounds = [...bounds.values()] const allSubgraphBounds = [...(subgraphBounds?.values() ?? [])] const subgraphs = diagram.subgraphs ?? [] - const contains = (subgraph: FlowchartSubgraph, nodeId: string): boolean => - subgraph.nodeIds.includes(nodeId) || - subgraphs.some((child) => child.parentId === subgraph.id && contains(child, nodeId)) + const contains = (subgraph: FlowchartSubgraph, endpointId: string): boolean => + subgraphContainsMember(subgraphs, subgraph, endpointId) const owner = [...subgraphs].reverse().find((subgraph) => { if (!contains(subgraph, route.edge.from) || !contains(subgraph, route.edge.to)) return false const children = subgraphs.filter((child) => child.parentId === subgraph.id) return ( - children.some((child) => contains(child, route.edge.from)) && - children.some((child) => contains(child, route.edge.to)) && - !children.some((child) => contains(child, route.edge.from) && contains(child, route.edge.to)) + children.some((child) => child.id === route.edge.from || contains(child, route.edge.from)) && + children.some((child) => child.id === route.edge.to || contains(child, route.edge.to)) && + !children.some( + (child) => + (child.id === route.edge.from || contains(child, route.edge.from)) && + (child.id === route.edge.to || contains(child, route.edge.to)), + ) ) }) const ownerBounds = owner ? subgraphBounds?.get(owner.id) : undefined @@ -918,8 +980,21 @@ function avoidNodeObstacles( const allowedContact = isSource && isTarget ? "both" : isSource ? "source" : isTarget ? "target" : undefined return pathIntersectsBounds(candidate.points, bound, allowedContact) }) + const sourceSubgraph = bounds.has(route.edge.from) ? undefined : subgraphBounds?.get(route.edge.from) + const intersectsSourceTitle = (candidate: FlowchartEdgeRoute): boolean => { + if (!sourceSubgraph || sourceSubgraph.label.length === 0 || !candidate.points[0]) return false + const connector = flowchartSourceConnector(sourceSubgraph, candidate.points[0]) + const title = subgraphTitleBounds(sourceSubgraph) + return ( + connector.y >= title.top && + connector.y < title.top + title.height && + connector.x >= title.left && + connector.x < title.left + title.width + ) + } const intersectsStructuralObstacle = (candidate: FlowchartEdgeRoute): boolean => intersectsNode(candidate) || + intersectsSourceTitle(candidate) || allSubgraphBounds.some( (bound) => pathRunsAlongFrame(candidate.points, bound) || @@ -945,8 +1020,8 @@ function avoidNodeObstacles( } if (!intersectsObstacle(route)) return route - const from = bounds.get(route.edge.from) - const to = bounds.get(route.edge.to) + const from = endpoints.get(route.edge.from) + const to = endpoints.get(route.edge.to) if (!from || !to) return route const routingBounds = [...allNodeBounds, ...allSubgraphBounds] const rightBusX = Math.max(...routingBounds.map((bound) => bound.left + bound.width - 1)) + BUS_CLEARANCE @@ -1090,28 +1165,44 @@ function avoidNodeObstacles( (busX): FlowchartEdgeRoute => ({ ...route, labelAxis: route.labelAxis === undefined ? undefined : "y", - points: pathViaLane(boundsSidePoint(from, "right"), lane("x", busX), boundsSidePoint(to, "right")), + points: pathViaLane( + endpointSidePoint(from, "right", "source"), + lane("x", busX), + endpointSidePoint(to, "right", "target"), + ), }), ), ...candidateLeftBusXs.map( (busX): FlowchartEdgeRoute => ({ ...route, labelAxis: route.labelAxis === undefined ? undefined : "y", - points: pathViaLane(boundsSidePoint(from, "left"), lane("x", busX), boundsSidePoint(to, "left")), + points: pathViaLane( + endpointSidePoint(from, "left", "source"), + lane("x", busX), + endpointSidePoint(to, "left", "target"), + ), }), ), ...candidateTopBusYs.map( (busY): FlowchartEdgeRoute => ({ ...route, labelAxis: route.labelAxis === undefined ? undefined : "x", - points: pathViaLane(boundsSidePoint(from, "top"), lane("y", busY), boundsSidePoint(to, "top")), + points: pathViaLane( + endpointSidePoint(from, "top", "source"), + lane("y", busY), + endpointSidePoint(to, "top", "target"), + ), }), ), ...candidateBottomBusYs.map( (busY): FlowchartEdgeRoute => ({ ...route, labelAxis: route.labelAxis === undefined ? undefined : "x", - points: pathViaLane(boundsSidePoint(from, "bottom"), lane("y", busY), boundsSidePoint(to, "bottom")), + points: pathViaLane( + endpointSidePoint(from, "bottom", "source"), + lane("y", busY), + endpointSidePoint(to, "bottom", "target"), + ), }), ), ] @@ -1120,7 +1211,7 @@ function avoidNodeObstacles( ) const attachments = targetSides.flatMap((sourceSide) => targetSides.flatMap((targetSide) => - buses.map((bus) => routeViaBus(boundsSidePoint(from, sourceSide), targetSide, bus)), + buses.map((bus) => routeViaBus(endpointSidePoint(from, sourceSide, "source"), targetSide, bus)), ), ) if (subgraphBounds) { @@ -1149,6 +1240,7 @@ function avoidLabelOverlap( route: FlowchartEdgeRoute, otherRoutes: readonly FlowchartEdgeRoute[], bounds: ReadonlyMap, + endpoints: ReadonlyMap, subgraphBounds: ReadonlyMap | undefined, targetWidth?: number, includeLabelWidth = true, @@ -1160,7 +1252,7 @@ function avoidLabelOverlap( other.edge.label ? [flowchartRouteLabelLayout(other, diagramTextWidth)] : [], ) const otherConnectorBounds = otherRoutes.flatMap((other) => { - const source = bounds.get(other.edge.from) + const source = endpoints.get(other.edge.from) const sourcePoint = other.points[0] if (!source || !sourcePoint) return [] const connector = flowchartSourceConnector(source, sourcePoint) @@ -1249,54 +1341,71 @@ export function routeFlowchartEdges( subgraphBounds?: ReadonlyMap, targetWidth?: number, directionAligned = false, + lockedRoutes?: ReadonlyMap, ): FlowchartEdgeRoute[] { const routedDiagram = { ...diagram, edges: diagram.edges.filter((edge) => !edge.orderOnly) } + const endpoints = new Map([ + ...(subgraphBounds ? [...subgraphBounds] : []), + ...bounds, + ]) const handled = new Set() const routes: FlowchartEdgeRoute[] = [] + if (lockedRoutes) { + for (const [edge, route] of lockedRoutes) { + routes.push(route) + handled.add(edge) + } + } const leftBoundary = subgraphBounds ? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left)) : undefined - routeParallelEdges(routedDiagram, bounds, directionForEdge, directionAligned, handled, routes) + routeParallelEdges(routedDiagram, endpoints, directionForEdge, directionAligned, handled, routes) for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) { const horizontalEdges = routedDiagram.edges.filter( (edge) => !handled.has(edge) && directionForEdge(edge) === direction, ) if (horizontalEdges.length === 0) continue - const records = horizontalForwardRecords(horizontalEdges, bounds, direction) - routeHorizontalFanOut(records, bounds, direction, handled, routes) + const records = horizontalForwardRecords(horizontalEdges, endpoints, direction) + routeHorizontalFanOut(records, endpoints, direction, handled, routes) routeHorizontalFanIn(records, direction, handled, routes) } - routeHorizontalSubgraphExitFanIn(routedDiagram, bounds, subgraphBounds, handled, routes) - routeHorizontalSubgraphEntries(routedDiagram, bounds, subgraphBounds, handled, routes) + routeHorizontalSubgraphExitFanIn(routedDiagram, endpoints, subgraphBounds, handled, routes) + routeHorizontalSubgraphEntries(routedDiagram, endpoints, subgraphBounds, handled, routes) for (const direction of ["TD", "TB", "BT"] satisfies FlowchartDirection[]) { const verticalEdges = routedDiagram.edges.filter( (edge) => !handled.has(edge) && directionForEdge(edge) === direction, ) if (verticalEdges.length === 0) continue - const records = verticalForwardRecords(verticalEdges, bounds, direction) + const records = verticalForwardRecords(verticalEdges, endpoints, direction) routeVerticalFanOut(records, direction, handled, routes) routeVerticalFanIn(records, direction, handled, routes) } for (const edge of routedDiagram.edges) { if (handled.has(edge)) continue - const from = bounds.get(edge.from) - const to = bounds.get(edge.to) + const from = endpoints.get(edge.from) + const to = endpoints.get(edge.to) if (!from || !to) continue routes.push({ edge, points: edgePath(from, to, directionForEdge(edge), leftBoundary) }) } for (let index = routes.length - 1; index >= 0; index--) { - routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, subgraphBounds, index, routedDiagram) + if (lockedRoutes?.has(routes[index]!.edge)) continue + routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, endpoints, subgraphBounds, index, routedDiagram) } const subgraphs = diagram.subgraphs ?? [] const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph])) const containers = (id: string) => { const ids = new Set() - let current = subgraphs.find((subgraph) => subgraph.nodeIds.includes(id)) + const directSubgraph = subgraphById.get(id) + let current = directSubgraph + ? directSubgraph.parentId + ? subgraphById.get(directSubgraph.parentId) + : undefined + : subgraphs.find((subgraph) => subgraph.nodeIds.includes(id)) while (current) { ids.add(current.id) current = current.parentId ? subgraphById.get(current.parentId) : undefined @@ -1319,6 +1428,7 @@ export function routeFlowchartEdges( return targets.size > 1 || sources.size > 1 } return routes.reduce((resolved, route, index) => { + if (lockedRoutes?.has(route.edge)) return [...resolved, route] const grouped = groupedLabelEdge(route.edge) return [ ...resolved, @@ -1326,6 +1436,7 @@ export function routeFlowchartEdges( route, [...resolved, ...routes.slice(index + 1)], bounds, + endpoints, subgraphBounds, targetWidth !== undefined && subgraphs.length > 0 && grouped ? Math.max(1, targetWidth - 5) : targetWidth, subgraphs.length === 0 || grouped, @@ -1339,15 +1450,17 @@ export function avoidFlowchartFrameBorders( bounds: ReadonlyMap, subgraphBounds: ReadonlyMap, ): void { - const inside = (node: FlowchartNodeBounds, frame: FlowchartSubgraphBounds) => + const endpoints = new Map([...subgraphBounds, ...bounds]) + const inside = (node: FlowchartEndpointBounds, frame: FlowchartSubgraphBounds) => + node.id !== frame.id && node.left >= frame.left && node.top >= frame.top && node.left + node.width <= frame.left + frame.width && node.top + node.height <= frame.top + frame.height for (const route of routes) { - const source = bounds.get(route.edge.from) - const target = bounds.get(route.edge.to) + const source = endpoints.get(route.edge.from) + const target = endpoints.get(route.edge.to) for (const frame of subgraphBounds.values()) { const inward = Boolean(source && target && inside(source, frame) && inside(target, frame)) const right = frame.left + frame.width - 1 @@ -1395,7 +1508,7 @@ export function avoidFlowchartFrameBorders( } } -function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide { +function sideForOutsidePoint(bounds: FlowchartEndpointBounds, sourcePoint: FlowchartPoint): DiagramSide { if (sourcePoint.x < bounds.left) return "left" if (sourcePoint.x >= bounds.left + bounds.width) return "right" if (sourcePoint.y < bounds.top) return "top" @@ -1416,7 +1529,7 @@ function connectorChar(side: DiagramSide): string { } export function flowchartSourceConnector( - from: FlowchartNodeBounds, + from: FlowchartEndpointBounds, sourcePoint: FlowchartPoint, ): { x: number; y: number; char: string } { const side = sideForOutsidePoint(from, sourcePoint) diff --git a/packages/merman/src/flowchart/types.ts b/packages/merman/src/flowchart/types.ts index 2517e0cdc1e6..ac26ea4654ef 100644 --- a/packages/merman/src/flowchart/types.ts +++ b/packages/merman/src/flowchart/types.ts @@ -51,6 +51,8 @@ export interface FlowchartSubgraphBounds extends DiagramBounds { labelSide: "top" | "bottom" } +export type FlowchartEndpointBounds = FlowchartNodeBounds | FlowchartSubgraphBounds + export type FlowchartPoint = DiagramPoint export interface FlowchartEdgeRoute { diff --git a/packages/merman/src/test/layout-audit/fixtures.ts b/packages/merman/src/test/layout-audit/fixtures.ts index 3de83a123dac..594fc9d9c041 100644 --- a/packages/merman/src/test/layout-audit/fixtures.ts +++ b/packages/merman/src/test/layout-audit/fixtures.ts @@ -510,6 +510,29 @@ export function layoutFixtures(): readonly LayoutFixture[] { end`, curated: true, }, + { + id: "flowchart/subgraph-pipeline/curated", + kind: "flowchart" as const, + family: "subgraph-pipeline", + profile: "short" as const, + source: `flowchart TD + subgraph S1["1. provider.transform (seed)"] + A1["Catalog model → copy catalog variants"] + A2["Custom model → apply package/modelID/limit + seed default variants"] + end + subgraph S2["2. plugin model.transform"] + B["Plugin can replace variants, clear to [], or ignore"] + end + subgraph S3["3. config model.transform (post)"] + C1{"Config sets reasoning or variants?"} + C1 -- yes --> C2["Apply explicit config"] + C1 -- no --> C3{"Config changed package (or recreated removed model)?"} + C3 -- yes --> C4["Respell / reseed variants"] + C3 -- no --> C5["Keep current variants"] + end + S1 --> S2 --> S3`, + curated: true, + }, ...states, ] } diff --git a/packages/merman/src/test/layout-audit/harness.ts b/packages/merman/src/test/layout-audit/harness.ts index 450ba4237dc8..641fee73c3d6 100644 --- a/packages/merman/src/test/layout-audit/harness.ts +++ b/packages/merman/src/test/layout-audit/harness.ts @@ -255,6 +255,7 @@ function auditFlowchart(fixture: LayoutFixture, viewport: (typeof auditViewports const size = grid.getTextSize({ trimTop: true, trimBottom: true }) const routes = layout.routes.map((route) => ({ from: route.edge.from, to: route.edge.to, points: route.points })) const bodyIds = layout.diagram.nodes.map((node) => node.id) + const endpointBounds = new Map([...layout.subgraphBounds, ...layout.bounds]) if (!Number.isFinite(size.width) || !Number.isFinite(size.height) || size.width <= 0 || size.height <= 0) violations.push("rendered grid has invalid dimensions") @@ -263,7 +264,7 @@ function auditFlowchart(fixture: LayoutFixture, viewport: (typeof auditViewports if (layout.routes.length !== layout.diagram.edges.filter((edge) => !edge.orderOnly).length) violations.push("rendered route count does not match visible edge count") validateBodies(layout.bounds, bodyIds, violations) - validateRoutes(routes, layout.bounds, bodyIds, violations) + validateRoutes(routes, endpointBounds, bodyIds, violations) for (const node of layout.diagram.nodes) requireOutputLines(output, layout.bounds.get(node.id)?.lines ?? [], `node ${node.id}`, violations) From 28a3974efc7a764e0317f205e21bfffca8b08e10 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:16:49 -0500 Subject: [PATCH 05/40] fix(core): tag image job notices with their job ID (#60098) --- packages/core/src/image/notice.ts | 6 +++ .../core/src/session/execution/restart.ts | 9 +++- .../core/src/tool/plugin/image-generate.ts | 42 +++++++++++-------- packages/core/test/session-execution.test.ts | 4 +- .../core/test/tool-image-generate.test.ts | 17 +++++--- 5 files changed, 50 insertions(+), 28 deletions(-) create mode 100644 packages/core/src/image/notice.ts diff --git a/packages/core/src/image/notice.ts b/packages/core/src/image/notice.ts new file mode 100644 index 000000000000..dfa23d1c3f97 --- /dev/null +++ b/packages/core/src/image/notice.ts @@ -0,0 +1,6 @@ +export * as ImageNotice from "./notice.js" + +/** The completion notice for a background image job, tagged with its job ID like `` notices. */ +export function text(input: { id: string; state: string; body: string }) { + return `\n${input.body}\n` +} diff --git a/packages/core/src/session/execution/restart.ts b/packages/core/src/session/execution/restart.ts index bf4aa65b35a8..dd1eb2f3daa3 100644 --- a/packages/core/src/session/execution/restart.ts +++ b/packages/core/src/session/execution/restart.ts @@ -9,6 +9,7 @@ import { SessionEvent } from "../event.js" import { SessionExecution } from "../execution.js" import { SessionSchema } from "../schema.js" import { SessionStore } from "../store.js" +import { ImageNotice } from "../../image/notice.js" import { ShellResult } from "../../shell/result.js" import { SubagentCompletion } from "../subagent-completion.js" @@ -139,7 +140,7 @@ export const layer = (options?: Options) => background: Job.Background, recovery: Extract, ) { - const text = + const body = background.status === "running" ? `Image request for ${recovery.path} was cancelled because the server restarted.` : background.status === "completed" @@ -152,7 +153,11 @@ export const layer = (options?: Options) => id: background.notificationID, sessionID: recovery.sessionID, description: "Image generation", - text, + text: ImageNotice.text({ + id: background.id, + state: background.status === "running" ? "cancelled" : background.status, + body, + }), metadata: { source: "image_generate", jobID: background.id, diff --git a/packages/core/src/tool/plugin/image-generate.ts b/packages/core/src/tool/plugin/image-generate.ts index bd7d1cc5a31c..b7a2ca829ec0 100644 --- a/packages/core/src/tool/plugin/image-generate.ts +++ b/packages/core/src/tool/plugin/image-generate.ts @@ -13,6 +13,7 @@ import { ConfigEntryObserver } from "../../config/plugin/entry-observer.js" import { Environment } from "../../environment/index.js" import { FileAccess } from "../../file-access.js" import { Generate } from "../../generate.js" +import { ImageNotice } from "../../image/notice.js" import { Job } from "../../job.js" import { Mime } from "../../mime.js" import { Model } from "../../model.js" @@ -65,6 +66,7 @@ export const Input = Schema.Struct({ }) export const Output = Schema.Struct({ + id: Schema.String.annotate({ description: "The image job's ID, repeated in its completion notice" }), path: Schema.String.annotate({ description: "Where the image is saved, without its extension" }), model: Schema.String.annotate({ description: "providerID/modelID" }), }) @@ -198,22 +200,26 @@ export const Plugin = { }) const notifyWhenDone = Effect.fn("ImageGenerateTool.notifyWhenDone")( - function* (sessionID: SessionSchema.ID, id: string, notificationID: SessionMessage.ID, file: string) { - const info = (yield* jobs.wait({ id })).info + function* (input: { sessionID: SessionSchema.ID; id: string; notificationID: SessionMessage.ID; file: string }) { + const info = (yield* jobs.wait({ id: input.id })).info if (!info || info.status === "running") return yield* sessions.synthetic({ - id: notificationID, - sessionID, + id: input.notificationID, + sessionID: input.sessionID, description: "Image generation", - text: - info.status === "completed" - ? (info.output ?? "") - : info.status === "error" - ? (info.error ?? `Image request failed for ${file}.`) - : `Image request for ${file} was cancelled.`, - metadata: { source: name, jobID: id, state: info.status, path: file }, + text: ImageNotice.text({ + id: input.id, + state: info.status, + body: + info.status === "completed" + ? (info.output ?? "") + : info.status === "error" + ? (info.error ?? `Image request failed for ${input.file}.`) + : `Image request for ${input.file} was cancelled.`, + }), + metadata: { source: name, jobID: input.id, state: info.status, path: input.file }, }) - yield* jobs.completeBackground(notificationID) + yield* jobs.completeBackground(input.notificationID) }, Effect.forkIn(scope, { startImmediately: true }), ) @@ -345,7 +351,7 @@ export const Plugin = { ) const id = `image_${Identifier.create(false)}` const notificationID = SessionMessage.ID.create() - const job = yield* jobs.start({ + yield* jobs.start({ id, notificationID, type: name, @@ -354,17 +360,17 @@ export const Plugin = { recovery: { kind: "image", sessionID: context.sessionID, path: stem, model: ref }, run, }) - yield* jobs.background(job.id) - yield* notifyWhenDone(context.sessionID, job.id, notificationID, stem) + yield* jobs.background(id) + yield* notifyWhenDone({ sessionID: context.sessionID, id, notificationID, file: stem }) return { - output: { path: stem, model: ref }, + output: { id, path: stem, model: ref }, content: [ - `Image request started in the background with ${ref}. It will be saved as ${stem} with the extension of its real format (.png, .jpg, .webp or .gif); the notice gives the exact path.${resolved.note ?? ""}`, + `Image job ${id} started in the background with ${ref}. It will be saved as ${stem} with the extension of its real format (.png, .jpg, .webp or .gif); the notice gives the exact path.${resolved.note ?? ""}`, "You will be notified automatically when it finishes or fails.", "DO NOT sleep, poll, check whether the file exists, or request the same image again while it runs.", "Work on other tasks, or briefly tell the user what you started and end your response.", ].join("\n"), - metadata: { path: stem, model: ref, jobID: job.id }, + metadata: { path: stem, model: ref, jobID: id }, } }).pipe( Effect.mapError((error) => diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts index 4cd23d9af22a..9c3ac71950e2 100644 --- a/packages/core/test/session-execution.test.ts +++ b/packages/core/test/session-execution.test.ts @@ -571,8 +571,8 @@ describe("SessionRestart background recovery", () => { const inbox = yield* SessionInbox.list(database.db, sessionID) expect(inbox.map((item) => (item.type === "synthetic" ? item.payload.text : undefined)).toSorted()).toEqual([ - "Image request finished. Saved image: /tmp/done.png (test/painter).", - "Image request for /tmp/running.png was cancelled because the server restarted.", + '\nImage request finished. Saved image: /tmp/done.png (test/painter).\n', + '\nImage request for /tmp/running.png was cancelled because the server restarted.\n', ]) expect(yield* restarted.pendingBackground).toEqual([]) }), diff --git a/packages/core/test/tool-image-generate.test.ts b/packages/core/test/tool-image-generate.test.ts index 0ec830a118d9..478139392f42 100644 --- a/packages/core/test/tool-image-generate.test.ts +++ b/packages/core/test/tool-image-generate.test.ts @@ -238,20 +238,23 @@ describe("image_generate", () => { const result = yield* run({ prompt: "make it blue", images: ["in.png"], path: "out.png" }) const file = path.join(directory, "out.png") const stem = path.join(directory, "out") - expect(result.output).toEqual({ path: stem, model: "test/painter" }) + const id = result.output.id + expect(id).toStartWith("image_") + expect(result.output).toEqual({ id, path: stem, model: "test/painter" }) expect(result.content).toEqual([ { type: "text", text: [ - `Image request started in the background with test/painter. It will be saved as ${stem} with the extension of its real format (.png, .jpg, .webp or .gif); the notice gives the exact path.`, + `Image job ${id} started in the background with test/painter. It will be saved as ${stem} with the extension of its real format (.png, .jpg, .webp or .gif); the notice gives the exact path.`, "You will be notified automatically when it finishes or fails.", "DO NOT sleep, poll, check whether the file exists, or request the same image again while it runs.", "Work on other tasks, or briefly tell the user what you started and end your response.", ].join("\n"), }, ]) + // The notice carries the job ID from the start result, like shell notices. expect(yield* notice(result)).toBe( - `Image request finished. Saved image: ${file} (test/painter).\nUse the read tool on this path to view the image.`, + `\nImage request finished. Saved image: ${file} (test/painter).\nUse the read tool on this path to view the image.\n`, ) expect((yield* Effect.promise(() => fs.readFile(file))).toString("base64")).toBe(PNG) expect(fixture.requests).toEqual([ @@ -288,7 +291,7 @@ describe("image_generate", () => { const result = yield* run({ prompt: "a cat" }) expect(path.dirname(result.output.path)).toBe(path.join(global.tmp, "images", session.id)) const saved = `${result.output.path}.jpg` - expect(yield* notice(result)).toBe( + expect(yield* notice(result)).toContain( `Image request finished. Saved image: ${saved} (test/painter).\nUse the read tool on this path to view the image.`, ) expect(yield* Effect.promise(() => Bun.file(saved).exists())).toBe(true) @@ -327,7 +330,7 @@ describe("image_generate", () => { }) const result = yield* run({ prompt: "x", path: "out.png" }) expect(yield* notice(result)).toBe( - `Image request failed for ${path.join(directory, "out")}: The prompt was rejected`, + `\nImage request failed for ${path.join(directory, "out")}: The prompt was rejected\n`, ) expect(yield* Effect.promise(() => Bun.file(path.join(directory, "out.png")).exists())).toBe(false) }), @@ -343,7 +346,9 @@ describe("image_generate", () => { const id = String(result.metadata?.jobID) expect((yield* jobs.get(id))?.status).toBe("running") yield* jobs.cancel(id) - expect(yield* notice(result)).toBe(`Image request for ${path.join(directory, "out")} was cancelled.`) + expect(yield* notice(result)).toBe( + `\nImage request for ${path.join(directory, "out")} was cancelled.\n`, + ) expect(yield* Effect.promise(() => Bun.file(path.join(directory, "out.png")).exists())).toBe(false) }), ) From 510aa79d075c32e75f85b7774ff7390370c22b18 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 28 Oct 2026 23:05:00 -0700 Subject: [PATCH 06/40] fix(core): store forked and imported session media as blobs (#60118) --- packages/core/src/session.ts | 33 ++- packages/core/src/session/blob.ts | 66 +++++- packages/core/src/session/projector.ts | 4 +- packages/core/src/session/transfer.ts | 40 +++- packages/core/test/session-projector.test.ts | 132 +++++++++++ packages/core/test/session-prompt.test.ts | 217 ++++++++++++++++++- packages/server/test/media.test.ts | 35 +-- 7 files changed, 459 insertions(+), 68 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 7738259f56b2..2810a7abdc29 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -280,16 +280,7 @@ const layer = Layer.effect( const environments = yield* SessionEnvironment.Service const locations = yield* LocationServiceMap.Service const sessions = yield* Session.make() - // `experimental.media_blob` as the session's own location configures it, where its media is written. - const keepsMediaRefs = (session: SessionSchema.Info) => - SessionBlob.enabled.pipe( - instances.provide(session), - Effect.catch((cause) => - Effect.logWarning("could not resolve experimental.media_blob; serving media inline", { cause }).pipe( - Effect.as(false), - ), - ), - ) + const mediaBlob = (session: SessionSchema.Info) => SessionBlob.enabledFor(instances, session) const isDurableSessionEvent = (item: Bus.LogItem): item is SessionEvent.DurableEvent => Object.hasOwn(SessionEvent.Durable.cases, item.type) @@ -378,12 +369,16 @@ const layer = Layer.effect( // The fork adopts the parent's newest instruction values rather than the // values in effect at the boundary; copied history may contain frozen // instruction-update text the initial baseline already reflects. - yield* bus.publish(SessionEvent.Forked, { - sessionID, - parentID: parent.id, - boundary: { type: input.before ? "before" : "through", messageID: boundary.id }, - ...inherited, - }) + // The parent's location, not the caller's, decides whether the copy's media is stored as blobs. + const media = yield* mediaBlob(parent) + yield* bus + .publish(SessionEvent.Forked, { + sessionID, + parentID: parent.id, + boundary: { type: input.before ? "before" : "through", messageID: boundary.id }, + ...inherited, + }) + .pipe(Effect.provideService(SessionBlob.ForkMedia, media)) return yield* result.get(sessionID).pipe(Effect.orDie) }), get: (sessionID) => sessions.forSession(sessionID).get(), @@ -437,13 +432,13 @@ const layer = Layer.effect( message: (input) => sessions.forSession(input.sessionID).message(input.messageID), messagesWithRefs: Effect.fn("Session.messagesWithRefs")(function* (input) { const session = yield* result.get(input.sessionID) - if (!(yield* keepsMediaRefs(session))) return yield* store.messages(input) + if (!(yield* mediaBlob(session))) return yield* store.messages(input) return yield* store.messagesWithRefs(input) }), messageWithRefs: Effect.fn("Session.messageWithRefs")(function* (input) { const session = yield* result.get(input.sessionID).pipe(Effect.option) if (Option.isNone(session)) return undefined - if (!(yield* keepsMediaRefs(session.value))) return yield* result.message(input) + if (!(yield* mediaBlob(session.value))) return yield* result.message(input) const stored = yield* store.messageWithRefs(input.messageID) return stored?.sessionID === input.sessionID ? stored.message : undefined }), @@ -465,7 +460,7 @@ const layer = Layer.effect( inbox: (sessionID) => sessions.forSession(sessionID).inbox(), inboxWithRefs: Effect.fn("Session.inboxWithRefs")(function* (sessionID) { const session = yield* result.get(sessionID) - if (!(yield* keepsMediaRefs(session))) return yield* result.inbox(sessionID) + if (!(yield* mediaBlob(session))) return yield* result.inbox(sessionID) return yield* SessionInbox.listWithRefs(db, sessionID) }), cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID), diff --git a/packages/core/src/session/blob.ts b/packages/core/src/session/blob.ts index 72639427dfb1..de7e0b6b51f4 100644 --- a/packages/core/src/session/blob.ts +++ b/packages/core/src/session/blob.ts @@ -2,12 +2,13 @@ export * as SessionBlob from "./blob.js" import { createHash } from "node:crypto" import { and, eq, exists, gte, inArray, notExists, notInArray, type SQL, sql } from "drizzle-orm" -import { Effect, Option, Schema } from "effect" +import { Cause, Context, Effect, Option, Schema } from "effect" import { map } from "effect/Array" import type { Attachment, FileAttachment } from "@opencode/schema/prompt" import type { Tool } from "@opencode/schema/tool" import { Config } from "../config.js" import type { Database } from "../database/database.js" +import type { Instance } from "../instance/service.js" import type { SessionMessage } from "./message.js" import type { SessionSchema } from "./schema.js" import { BlobRefTable, BlobTable, SessionInboxTable, SessionMessageTable } from "./sql.js" @@ -258,6 +259,28 @@ export const enabled = Effect.gen(function* () { return Config.latest(yield* config.value.entries(), "experimental")?.media_blob === true }) +/** + * {@link enabled} as `session`'s location configures it, where its media is written. Off when that location can't + * boot or load its config, which surfaces as defects too, so a broken location never fails the caller. + */ +export const enabledFor = (instances: Instance.Interface, session: SessionSchema.Info) => + enabled.pipe( + instances.provide(session), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(Cause.fromReasons(cause.reasons.filter(Cause.isInterruptReason))) + : Effect.logWarning("could not resolve experimental.media_blob; keeping media inline", { cause }).pipe( + Effect.as(false), + ), + ), + ) + +/** + * Whether a fork's projection stores the child's copied media as blobs. `Session.fork` sets it from the parent's + * location; the projection ignores the caller's own config, and a replayed fork without it copies media inline. + */ +export const ForkMedia = Context.Reference("@opencode/SessionBlob/ForkMedia", { defaultValue: () => false }) + const DATA_URI = /^data:[^,]*;base64,(.*)$/s function take(pending: Pending, raw: Buffer, mime: string) { @@ -285,11 +308,15 @@ function toolPartRefs(pending: Pending, part: AssistantPart): AssistantPart { return { ...part, state: { ...part.state, content: map(content, (item) => toolRef(pending, item)) } } } -const save = Effect.fnUntraced(function* (db: DatabaseService, pending: Pending) { - if (pending.size === 0) return +/** Inserts blobs, keeping any that already exist. */ +export const save = Effect.fnUntraced(function* ( + db: DatabaseService, + blobs: ReadonlyArray, +) { + if (blobs.length === 0) return yield* db .insert(BlobTable) - .values([...pending.values()]) + .values([...blobs]) .onConflictDoNothing() .run() .pipe(Effect.orDie) @@ -303,7 +330,7 @@ export const externalizePayload = Effect.fnUntraced(function* < const pending: Pending = new Map() const files = input.files.map((file) => fileRef(pending, file)) if (pending.size === 0) return input - yield* save(db, pending) + yield* save(db, [...pending.values()]) return { ...input, files } }) @@ -320,7 +347,7 @@ export const externalize = Effect.fnUntraced(function* ( const pending: Pending = new Map() const content = message.content.map((part) => toolPartRefs(pending, part)) if (pending.size === 0) return message - yield* save(db, pending) + yield* save(db, [...pending.values()]) return { ...message, content } }) @@ -341,6 +368,9 @@ function storedPartRefs(pending: Pending, part: Part): Part { * Moves a stored row's inline media into blobs whatever the flag says, for offline backfills. Returns the row to * store (the same object when nothing moved), the blobs to insert, and every hash the row then names. */ +export function split( + data: T, +): { data: T; moved: boolean; blobs: Array; hashes: Set } export function split(data: MessageRow["data"] | InboxRow["payload"]) { const pending: Pending = new Map() const stored = @@ -353,13 +383,35 @@ export function split(data: MessageRow["data"] | InboxRow["payload"]) { return { data: result, moved: pending.size > 0, blobs: [...pending.values()], hashes: hashesOf(result) } } +/** + * {@link split} for a message row copied into another session by a fork: saves the moved blobs and returns the row + * to store. Callers check {@link ForkMedia} and record the copy's refs. + */ +export const externalizeRow = Effect.fnUntraced(function* (db: DatabaseService, data: MessageRow["data"]) { + const result = split(data) + if (!result.moved) return data + yield* save(db, result.blobs) + return result.data +}) + /** Records that `owner` references each blob its stored row names, so release keeps them until it lets go. */ export const recordRefs = Effect.fnUntraced(function* ( db: DatabaseService, owner: Owner, data: MessageRow["data"] | InboxRow["payload"], ) { - const hashes = hashesOf(data) + yield* recordHashes(db, owner, hashesOf(data)) +}) + +/** + * {@link recordRefs} for the given hashes only, such as the blobs an import took out of its own inline data, so + * refs it carried in can't keep or expose blobs it never held. + */ +export const recordHashes = Effect.fnUntraced(function* ( + db: DatabaseService, + owner: Owner, + hashes: ReadonlySet, +) { if (hashes.size === 0) return // A ref whose blob is already gone keeps nothing alive; reads render it as unavailable. const found = yield* db diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 23bc41d83d9c..f15c07829f6d 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -187,6 +187,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created) const forkedID = (seq: number) => SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${seq}`) + // The child stores the parent's inline media as blobs; the parent's rows are never rewritten. + const media = yield* SessionBlob.ForkMedia let cursor = -1 while (copiedSeq !== undefined) { // A parent message whose parts live in session_part is copied with them inline, so the fork reads the same. @@ -219,7 +221,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( seq: row.seq, time_created: row.time_created, time_updated: row.time_updated, - data: row.data, + data: media ? yield* SessionBlob.externalizeRow(db, row.data) : row.data, } // A summary references the first message it kept, which the fork copied under a new ID. const keepID = row.type === "compaction" ? row.data.metadata?.keepID : undefined diff --git a/packages/core/src/session/transfer.ts b/packages/core/src/session/transfer.ts index 1c0cd94f9a6b..52e22a57402a 100644 --- a/packages/core/src/session/transfer.ts +++ b/packages/core/src/session/transfer.ts @@ -11,12 +11,14 @@ import { makeGlobalNode } from "@opencode/util/effect/app-node" import { App } from "../app.js" import { Bus } from "../bus.js" import { Database } from "../database/database.js" +import { Instance } from "../instance/service.js" import { Location } from "../location.js" import { Project } from "../project.js" import { upsertProject } from "../project/sql.js" import { AbsolutePath, RelativePath } from "../schema.js" import { Session } from "../session.js" import { Slug } from "../util/slug.js" +import { SessionBlob } from "./blob.js" import { SessionEvent } from "./event.js" import { SessionMessage } from "./message.js" import { SessionProjector } from "./projector.js" @@ -49,6 +51,7 @@ const layer = Layer.effect( const app = yield* App.Metadata const bus = yield* Bus.Service const { db } = yield* Database.Service + const instances = yield* Instance.Service const projects = yield* Project.Service const sessions = yield* Session.Service const encodeMessage = Schema.encodeSync(SessionMessage.Info) @@ -74,6 +77,9 @@ const layer = Layer.effect( const project = yield* projects.resolve(input.location.directory) yield* upsertProject(db, project).pipe(Effect.orDie) const importedAt = yield* Clock.currentTimeMillis + const subpath = RelativePath.make( + path.relative(project.directory, input.location.directory).replaceAll("\\", "/"), + ) const messages = input.data.messages.filter(isSettled).map((message, index) => { const encoded = encodeMessage(message) const { id: _, type, ...data } = encoded @@ -86,6 +92,17 @@ const layer = Layer.effect( data, } }) + // Only inline media the import carries becomes blobs, and only its blobs get refs: refs it carried in + // could otherwise keep or expose blobs it never held. The destination's config is loaded only when needed. + const split = messages.map((message) => SessionBlob.split(message.data)) + const media = + split.some((row) => row.moved) && + (yield* SessionBlob.enabledFor(instances, { + ...input.data.info, + projectID: project.id, + location: input.location, + subpath, + })) yield* bus .publish( SessionEvent.Created, @@ -96,9 +113,7 @@ const layer = Layer.effect( version: app.version, projectID: project.id, location: input.location, - subpath: RelativePath.make( - path.relative(project.directory, input.location.directory).replaceAll("\\", "/"), - ), + subpath, title: input.data.info.title, agent: input.data.info.agent, model: input.data.info.model, @@ -110,7 +125,22 @@ const layer = Layer.effect( commit: (seq) => Effect.gen(function* () { if (messages.length > 0) { - yield* db.insert(SessionMessageTable).values(messages).run().pipe(Effect.orDie) + if (media) yield* Effect.forEach(split, (row) => SessionBlob.save(db, row.blobs), { discard: true }) + const stored = media + ? messages.map((message, i) => ({ ...message, data: split[i]!.data })) + : messages + yield* db.insert(SessionMessageTable).values(stored).run().pipe(Effect.orDie) + if (media) + yield* Effect.forEach( + stored, + (message, i) => + SessionBlob.recordHashes( + db, + { sessionID, messageID: message.id, seq: message.seq }, + new Set(split[i]!.blobs.map((blob) => blob.hash)), + ), + { discard: true }, + ) yield* Bus.reserveSequence(db, sessionID, seq + messages.length) } yield* db @@ -159,7 +189,7 @@ const layer = Layer.effect( export const node = makeGlobalNode({ service: Service, layer, - deps: [App.node, Bus.node, Database.node, Project.node, Session.node], + deps: [App.node, Bus.node, Database.node, Instance.node, Project.node, Session.node], }) function isSettled(message: SessionMessage.Info) { diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 45870f06106d..53458bbb31f5 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -1688,4 +1688,136 @@ describe("SessionProjector", () => { ]) }), ) + + it.effect("stores a fork's copies of inline parent media as blobs and leaves the parent's rows inline", () => + Effect.gen(function* () { + const db = yield* seedSession() + const bus = yield* Bus.Service + const store = yield* SessionStore.Service + const childID = Session.ID.make("ses_media_inline_fork") + const boundary = yield* seedInlineMedia(db) + const parentRows = yield* messageRows(db, sessionID) + const parentParts = yield* db.select().from(SessionPartTable).all().pipe(Effect.orDie) + const parentMessages = yield* store.messages({ sessionID, order: "asc" }) + + yield* bus.publish(SessionEvent.Forked, { + sessionID: childID, + parentID: sessionID, + boundary: { type: "through", messageID: boundary }, + }) + + const hash = SessionBlob.hashBytes(png) + const uri = SessionStored.ref(hash) + const childRows = yield* messageRows(db, childID) + expect(childRows.map((row) => row.data)).toMatchObject([ + { files: [{ uri, mime: "image/png", name: "shot.png" }] }, + { content: [{ type: "tool", state: { content: [{ type: "file", uri, mime: "image/png" }] } }] }, + ]) + expect(JSON.stringify(childRows)).not.toContain(png.toString("base64")) + expect(yield* messageRows(db, sessionID)).toEqual(parentRows) + expect(yield* db.select().from(SessionPartTable).all().pipe(Effect.orDie)).toEqual(parentParts) + expect(yield* db.select({ hash: BlobTable.hash }).from(BlobTable).all().pipe(Effect.orDie)).toEqual([{ hash }]) + expect( + yield* db + .select({ session: BlobRefTable.session_id, message: BlobRefTable.message_id, hash: BlobRefTable.hash }) + .from(BlobRefTable) + .orderBy(asc(BlobRefTable.seq)) + .all() + .pipe(Effect.orDie), + ).toEqual(childRows.map((row) => ({ session: childID, message: row.id, hash }))) + + // The child owns its blob, so it reads the same with the parent gone. + yield* bus.publish(SessionEvent.Deleted, { sessionID }) + expect(yield* messageRows(db, sessionID)).toEqual([]) + expect(withoutIDs(yield* store.messages({ sessionID: childID, order: "asc" }))).toEqual( + withoutIDs(parentMessages), + ) + }).pipe(Effect.provideService(SessionBlob.ForkMedia, true)), + ) + + it.effect("copies inline parent media inline into a fork unless the parent's location stores media as blobs", () => + Effect.gen(function* () { + const db = yield* seedSession() + const bus = yield* Bus.Service + const store = yield* SessionStore.Service + const childID = Session.ID.make("ses_media_inline_fork_off") + const boundary = yield* seedInlineMedia(db) + const parentRows = yield* messageRows(db, sessionID) + + yield* bus.publish(SessionEvent.Forked, { + sessionID: childID, + parentID: sessionID, + boundary: { type: "through", messageID: boundary }, + }) + + const childRows = yield* messageRows(db, childID) + expect(childRows.map((row) => row.data)).toEqual( + (yield* SessionHistory.withParts(db, parentRows)).map((row) => row.data), + ) + expect(yield* db.select().from(BlobTable).all().pipe(Effect.orDie)).toEqual([]) + expect(yield* db.select().from(BlobRefTable).all().pipe(Effect.orDie)).toEqual([]) + expect(withoutIDs(yield* store.messages({ sessionID: childID, order: "asc" }))).toEqual( + withoutIDs(yield* store.messages({ sessionID, order: "asc" })), + ) + // The caller's own config doesn't decide: Session.fork sets ForkMedia from the parent's location. + }).pipe(Effect.provide(mediaBlob)), + ) +}) + +const messageRows = (db: Database.Interface["db"], session: Session.ID) => + db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, session)) + .orderBy(asc(SessionMessageTable.seq)) + .all() + .pipe(Effect.orDie) + +const withoutIDs = (messages: ReadonlyArray) => messages.map(({ id: _, ...message }) => message) + +/** A parent written before media blobs: an inline user file, and a tool screenshot in a session_part row. */ +const seedInlineMedia = Effect.fnUntraced(function* (db: Database.Interface["db"]) { + const userID = SessionMessage.ID.make("msg_inline_user") + const assistantID = SessionMessage.ID.make("msg_inline_assistant") + yield* db + .insert(SessionMessageTable) + .values([ + { + id: userID, + session_id: sessionID, + type: "user", + seq: 1, + time_created: 1, + data: { text: "look", files: [attachment("shot.png")], time: { created: 1 } }, + }, + assistantRow(assistantID, 2, { created, completed: created }), + ]) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionPartTable) + .values({ + message_id: assistantID, + session_id: sessionID, + ordinal: 0, + type: "tool", + call_id: "call_shot", + time_created: 2, + time_updated: 2, + data: { + type: "tool", + id: "call_shot", + name: "screenshot", + executed: true, + state: { + status: "completed", + input: {}, + content: [{ type: "file", uri: `data:image/png;base64,${png.toString("base64")}`, mime: "image/png" }], + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + }) + .run() + .pipe(Effect.orDie) + return assistantID }) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 533d2fa19d99..aceb528e75a5 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -44,6 +44,8 @@ import { Config } from "@opencode/core/config" import { Document, Info } from "@opencode/schema/config" import { ConfigExperimental } from "@opencode/schema/config/experimental" import { SessionStored } from "@opencode/core/session/stored" +import { SessionBlob } from "@opencode/core/session/blob" +import { SessionTransfer } from "@opencode/core/session/transfer" const executionCalls: Session.ID[] = [] const interruptCalls: Session.ID[] = [] @@ -73,6 +75,8 @@ const execution = Layer.succeed( }), ) const mediaDirectory = "/media" +// A location whose instance fails to boot, the way a bad config or unreadable directory does: as a defect. +const brokenDirectory = "/broken" const mediaBlob = Config.testLayer([ new Document({ type: "document", @@ -89,7 +93,9 @@ const locations = makeGlobalNode({ (ref: Location.Ref) => // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion Layer.mergeAll( - ref.directory === mediaDirectory ? mediaBlob : Layer.empty, + // Every real instance has its own config, so the caller's never shows through. + ref.directory === mediaDirectory ? mediaBlob : Config.testLayer([]), + ref.directory === brokenDirectory ? Layer.effectDiscard(Effect.die("instance boot failed")) : Layer.empty, LayerNode.compile(LayerNode.group([PluginHooks.node, Skill.node]), { replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))], }), @@ -112,7 +118,14 @@ const locations = makeGlobalNode({ }) const it = testEffect( AppNodeBuilder.build( - LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), + LayerNode.group([ + Database.node, + Bus.node, + SessionProjector.node, + SessionStore.node, + Session.node, + SessionTransfer.node, + ]), [ Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(execution), @@ -121,6 +134,7 @@ const it = testEffect( ), ) const sessionID = Session.ID.make("ses_prompt_test") +const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" const messageID = SessionMessage.ID.create() const setup = Effect.gen(function* () { @@ -189,6 +203,74 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => { return { id, session_id: sessionID, type, seq, time_created: 0, data } } +/** A user message with an inline image, then a tool result whose file is `toolUri` (inline by default). */ +const mediaMessages = (prefix: string, toolUri = `data:image/png;base64,${pngBase64}`) => + [ + { + id: `msg_${prefix}_user`, + type: "user", + text: "look", + files: [{ data: pngBase64, mime: "image/png", source: { type: "inline" }, name: "shot.png" }], + time: { created: 1 }, + }, + { + id: `msg_${prefix}_assistant`, + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_shot", + name: "screenshot", + executed: true, + state: { + status: "completed", + input: {}, + content: [{ type: "file", uri: toolUri, mime: "image/png" }], + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ], + time: { created: 2, completed: 3 }, + }, + ].map((message) => Schema.decodeUnknownSync(SessionMessage.Info)(message)) + +/** Forks a parent in `directory` holding one inline user image and returns the child's copied row. */ +const forkInlineParent = Effect.fnUntraced(function* (id: string, directory: string) { + const { db } = yield* Database.Service + const parentID = Session.ID.make(id) + yield* db + .insert(SessionTable) + .values({ id: parentID, project_id: Project.ID.global, slug: id, directory, title: id, version: "test" }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionMessageTable) + .values({ + id: SessionMessage.ID.make(`msg_${id}_user`), + session_id: parentID, + type: "user", + seq: 1, + time_created: 1, + data: { + text: "look", + files: [{ data: pngBase64, mime: "image/png", source: { type: "inline" } }], + time: { created: 1 }, + }, + }) + .run() + .pipe(Effect.orDie) + const child = yield* (yield* Session.Service).fork({ sessionID: parentID }) + const row = yield* db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, child.id)) + .get() + .pipe(Effect.orDie) + return row! +}) + describe("Session.prompt", () => { it.effect("exposes the execution registry", () => Effect.gen(function* () { @@ -493,6 +575,137 @@ describe("Session.prompt", () => { }), ) + it.effect("forks a session's inline media into blobs when its location enables media_blob", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const child = yield* forkInlineParent("ses_fork_media", mediaDirectory) + + const hash = SessionBlob.hashBytes(Buffer.from(pngBase64, "base64")) + expect(child.data).toMatchObject({ files: [{ uri: SessionStored.ref(hash), mime: "image/png" }] }) + expect(yield* db.select().from(BlobRefTable).all().pipe(Effect.orDie)).toEqual([ + { session_id: child.session_id, message_id: child.id, seq: 1, hash }, + ]) + }), + ) + + it.effect( + "forks inline media inline when the parent's location doesn't enable media_blob, whatever the caller's", + () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const child = yield* forkInlineParent("ses_fork_plain", "/project").pipe(Effect.provide(mediaBlob)) + + expect(child.data).toMatchObject({ files: [{ data: pngBase64, mime: "image/png" }] }) + expect(yield* db.select().from(BlobTable).all().pipe(Effect.orDie)).toEqual([]) + }), + ) + + it.effect("forks and imports with media inline when the location's instance can't boot", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const session = yield* Session.Service + const transfer = yield* SessionTransfer.Service + const child = yield* forkInlineParent("ses_fork_broken", brokenDirectory) + const imported = yield* transfer.import({ + data: { + info: { ...(yield* session.get(sessionID)), id: Session.ID.make("ses_import_broken") }, + messages: mediaMessages("ses_import_broken"), + }, + location: { directory: AbsolutePath.make(brokenDirectory) }, + }) + + expect(child.data).toMatchObject({ files: [{ data: pngBase64, mime: "image/png" }] }) + expect(yield* session.messages({ sessionID: imported.id, order: "asc" })).toEqual( + mediaMessages("ses_import_broken"), + ) + expect(yield* db.select().from(BlobTable).all().pipe(Effect.orDie)).toEqual([]) + }), + ) + + it.effect("records refs on import only for the blobs it took out of its own inline media", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const session = yield* Session.Service + const transfer = yield* SessionTransfer.Service + // A blob no session references, awaiting sweep; the import names it but never held its bytes. + const orphan = "d".repeat(64) + yield* db + .insert(BlobTable) + .values({ hash: orphan, mime: "image/png", bytes: 3, data: Buffer.from("abc"), time_created: 1 }) + .run() + .pipe(Effect.orDie) + + const imported = yield* transfer.import({ + data: { + info: { ...(yield* session.get(sessionID)), id: Session.ID.make("ses_import_orphan") }, + messages: mediaMessages("ses_import_orphan", SessionStored.ref(orphan)), + }, + location: { directory: AbsolutePath.make(mediaDirectory) }, + }) + + const hash = SessionBlob.hashBytes(Buffer.from(pngBase64, "base64")) + expect( + yield* db + .select({ session: BlobRefTable.session_id, hash: BlobRefTable.hash }) + .from(BlobRefTable) + .all() + .pipe(Effect.orDie), + ).toEqual([{ session: imported.id, hash }]) + }), + ) + + it.effect("imports media into blobs only where the location enables media_blob", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const session = yield* Session.Service + const transfer = yield* SessionTransfer.Service + const template = yield* session.get(sessionID) + const messages = (prefix: string) => mediaMessages(prefix) + const imported = (id: string, directory: string) => + transfer.import({ + data: { info: { ...template, id: Session.ID.make(id) }, messages: messages(id) }, + location: { directory: AbsolutePath.make(directory) }, + }) + const rows = (id: Session.ID) => + db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, id)) + .orderBy(SessionMessageTable.seq) + .all() + .pipe(Effect.orDie) + + const media = yield* imported("ses_import_media", mediaDirectory) + const plain = yield* imported("ses_import_plain", "/project") + + const hash = SessionBlob.hashBytes(Buffer.from(pngBase64, "base64")) + const uri = SessionStored.ref(hash) + const mediaRows = yield* rows(media.id) + expect(mediaRows.map((row) => row.data)).toMatchObject([ + { files: [{ uri, mime: "image/png", name: "shot.png" }] }, + { content: [{ state: { content: [{ type: "file", uri, mime: "image/png" }] } }] }, + ]) + expect(JSON.stringify(mediaRows)).not.toContain(pngBase64) + expect(JSON.stringify(yield* rows(plain.id))).toContain(pngBase64) + expect(yield* db.select({ hash: BlobTable.hash }).from(BlobTable).all().pipe(Effect.orDie)).toEqual([{ hash }]) + expect( + yield* db + .select({ session: BlobRefTable.session_id, message: BlobRefTable.message_id, seq: BlobRefTable.seq }) + .from(BlobRefTable) + .orderBy(BlobRefTable.seq) + .all() + .pipe(Effect.orDie), + ).toEqual(mediaRows.map((row) => ({ session: media.id, message: row.id, seq: row.seq }))) + expect(yield* session.messages({ sessionID: media.id, order: "asc" })).toEqual(messages("ses_import_media")) + expect(yield* session.messages({ sessionID: plain.id, order: "asc" })).toEqual(messages("ses_import_plain")) + }), + ) + it.effect("normalizes large image content before validating persisted Base64", () => Effect.gen(function* () { yield* setup diff --git a/packages/server/test/media.test.ts b/packages/server/test/media.test.ts index 17ce2da1cebe..b51c50dbf64c 100644 --- a/packages/server/test/media.test.ts +++ b/packages/server/test/media.test.ts @@ -4,7 +4,6 @@ import fs from "node:fs/promises" import path from "node:path" import { expect } from "bun:test" import { OpenCode, type SessionMessageInfo } from "@opencode/client" -import { SessionBlob } from "@opencode/core/session/blob" import { Session } from "@opencode/schema/session" import { Effect } from "effect" import { tmpdirScoped } from "../../core/test/fixture/tmpdir" @@ -77,38 +76,6 @@ const client = (handler: (request: Request) => Promise, capabilities?: }), }) -// Import stores media inline, so move the imported rows' media into blobs the way the live write path stores them. -const storeAsBlobs = (database: string) => { - const db = new Sqlite(database) - const rows = db - .query< - { id: string; session_id: string; seq: number; data: string }, - [] - >(`SELECT id, session_id, seq, data FROM session_message`) - .all() - for (const row of rows) { - const split = SessionBlob.split(JSON.parse(row.data)) - if (!split.moved) continue - db.run(`UPDATE session_message SET data = ? WHERE id = ?`, [JSON.stringify(split.data), row.id]) - for (const blob of split.blobs) - db.run(`INSERT OR IGNORE INTO blob (hash, mime, bytes, data, time_created) VALUES (?, ?, ?, ?, ?)`, [ - blob.hash, - blob.mime, - blob.bytes, - blob.data, - Date.now(), - ]) - for (const hash of split.hashes) - db.run(`INSERT OR IGNORE INTO blob_ref (session_id, message_id, hash, seq) VALUES (?, ?, ?, ?)`, [ - row.session_id, - row.id, - hash, - row.seq, - ]) - } - db.close() -} - const media = (base64: string) => `media:${hash(base64)}` const route = (base64: string) => `http://opencode.local/api/experimental/media/${hash(base64)}` @@ -125,8 +92,8 @@ it.live("serves stored media as refs only to clients that opt in while experimen yield* Effect.promise(async () => { const template = await plain.session.create({ title: "Media wire" }) + // With the flag on, import stores the media as blobs. await plain.experimental.session.import({ info: { ...template, id: sessionID }, messages: [user, assistant] }) - storeAsBlobs(database) // Without the capability, reads stay byte-for-byte what was imported. expect((await plain.message.list({ sessionID, order: "asc" })).data).toEqual([user, assistant]) From d500927b9d3c4f0746c0e7ba9f4c18507ef1bfc4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:45:09 +0000 Subject: [PATCH 07/40] feat(core): record when a session's latest execution started (#59910) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> --- .../client/src/promise/generated/types.ts | 3 +- packages/client/src/solid/data.ts | 4 ++ packages/client/test/solid-data.test.ts | 44 +++++++++++++++++++ packages/core/schema.json | 14 +++++- packages/core/src/database/migration.gen.ts | 2 + .../20261029073502_session_time_busy.ts | 13 ++++++ packages/core/src/database/schema.gen.ts | 1 + packages/core/src/session/info.ts | 1 + packages/core/src/session/projector.ts | 8 ++++ packages/core/src/session/sql.ts | 1 + packages/core/src/session/transfer.ts | 7 +++ packages/core/test/session-create.test.ts | 17 +++++-- packages/core/test/session-projector.test.ts | 31 +++++++++++++ packages/core/test/v1-migration.test.ts | 1 + packages/protocol/openapi.json | 3 ++ packages/schema/src/session.ts | 3 ++ 16 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/database/migration/20261029073502_session_time_busy.ts diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 460117b99ab2..9439a461c8ff 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2232,7 +2232,7 @@ export type SessionInfo = { cost: MoneyUSD tokens: TokenUsageInfo outcome?: "succeeded" | "failed" | "interrupted" - time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number } + time: { created: number; updated: number; busy?: number; idle?: number; viewed?: number; archived?: number } title?: string subpath?: RelativePath metadata?: SessionMetadata @@ -3685,6 +3685,7 @@ export type ExperimentalSessionImportInput = { readonly time: { readonly created: number readonly updated: number + readonly busy?: number readonly idle?: number readonly viewed?: number readonly archived?: number diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 17c8bbb1227e..30aee4392362 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -1108,6 +1108,10 @@ export function createData(config: CreateDataInput) { return case "session.execution.started": setSessionActive(event.data.sessionID, "running") + // Revalidate so `time.busy` follows the projected execution start. + if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return + result.session.invalidate(event.data.sessionID) + refresh(() => result.session.sync(event.data.sessionID)) return case "session.compaction.started": if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID) diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 53d536ca746f..69dea99a2455 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -330,6 +330,50 @@ test("omits inline tool-result file payloads from read and live tool results whe } }) +test("refreshes a loaded session's execution start when it starts running", async () => { + const listeners = new Set[0]>() + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if (!request.url.endsWith("/api/session/ses_refresh")) throw new Error(`Unexpected request: ${request.url}`) + return Response.json({ data: { ...session(2), time: { ...session(2).time, busy: 3 } } }) + }, + }) + const event: CreateDataInput["event"] = { + on: + ( + _type: Type, + _handler: (event: Extract) => void, + ) => + () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + } + const setup = createRoot((dispose) => ({ + data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }), + dispose, + })) + + try { + setup.data.session.remember(session(2)) + const started: OpenCodeEvent = { + id: "evt_started", + created: 3, + type: "session.execution.started", + durable: { aggregateID: "ses_refresh", seq: 1, version: 1 }, + data: { sessionID: "ses_refresh" }, + } + listeners.forEach((listener) => listener({ name: started.type, details: started })) + expect(setup.data.session.status("ses_refresh")).toBe("running") + await wait(() => setup.data.session.get("ses_refresh")?.time.busy === 3) + } finally { + setup.dispose() + } +}) + test("revalidates after an event overtakes an active session read", async () => { let release!: () => void const gate = new Promise((resolve) => (release = resolve)) diff --git a/packages/core/schema.json b/packages/core/schema.json index c643c298402b..bb629130d75f 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,8 @@ { "version": "7", "dialect": "sqlite", - "id": "dc16dd12-cb76-432f-8108-0c3aadaf54bb", - "prevIds": ["23940db2-8b4e-4a5e-a404-70d785bd788c"], + "id": "e5daa0af-cae7-4057-919a-ebf44dfced0a", + "prevIds": ["dc16dd12-cb76-432f-8108-0c3aadaf54bb"], "ddl": [ { "name": "account_state", @@ -1672,6 +1672,16 @@ "entityType": "columns", "table": "session_v2" }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_busy", + "entityType": "columns", + "table": "session_v2" + }, { "type": "integer", "notNull": false, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 5438f244a777..f5afbd4fb6d5 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -55,6 +55,7 @@ import m52 from "./migration/20261025123711_blob.js" import m53 from "./migration/20261025154125_drop_session_pending.js" import m54 from "./migration/20261026223005_schedule.js" import m55 from "./migration/20261029002625_session_part.js" +import m56 from "./migration/20261029073502_session_time_busy.js" export const migrations = [ m00, @@ -113,4 +114,5 @@ export const migrations = [ m53, m54, m55, + m56, ] satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20261029073502_session_time_busy.ts b/packages/core/src/database/migration/20261029073502_session_time_busy.ts new file mode 100644 index 000000000000..b0bdd0ee1cfe --- /dev/null +++ b/packages/core/src/database/migration/20261029073502_session_time_busy.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration.js" + +const migration: DatabaseMigration.Migration = { + id: "20261029073502_session_time_busy", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_busy\` integer;`) + }) + }, +} + +export default migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 6a7ef1601ab1..2cb123ff319b 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -258,6 +258,7 @@ const schema: Omit = { \`model\` text, \`time_created\` integer NOT NULL, \`time_updated\` integer NOT NULL, + \`time_busy\` integer, \`time_idle\` integer, \`time_viewed\` integer, \`idle_outcome\` text, diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index ab8a78c6c004..bde75487c249 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -52,6 +52,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In time: { created: DateTime.makeUnsafe(row.time_created), updated: DateTime.makeUnsafe(row.time_updated), + busy: row.time_busy === null ? undefined : DateTime.makeUnsafe(row.time_busy), idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle), viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed), archived: row.time_archived === null ? undefined : DateTime.makeUnsafe(row.time_archived), diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index f15c07829f6d..0f0136c1768d 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -787,6 +787,14 @@ const layer = Layer.effectDiscard( delivery: event.data.delivery, }), ) + yield* bus.project(SessionEvent.Execution.Started, (event) => + db + .update(SessionTable) + .set({ time_busy: event.created, time_updated: sql`${SessionTable.time_updated}` }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie), + ) yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event)) yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event)) yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event)) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 0f02d94f38e9..8684925220d9 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -63,6 +63,7 @@ export const SessionTable = sqliteTable( variant?: string }>(), ...Timestamps, + time_busy: integer(), time_idle: integer(), time_viewed: integer(), idle_outcome: text().$type>(), diff --git a/packages/core/src/session/transfer.ts b/packages/core/src/session/transfer.ts index 52e22a57402a..f123a20497e4 100644 --- a/packages/core/src/session/transfer.ts +++ b/packages/core/src/session/transfer.ts @@ -154,6 +154,13 @@ const layer = Layer.effect( tokens_cache_write: input.data.info.tokens.cache.write, time_created: DateTime.toEpochMillis(input.data.info.time.created), time_updated: importedAt, + time_busy: + input.data.info.time.idle && input.data.info.time.busy + ? Math.min( + DateTime.toEpochMillis(input.data.info.time.idle), + DateTime.toEpochMillis(input.data.info.time.busy), + ) + : null, time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null, time_viewed: input.data.info.time.idle && input.data.info.time.viewed diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 41ed037b9b76..0caa7731b1d3 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1438,6 +1438,7 @@ describe("SessionTransfer", () => { time: { ...template.time, updated: DateTime.makeUnsafe(100), + busy: DateTime.makeUnsafe(120), idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150), }, @@ -1479,6 +1480,7 @@ describe("SessionTransfer", () => { }) expect(imported.time).toMatchObject({ updated: DateTime.makeUnsafe(1_000), + busy: DateTime.makeUnsafe(120), idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150), }) @@ -1488,7 +1490,11 @@ describe("SessionTransfer", () => { ]) expect(yield* Bus.latestSequence(db, sessionID)).toBe(2) const exported = yield* transfer.export({ sessionID }) - expect(exported.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) }) + expect(exported.info.time).toMatchObject({ + busy: DateTime.makeUnsafe(120), + idle: DateTime.makeUnsafe(200), + viewed: DateTime.makeUnsafe(150), + }) expect(exported.messages).toEqual(messages) const sanitized = yield* transfer.export({ sessionID, sanitize: true }) expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) }) @@ -1527,7 +1533,7 @@ describe("SessionTransfer", () => { }), ) - it.effect("clamps an imported viewed watermark to its idle transition", () => + it.effect("clamps imported busy and viewed watermarks to their idle transition", () => Effect.gen(function* () { const session = yield* Session.Service const transfer = yield* SessionTransfer.Service @@ -1540,6 +1546,7 @@ describe("SessionTransfer", () => { outcome: "succeeded", time: { ...template.time, + busy: DateTime.makeUnsafe(300), idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(250), }, @@ -1549,7 +1556,11 @@ describe("SessionTransfer", () => { location, }) - expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(200) }) + expect(imported.time).toMatchObject({ + busy: DateTime.makeUnsafe(200), + idle: DateTime.makeUnsafe(200), + viewed: DateTime.makeUnsafe(200), + }) expect(imported.outcome).toBe("succeeded") }), ) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 53458bbb31f5..ab50f233d935 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -600,6 +600,37 @@ describe("SessionProjector", () => { }), ) + it.effect("records each execution's start, including a resume after shutdown", () => + Effect.gen(function* () { + const db = yield* seedSession({ time_created: 0, time_updated: 0 }) + const bus = yield* Bus.Service + const times = () => + db + .select({ busy: SessionTable.time_busy, idle: SessionTable.time_idle, updated: SessionTable.time_updated }) + .from(SessionTable) + .get() + .pipe(Effect.orDie) + + yield* TestClock.setTime(10) + yield* bus.publish(SessionEvent.Execution.Started, { sessionID }) + expect(yield* times()).toEqual({ busy: 10, idle: null, updated: 0 }) + + yield* TestClock.setTime(20) + yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" }) + yield* TestClock.setTime(30) + yield* bus.publish(SessionEvent.Execution.Started, { sessionID }) + expect(yield* times()).toEqual({ busy: 30, idle: null, updated: 0 }) + + yield* TestClock.setTime(40) + yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }) + expect(yield* times()).toEqual({ busy: 30, idle: 40, updated: 0 }) + + yield* TestClock.setTime(50) + yield* bus.publish(SessionEvent.Execution.Started, { sessionID }) + expect(yield* times()).toEqual({ busy: 50, idle: 40, updated: 0 }) + }), + ) + it.effect("updates only the newest incomplete assistant projection", () => Effect.gen(function* () { const db = yield* seedSession() diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts index 2bd411e9a439..adf925d076d6 100644 --- a/packages/core/test/v1-migration.test.ts +++ b/packages/core/test/v1-migration.test.ts @@ -66,6 +66,7 @@ const session = ( model: null, time_created: 1, time_updated: 2, + time_busy: null, time_idle: null, time_viewed: null, idle_outcome: null, diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 3d971b8dd7e8..082023339ecb 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -21856,6 +21856,9 @@ "updated": { "type": "number" }, + "busy": { + "type": "number" + }, "idle": { "type": "number" }, diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index 9f6cd0d71b0c..b03d42cf2965 100644 --- a/packages/schema/src/session.ts +++ b/packages/schema/src/session.ts @@ -46,6 +46,9 @@ export const Info = Schema.Struct({ time: Schema.Struct({ created: DateTimeUtcFromMillis, updated: DateTimeUtcFromMillis, + /** Start of the most recent execution. While it runs, `idle` still holds the previous execution's end. Resuming after a restart starts a new execution. */ + busy: DateTimeUtcFromMillis.pipe(optional), + /** End of the most recent completed execution. */ idle: DateTimeUtcFromMillis.pipe(optional), viewed: DateTimeUtcFromMillis.pipe(optional), archived: DateTimeUtcFromMillis.pipe(optional), From bb85c5414b203850110fa65a9a60c5c1fbea0c42 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:55:37 +0800 Subject: [PATCH 08/40] fix(app): list side launchers only in a New tab and close the region with its last tab (#60126) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> --- .../app/e2e/regression/side-panels.spec.ts | 22 ++++++++++++++++ packages/app/src/runtime/plugin/panels.tsx | 16 ++++++++---- .../app/src/runtime/plugin/side-region.tsx | 26 +++++++------------ 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/packages/app/e2e/regression/side-panels.spec.ts b/packages/app/e2e/regression/side-panels.spec.ts index b4cd9fe1900e..1344ed9ce75d 100644 --- a/packages/app/e2e/regression/side-panels.spec.ts +++ b/packages/app/e2e/regression/side-panels.spec.ts @@ -190,6 +190,28 @@ test("new tab selection and closing stay in the focused pane", async ({ page }) await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true") }) +test("closing the last New tab closes the side region, and only a New tab lists the launchers", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await openSession(page, { name: "LastNewTab", seed: { panes: { ses_lastnewtab: { review: true } } } }) + + const panel = page.locator("#review-panel") + const toggle = page.getByRole("button", { name: "Toggle review", exact: true }) + const newTab = panel.getByRole("tab", { name: "New tab", exact: true }) + const review = panel.getByRole("button", { name: /^Review/ }) + + // An empty side region opens on a New tab listing the launchers. + await expect(newTab).toHaveAttribute("aria-selected", "true") + await expect(review).toBeVisible() + + // Closing it leaves no tab, so the region closes rather than listing the launchers without one. + await newTab.locator("..").getByRole("button", { name: "Close tab" }).click() + await expect(toggle).toHaveAttribute("aria-expanded", "false") + + await toggle.click() + await expect(newTab).toHaveAttribute("aria-selected", "true") + await expect(review).toBeVisible() +}) + // A DOM mark survives only while the same node stays mounted. function mark(locator: Locator) { return locator.evaluate((element) => element.setAttribute("data-e2e-mounted", "original")) diff --git a/packages/app/src/runtime/plugin/panels.tsx b/packages/app/src/runtime/plugin/panels.tsx index f84aaccf9962..91c61637fa9e 100644 --- a/packages/app/src/runtime/plugin/panels.tsx +++ b/packages/app/src/runtime/plugin/panels.tsx @@ -112,7 +112,7 @@ export function createRegion(input: { open: (key: string) => void /** Whether the side region is open, and the user's toggle for it. */ side: Accessor<{ opened(): boolean; toggle(): void }> - /** Runs when a close leaves no stored tab, in the same batch as the close. */ + /** Runs when a close leaves no stored tab and no New tab, in the same batch as the close. */ onEmptied?: () => void /** Runs work that forces a layout, like moving focus, once the region's open motion lands. */ settled?: (run: () => void) => void @@ -327,6 +327,14 @@ export function createRegion(input: { if (state.adding.some((value) => !live(value))) setState("adding", (list) => list.filter(live)) }) + // Closing the last New tab of a strip with no tabs closes the region, as closing its last tab does. + const dismissNewTab = (id: string) => + batch(() => { + setState("adding", (list) => list.filter((value) => value.id !== id)) + + if (stored().length === 0 && !state.adding.some(live)) input.onEmptied?.() + }) + return { /** Whether a key event belongs to the side region rather than the session around it. */ focus: createSideFocus(), @@ -372,15 +380,13 @@ export function createRegion(input: { setState("chosen", pane, id) }) }, - dismissNewTab(id: string) { - setState("adding", (list) => list.filter((value) => value.id !== id)) - }, + dismissNewTab, /** Closes the shown new tab view in the given or active pane; returns whether one was showing. */ closeNewTab(pane: SessionPane = activePane()) { const value = shownNewTab(pane) if (!value) return false - setState("adding", (list) => list.filter((entry) => entry.id !== value.id)) + dismissNewTab(value.id) return true }, diff --git a/packages/app/src/runtime/plugin/side-region.tsx b/packages/app/src/runtime/plugin/side-region.tsx index 2c10a6d190b3..29bd1ad04d4c 100644 --- a/packages/app/src/runtime/plugin/side-region.tsx +++ b/packages/app/src/runtime/plugin/side-region.tsx @@ -383,23 +383,15 @@ export function PaneTabs(props: { - {/* After + runs a primary item, its tab owns the empty pane until it arrives. */} + {/* After + runs a primary item, its tab owns the empty pane until it arrives. The launchers list only in a + New tab, not in a pane with no tabs. */} - 0} - fallback={ -
-
- -
- {language.t("session.files.selectToOpen")} -
-
-
- } - > - -
+
+
+ +
{language.t("session.files.selectToOpen")}
+
+
@@ -518,7 +510,7 @@ function AddMenu(props: { items: readonly SessionPanelMenuItem[]; onChoose: () = ) } -/** The + menu items as rows, then the session's recently closed tabs, in the empty side region and the new tab view. */ +/** The + menu items as rows, then the session's recently closed tabs, in the new tab view. */ function Launchers(props: { items: readonly SessionPanelMenuItem[] onChoose: () => void From 299975163276e850aca75a2828986bb87df7a71d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:03:47 +0800 Subject: [PATCH 09/40] fix(app): order the inbox Working shelf by when each run started (#60147) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> --- .../src/shell/titlebar/inbox-working.test.ts | 26 ++++++++++++++++ .../app/src/shell/titlebar/inbox-working.ts | 22 ++++++++++++++ packages/app/src/shell/titlebar/inbox.tsx | 30 ++++++++++++------- 3 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 packages/app/src/shell/titlebar/inbox-working.test.ts create mode 100644 packages/app/src/shell/titlebar/inbox-working.ts diff --git a/packages/app/src/shell/titlebar/inbox-working.test.ts b/packages/app/src/shell/titlebar/inbox-working.test.ts new file mode 100644 index 000000000000..dc30dd0aa8ce --- /dev/null +++ b/packages/app/src/shell/titlebar/inbox-working.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test" +import { compareWorking, workingSince } from "./inbox-working" + +test("uses the current run's start", () => { + expect(workingSince({ time: { busy: 20 } })).toBe(20) + expect(workingSince({ time: { busy: 20, idle: 10 } })).toBe(20) +}) + +test("has no start while only an earlier run's start is known", () => { + expect(workingSince({ time: { busy: 10, idle: 20 } })).toBeUndefined() + expect(workingSince({ time: { idle: 20 } })).toBeUndefined() + expect(workingSince({ time: {} })).toBeUndefined() +}) + +test("orders by run start, then puts runs with no known start last", () => { + const entry = (key: string, time: { busy?: number; idle?: number }) => ({ key, session: { time } }) + + const sorted = [ + entry("d", {}), + entry("b", { busy: 30 }), + entry("c", { busy: 5, idle: 10 }), + entry("a", { busy: 20, idle: 10 }), + ].toSorted(compareWorking) + + expect(sorted.map((item) => item.key)).toEqual(["a", "b", "c", "d"]) +}) diff --git a/packages/app/src/shell/titlebar/inbox-working.ts b/packages/app/src/shell/titlebar/inbox-working.ts new file mode 100644 index 000000000000..db62002b592f --- /dev/null +++ b/packages/app/src/shell/titlebar/inbox-working.ts @@ -0,0 +1,22 @@ +type SessionTime = { time: { busy?: number; idle?: number } } + +/** + * When the session's current run started. Undefined while the client still holds an earlier run's start (it ended at + * `time.idle`, after `time.busy`) or the server does not record `time.busy`. + */ +export function workingSince(session: SessionTime) { + const { busy, idle } = session.time + + if (busy === undefined || (idle !== undefined && idle > busy)) return undefined + + return busy +} + +/** Earliest run start first, so a session that starts working joins the end. Runs with no known start go last. */ +export function compareWorking(a: { key: string; session: SessionTime }, b: { key: string; session: SessionTime }) { + const start = (workingSince(a.session) ?? Infinity) - (workingSince(b.session) ?? Infinity) + + if (start) return start + + return a.key < b.key ? -1 : a.key > b.key ? 1 : 0 +} diff --git a/packages/app/src/shell/titlebar/inbox.tsx b/packages/app/src/shell/titlebar/inbox.tsx index 7c97331778f9..a67e6dbf3843 100644 --- a/packages/app/src/shell/titlebar/inbox.tsx +++ b/packages/app/src/shell/titlebar/inbox.tsx @@ -16,6 +16,7 @@ import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar" import { sessionTabTitle } from "./tab-title" import { sessionTitle } from "@/session/title" import { createSessionInbox, inboxArchived, inboxBusy, type InboxEntry, type InboxStatus } from "./inbox-model" +import { compareWorking, workingSince } from "./inbox-working" // `since` is when the inbox was first used; Recent only lists sessions that finished a run after it. const InboxState = Persistence.struct({ @@ -35,6 +36,7 @@ export function SidebarInbox() { const sections = createMemo(() => { const list = inbox.entries() + // The most recently finished session comes first. const finished = list .filter((entry) => { @@ -48,7 +50,8 @@ export function SidebarInbox() { needs: list.filter((entry) => entry.status === "approval" || entry.status === "input"), inbox: finished.filter((entry) => !inboxArchived(entry)), archived: finished.filter(inboxArchived), - working: list.filter((entry) => entry.status === "working"), + // `time.updated` moves with every message of a run, so order by when each run started to keep rows in place. + working: list.filter((entry) => entry.status === "working").toSorted(compareWorking), } }) @@ -77,6 +80,7 @@ export function SidebarInbox() { await inbox.remove(props.entry) dialog.close() } + return ( @@ -251,13 +255,22 @@ function InboxRow(props: { return language.plural("sidebar.inbox.elapsed.days", Math.floor(seconds / 86_400)) } - const elapsed = () => { - // `time.updated` moves when a prompt is enqueued, so it marks when the current run was requested. - const seconds = Math.max(0, Math.floor((props.now - props.entry.session.time.updated) / 1000)) + // Without the current run's start yet, say only that the session is working. + const working = () => { + const since = workingSince(props.entry.session) + + if (since === undefined) return language.t("sidebar.inbox.status.working") - return seconds < 60 ? language.plural("sidebar.inbox.elapsed.seconds", seconds) : compact(seconds) + const seconds = Math.max(0, Math.floor((props.now - since) / 1000)) + const duration = seconds < 60 ? language.plural("sidebar.inbox.elapsed.seconds", seconds) : compact(seconds) + + return language.t("sidebar.inbox.status.workingFor", { duration }) } + // Ready rows show their age instead of a status. + const statusLabel = (value: InboxStatus) => + value === "ready" ? undefined : language.t(`sidebar.inbox.status.${value}`) + const age = () => { const seconds = Math.max( 0, @@ -304,10 +317,7 @@ function InboxRow(props: { "text-v2-state-fg-success": status() === "done", }} > - }`)} - > + {/* Announce only the status: the ticking duration would be re-read every second. */} {language.t("sidebar.inbox.status.working")} {/* Keep the status colour under the sweep. */} @@ -315,7 +325,7 @@ function InboxRow(props: { aria-hidden="true" class="[&_[data-component=text-shimmer]]:[--text-shimmer-base-color:var(--v2-state-fg-info)]" > - + From 7ab721a97f383b89a8d901c87866b1565c1cfd9c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:08:45 +0800 Subject: [PATCH 10/40] fix(session-ui): scroll Markdown fragment links to their heading (#59550) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> --- .../component-tests/markdown.spec.ts | 33 +++++++++ .../markdown-fragment-links.test.ts | 35 +++++++++ .../src/components/markdown-fragment-links.ts | 74 +++++++++++++++++++ .../session-ui/src/components/markdown.tsx | 6 ++ packages/ui/src/context/marked-base.tsx | 6 +- packages/ui/src/context/marked-parser.test.ts | 8 ++ 6 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 packages/session-ui/src/components/markdown-fragment-links.test.ts create mode 100644 packages/session-ui/src/components/markdown-fragment-links.ts diff --git a/packages/session-ui/component-tests/markdown.spec.ts b/packages/session-ui/component-tests/markdown.spec.ts index 8c120b3ad775..5d990792d8cb 100644 --- a/packages/session-ui/component-tests/markdown.spec.ts +++ b/packages/session-ui/component-tests/markdown.spec.ts @@ -438,6 +438,39 @@ story("keeps link icons on the same line as the start of their label", async ({ expect(result.long).toBeGreaterThan(1) }) +story("scrolls fragment links to their heading inside the document's scroller", async ({ page }) => { + await page.evaluate(async (fixture) => { + const { mountMarkdown } = await import(fixture) + const filler = Array.from({ length: 40 }, (_, index) => `Paragraph ${index}`).join("\n\n") + await mountMarkdown({ + text: `See [Host services](#host-services) and [the second usage](#usage-1).\n\n## Usage\n\n${filler}\n\n## Host services\n\n${filler}\n\n## Usage\n\n${filler}`, + }) + const host = document.querySelector('[data-testid="markdown-fixture"]')! + host.style.height = "240px" + host.style.overflowY = "auto" + }, fixture) + const host = page.getByTestId("markdown-fixture") + const markdown = host.locator('[data-component="markdown"]') + await expect(markdown).toHaveAttribute("data-markdown-ready", "") + const url = page.url() + + const offset = (index: number) => + host.evaluate( + (host, index) => + host.querySelectorAll("h2")[index]!.getBoundingClientRect().top - host.getBoundingClientRect().top, + index, + ) + + await expect(markdown.getByRole("link", { name: "Host services" })).not.toHaveAttribute("target", "_blank") + await markdown.getByRole("link", { name: "Host services" }).click() + await expect.poll(() => offset(1)).toBeCloseTo(0, 0) + + await host.evaluate((host) => host.scrollTo({ top: 0 })) + await markdown.getByRole("link", { name: "the second usage" }).click() + await expect.poll(() => offset(2)).toBeCloseTo(0, 0) + expect(page.url()).toBe(url) +}) + async function resolvedColor(page: Page, token: string) { return page.evaluate((token) => { const probe = document.createElement("span") diff --git a/packages/session-ui/src/components/markdown-fragment-links.test.ts b/packages/session-ui/src/components/markdown-fragment-links.test.ts new file mode 100644 index 000000000000..04fd1b6006d1 --- /dev/null +++ b/packages/session-ui/src/components/markdown-fragment-links.test.ts @@ -0,0 +1,35 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator" +import { afterAll, beforeAll, expect, test } from "bun:test" +import { fragmentTarget, headingSlug } from "./markdown-fragment-links" + +beforeAll(() => GlobalRegistrator.register()) + +afterAll(() => GlobalRegistrator.unregister()) + +test("slugs headings the way GitHub anchors them", () => { + expect(headingSlug("Host services")).toBe("host-services") + expect(headingSlug("What's `node:*`?")).toBe("whats-node") + expect(headingSlug("API & SDK: v2.0")).toBe("api--sdk-v20") + expect(headingSlug("snake_case and-hyphens")).toBe("snake_case-and-hyphens") + expect(headingSlug("Überblick 概要")).toBe("überblick-概要") +}) + +test("finds headings by slug, numbering repeats, then raw HTML ids and names", () => { + const root = document.createElement("div") + root.innerHTML = [ + "

Host services

", + "

Usage

", + "

Usage

", + '

custom

', + '', + ].join("") + const headings = root.querySelectorAll("h3") + + expect(fragmentTarget(root, "host-services")?.textContent).toBe("Host services") + expect(fragmentTarget(root, "Host-Services")?.textContent).toBe("Host services") + expect(fragmentTarget(root, "usage")).toBe(headings[0]) + expect(fragmentTarget(root, "usage-1")).toBe(headings[1]) + expect(fragmentTarget(root, "custom")?.textContent).toBe("custom") + expect(fragmentTarget(root, "legacy")?.getAttribute("name")).toBe("user-content-legacy") + expect(fragmentTarget(root, "missing")).toBeUndefined() +}) diff --git a/packages/session-ui/src/components/markdown-fragment-links.ts b/packages/session-ui/src/components/markdown-fragment-links.ts new file mode 100644 index 000000000000..d86089c82b91 --- /dev/null +++ b/packages/session-ui/src/components/markdown-fragment-links.ts @@ -0,0 +1,74 @@ +/** A heading's anchor as GitHub writes it: lowercased, punctuation dropped, spaces as hyphens. */ +export function headingSlug(text: string) { + return text + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu, "") + .replaceAll(" ", "-") +} + +/** + * The element a `#fragment` link names in this document: a heading by its slug, where repeated headings take `-1`, + * `-2` and so on, or an element whose raw HTML `id` or `name` it is. + */ +export function fragmentTarget(root: HTMLElement, fragment: string) { + const slug = fragment.toLowerCase() + const seen = new Map() + + const heading = Array.from(root.querySelectorAll("h1, h2, h3, h4, h5, h6")).find((heading) => { + const base = headingSlug(heading.textContent ?? "") + const count = seen.get(base) ?? 0 + seen.set(base, count + 1) + + return (count === 0 ? base : `${base}-${count}`) === slug + }) + + if (heading) return heading + // Sanitizing prefixes raw HTML `id` and `name` so they cannot clobber the app's own globals. + const named = CSS.escape(`user-content-${fragment}`) + + return root.querySelector(`[id="${named}"], [name="${named}"]`) ?? undefined +} + +/** Fragment links scroll to their target in this document instead of navigating the app. */ +export function setupFragmentLinks(root: HTMLElement) { + const click = (event: MouseEvent) => { + if (event.defaultPrevented || event.button !== 0 || !(event.target instanceof Element)) return + const link = event.target.closest('a[href^="#"]') + + if (!(link instanceof HTMLAnchorElement) || !root.contains(link)) return + event.preventDefault() + const fragment = decodeFragment(link.getAttribute("href")!.slice(1)) + const target = fragment ? fragmentTarget(root, fragment) : undefined + + if (!target) return + const scroller = scrollParent(root) + + // `scrollIntoView` would also scroll clipped ancestors and shift the surrounding layout. + if (!scroller) return target.scrollIntoView({ block: "start" }) + scroller.scrollTo({ + top: scroller.scrollTop + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top, + }) + } + + root.addEventListener("click", click) + + return () => root.removeEventListener("click", click) +} + +function scrollParent(element: HTMLElement) { + let parent = element.parentElement + + while (parent) { + if (/auto|scroll/.test(getComputedStyle(parent).overflowY)) return parent + parent = parent.parentElement + } +} + +function decodeFragment(value: string) { + try { + return decodeURIComponent(value) + } catch { + return value + } +} diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index af2010a83a80..69d7b621f838 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -46,6 +46,7 @@ import { } from "../context/markdown" import { createMarkdownImages } from "./markdown-image" import { createImagePreview } from "./image-preview" +import { setupFragmentLinks } from "./markdown-fragment-links" import { markSessionLinks, setupSessionLinks } from "./markdown-session-links" import { markdownClipboardHtml } from "./markdown-clipboard" @@ -805,6 +806,7 @@ export function Markdown( let linkCleanup: (() => void) | undefined let faviconCleanup: (() => void) | undefined let sessionLinkCleanup: (() => void) | undefined + let fragmentLinkCleanup: (() => void) | undefined let readImage: ReadMarkdownImage | undefined let images: ReturnType | undefined @@ -895,6 +897,8 @@ export function Markdown( if (!sessionLinkCleanup) sessionLinkCleanup = setupSessionLinks(container, () => markdown?.openSession) + if (!fragmentLinkCleanup) fragmentLinkCleanup = setupFragmentLinks(container) + if (!faviconCleanup) faviconCleanup = setupExternalLinkFavicons(container) container.toggleAttribute("data-local-links", !!markdown?.openLocalFile) @@ -924,6 +928,8 @@ export function Markdown( if (sessionLinkCleanup) sessionLinkCleanup() + if (fragmentLinkCleanup) fragmentLinkCleanup() + if (faviconCleanup) faviconCleanup() const container = root() diff --git a/packages/ui/src/context/marked-base.tsx b/packages/ui/src/context/marked-base.tsx index b51ce5ea72cf..a282031f88d0 100644 --- a/packages/ui/src/context/marked-base.tsx +++ b/packages/ui/src/context/marked-base.tsx @@ -5,8 +5,12 @@ export function createMarkdownBase() { renderer: { link({ href, title, tokens }) { const titleAttr = title ? ` title="${title}"` : "" + const text = this.parser.parseInline(tokens) - return `${this.parser.parseInline(tokens)}` + // A fragment link points within the document, so it stays in the page. + if (href.startsWith("#")) return `${text}` + + return `${text}` }, }, }) diff --git a/packages/ui/src/context/marked-parser.test.ts b/packages/ui/src/context/marked-parser.test.ts index ec0dcbc60b9b..c129c152ac33 100644 --- a/packages/ui/src/context/marked-parser.test.ts +++ b/packages/ui/src/context/marked-parser.test.ts @@ -18,6 +18,14 @@ test.each([ expect(parseSmallMarkdown(markdown)).toBe(expected) }) +test("keeps fragment links in the page", async () => { + const markdown = "See [Host services](#host-services)" + const expected = '

See Host services

\n' + + expect(await parser.parse(markdown)).toBe(expected) + expect(parseSmallMarkdown(markdown)).toBe(expected) +}) + test("renders inline and block math", async () => { expect(await parser.parse("\\(x^2\\)")).toContain('') expect(await parser.parse("$$\nx^2\n$$\n")).toContain('') From 4b60235a518a0157f282e6fc671b1561e3e04175 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:39:03 +0000 Subject: [PATCH 11/40] fix(app): report composer attachment persistence failures through onError (#59546) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> --- .../src/composer/attachments/attachments.ts | 28 ++-- .../composer-attachment-errors.test.tsx | 140 ++++++++++++++++++ 2 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 packages/app/test-browser/composer-attachment-errors.test.tsx diff --git a/packages/app/src/composer/attachments/attachments.ts b/packages/app/src/composer/attachments/attachments.ts index 5cc33dcc351e..f8953eb10a1f 100644 --- a/packages/app/src/composer/attachments/attachments.ts +++ b/packages/app/src/composer/attachments/attachments.ts @@ -17,13 +17,15 @@ type PromptTarget = { export type ComposerAttachmentConfig = { picker?: ( options: { defaultPath?: string; multiple?: boolean; accept?: string[] }, - onFile: (file: File) => Promise, + onFile: (file: File) => Promise, ) => Promise directory: () => string destination: () => AttachmentDestination isDialogActive: () => boolean duplicate: () => void + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- a failed attachment is opaque; the host formats it onError: (error: unknown) => void + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- a failed upload is opaque; the host formats it onUploadError: (error: unknown) => void readClipboardImage?: () => Promise getPathForFile?: (file: File) => string @@ -130,7 +132,7 @@ export function createComposerAttachments( .track({ id, filename: file.name, mime, size: file.size }, (report, signal) => destination.upload(file, report, signal), ) - .catch((error: unknown) => { + .catch((error) => { input.onUploadError(error) return undefined @@ -140,13 +142,20 @@ export function createComposerAttachments( if (path) addPath(target, { filename: file.name, mime, path }) } - const addAttachments = async (files: File[], target = capture()) => { - return files.reduce(async (result, file) => { - const previous = await result + // Callers start this without awaiting it, so a failure (for example a blob write on a full disk) + // is reported through onError instead of escaping as an unhandled rejection. + const addAttachments = (files: File[], target = capture(), clipboard = false) => + files + .reduce(async (result, file) => { + const previous = await result - return (await add(file, target)) || previous - }, Promise.resolve(false)) - } + return (await add(file, target, clipboard)) || previous + }, Promise.resolve(false)) + .catch((error) => { + input.onError(error) + + return false + }) const handlePaste = async (event: ClipboardEvent) => { const clipboardData = event.clipboardData @@ -176,7 +185,7 @@ export function createComposerAttachments( if (input.readClipboardImage && !plainText) { const file = await input.readClipboardImage() - if (file && (await add(file, target, true))) return + if (file && (await addAttachments([file], target, true))) return } if (!plainText) return @@ -195,6 +204,7 @@ export function createComposerAttachments( return } + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- probing for a deprecated browser API that some environments lack if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return put() } diff --git a/packages/app/test-browser/composer-attachment-errors.test.tsx b/packages/app/test-browser/composer-attachment-errors.test.tsx new file mode 100644 index 000000000000..e3c84e17da9c --- /dev/null +++ b/packages/app/test-browser/composer-attachment-errors.test.tsx @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { render } from "solid-js/web" +import { createStore } from "solid-js/store" +import { createComposerAttachments } from "@/composer/attachments/attachments" +import type { ComposerPrompt } from "@/composer/types" + +const failure = new Error("ENOSPC: no space left on device") + +const unhandled: unknown[] = [] + +const recordUnhandled: NodeJS.UnhandledRejectionListener = (reason) => { + unhandled.push(reason) +} + +beforeEach(() => { + unhandled.length = 0 + process.on("unhandledRejection", recordUnhandled) +}) + +afterEach(() => { + process.off("unhandledRejection", recordUnhandled) +}) + +function setup(options: { readClipboardImage?: () => Promise } = {}) { + const [store, setStore] = createStore<{ prompt: ComposerPrompt; cursor: number }>({ + prompt: [{ type: "text", content: "", start: 0, end: 0 }], + cursor: 0, + }) + + const errors: unknown[] = [] + + let attachments!: ReturnType + + const dispose = render(() => { + attachments = createComposerAttachments({ + capture: () => ({ + current: () => store.prompt, + cursor: () => store.cursor, + set: (prompt, cursor = store.cursor) => setStore({ prompt, cursor }), + }), + editor: () => document.createElement("div"), + focusEditor() {}, + addPart: () => false, + setDraggingType() {}, + directory: () => "/repo", + destination: () => ({ + input: { image: true, pdf: true }, + local: false, + upload: () => Promise.reject(new Error("inline images must not upload")), + }), + isDialogActive: () => false, + duplicate() {}, + onError: (error) => errors.push(error), + onUploadError: (error) => errors.push(error), + store: () => Promise.reject(failure), + readClipboardImage: options.readClipboardImage, + }) + + return null + }, document.createElement("div")) + + return { attachments, errors, store, dispose } +} + +const image = (name = "image.png") => new File([name], name, { type: "image/png" }) + +function transfer(file?: File) { + const data = new DataTransfer() + + if (file) data.items.add(file) + + return data +} + +// Lets fire-and-forget work settle and any unhandled rejection surface. +const settle = () => new Promise((resolve) => setTimeout(resolve, 10)) + +describe("Composer attachment persistence failures", () => { + test("adding files reports a failed blob write through onError", async () => { + const { attachments, errors, store, dispose } = setup() + + void attachments.addAttachments([image()]) + await settle() + + expect(errors).toEqual([failure]) + expect(unhandled).toEqual([]) + expect(store.prompt.some((part) => part.type === "image")).toBe(false) + dispose() + }) + + test("pasting an image reports a failed blob write through onError", async () => { + const { attachments, errors, dispose } = setup() + const event = new ClipboardEvent("paste", { cancelable: true, clipboardData: transfer(image()) }) + + void attachments.handlePaste(event) + await settle() + + expect(errors).toEqual([failure]) + expect(unhandled).toEqual([]) + dispose() + }) + + test("dropping an image on the document reports a failed blob write through onError", async () => { + const { errors, dispose } = setup() + await Promise.resolve() + const event = new DragEvent("drop", { cancelable: true }) + // happy-dom ignores the dataTransfer init option. + Object.defineProperty(event, "dataTransfer", { value: transfer(image()) }) + + document.dispatchEvent(event) + await settle() + + expect(errors).toEqual([failure]) + expect(unhandled).toEqual([]) + dispose() + }) + + test("pasting a native clipboard image reports a failed blob write through onError", async () => { + const { attachments, errors, dispose } = setup({ readClipboardImage: async () => image() }) + const event = new ClipboardEvent("paste", { cancelable: true, clipboardData: transfer() }) + + void attachments.handlePaste(event) + await settle() + + expect(errors).toEqual([failure]) + expect(unhandled).toEqual([]) + dispose() + }) + + test("a failed batch reports through onError once", async () => { + const { attachments, errors, dispose } = setup() + + const added = await attachments.addAttachments([image("first.png"), image("second.png")]) + + expect(added).toBe(false) + expect(errors).toEqual([failure]) + expect(unhandled).toEqual([]) + dispose() + }) +}) From 2fc371657b452a9191879432cc8cc1dd85978c8a Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:50:12 +0100 Subject: [PATCH 12/40] feat(app): unify quote comments and browser annotations (#60164) --- .../app/component-tests/browser-pane.spec.ts | 146 ++++- .../app/component-tests/line-comment.spec.ts | 46 ++ .../component-tests/terminal-focus.spec.ts | 17 + .../app/e2e/regression/terminal-input.spec.ts | 48 ++ .../e2e/regression/timeline-stream.spec.ts | 533 +++++++++++++++--- packages/app/e2e/utils/drafts.ts | 52 ++ .../app/src/composer/comment-note.test.ts | 2 + packages/app/src/composer/comment-note.ts | 2 + packages/app/src/composer/editor/editor.tsx | 20 +- .../app/src/composer/editor/interaction.ts | 12 +- .../app/src/composer/editor/quote-comment.tsx | 115 +++- packages/app/src/composer/model.ts | 1 - packages/app/src/composer/quote-position.ts | 32 ++ packages/app/src/composer/quote-source.ts | 39 +- packages/app/src/composer/request.test.ts | 42 ++ packages/app/src/composer/request.ts | 7 +- packages/app/src/composer/schema.test.ts | 19 +- packages/app/src/composer/schema.ts | 18 + packages/app/src/composer/state.ts | 36 +- packages/app/src/composer/submit.test.ts | 70 +++ packages/app/src/composer/submit.ts | 6 +- packages/app/src/runtime/i18n/en.ts | 4 + .../app/src/runtime/plugin/mounted-session.ts | 4 +- packages/app/src/session/composer/region.tsx | 7 +- packages/app/src/session/screen.tsx | 5 - .../src/session/timeline/quote-highlight.ts | 75 +-- packages/app/src/session/timeline/quote.tsx | 183 +++--- packages/gui-plugins/README.md | 26 + packages/gui-plugins/src/browser/comment.ts | 3 + packages/gui-plugins/src/browser/i18n/en.ts | 6 + .../gui-plugins/src/browser/panel.fixture.tsx | 28 +- packages/gui-plugins/src/browser/panel.tsx | 96 +++- packages/gui-plugins/src/terminal/i18n/en.ts | 1 + packages/gui-plugins/src/terminal/panel.tsx | 6 +- packages/gui-plugins/src/terminal/quote.tsx | 86 ++- .../src/terminal/terminal.fixture.tsx | 111 +++- .../gui-plugins/src/terminal/terminal.tsx | 4 +- packages/plugin/src/gui/host-apis.ts | 42 +- .../session-message-projection.spec.ts | 68 +++ packages/session-ui/src/actions.ts | 2 + .../session-ui/src/message/comment-card.tsx | 28 +- .../src/message/message-content.tsx | 2 +- .../src/timeline/timeline-row.stories.tsx | 51 +- packages/ui/package.json | 1 + .../line-comment/line-comment.css | 91 +++ .../line-comment/line-comment.stories.tsx | 22 +- .../line-comment/line-comment.tsx | 294 ++++++---- .../data-display/line-comment/quote-check.svg | 3 + 48 files changed, 2054 insertions(+), 458 deletions(-) create mode 100644 packages/app/e2e/utils/drafts.ts create mode 100644 packages/app/src/composer/quote-position.ts create mode 100644 packages/ui/src/data-display/line-comment/quote-check.svg diff --git a/packages/app/component-tests/browser-pane.spec.ts b/packages/app/component-tests/browser-pane.spec.ts index 129566b07de5..cfdcf9df3a56 100644 --- a/packages/app/component-tests/browser-pane.spec.ts +++ b/packages/app/component-tests/browser-pane.spec.ts @@ -210,6 +210,7 @@ story("keeps a still of the page under an open dialog instead of blanking the pa story("comments on a picked element over a still of the page", async ({ page }) => { const root = page.getByTestId("browser-pane-fixture") + await root.getByRole("combobox", { name: "Picked element scenario" }).selectOption("zoomed") const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true }) await picker.click() await expect(picker).toHaveAttribute("aria-pressed", "true") @@ -223,20 +224,25 @@ story("comments on a picked element over a still of the page", async ({ page }) await expect(picker).toHaveAttribute("aria-pressed", "false") const editor = root.locator('[data-slot="browser-comment-editor"] textarea') await expect(editor).toBeFocused() + await expect(editor).toHaveAttribute("placeholder", "Add an optional comment...") + await expect(editor).toHaveAccessibleName("Comment on this element") await expect(root.getByText("Session getter reads: 0", { exact: true })).toBeVisible() await expect(root.locator('[data-slot="browser-comment-editor"]')).toContainText("button.primary") // The spotlight frames the picked element in surface pixels. - await expect(root.locator('[data-slot="browser-comment-spotlight"]')).toHaveCSS("left", "48px") + await expect(root.locator('[data-slot="browser-comment-spotlight"]')).toHaveCSS("left", "38.4px") + await expect(root.locator('[data-slot="browser-comment-spotlight"]')).toHaveCSS("width", "128px") // The native page hides behind its still so the editor can float over it. await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false") await expect(root.locator("#browser-panel img")).toBeVisible() await editor.fill("Make this the primary colour") - await editor.press("Enter") + await root.getByRole("button", { name: "Add element comment", exact: true }).click() await expect(root.getByTestId("fixture-comments")).toHaveText("button.primary @e7: Make this the primary colour") await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0) await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible() await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") + await expect(root.getByTestId("fixture-comment-metadata")).toContainText('selector \\"main > button.primary\\"') + await expect(root.getByTestId("fixture-comment-metadata")).toContainText("accessible name") // The pane stays mounted when Beta is routed, and its picker then listens to Beta's page. await root.getByRole("button", { name: "Beta", exact: true }).click() @@ -323,29 +329,147 @@ story("keeps a comment draft but drops its ref when the page navigates", async ( ) }) -story("keeps the comment editor and its actions inside the page", async ({ page }) => { +story("grows the compact comment upward without flipping and keeps its actions inside the page", async ({ page }) => { const root = page.getByTestId("browser-pane-fixture") + await root.getByRole("combobox", { name: "Picked element scenario" }).selectOption("low") await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click() await root.getByRole("button", { name: "Pick element", exact: true }).click() const editor = root.locator('[data-slot="browser-comment-editor"]') - // As if the user dragged the textarea's resize handle far past the page. - await editor.locator("textarea").evaluate((element) => { - element.style.height = "900px" - }) - // The editor moves once it has measured its new height. + const field = editor.locator("textarea") + await expect(editor).toHaveCSS("width", "320px") + await expect(field).toBeFocused() + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + const opening = await editor.boundingBox() + + if (!opening) throw new Error("Missing opening annotation") + await field.fill("A line of feedback\n".repeat(6)) + await expect(field).toHaveCSS("height", "140px") + // The field lays out before ResizeObserver repositions its parent. + await expect + .poll(async () => { + const grown = await editor.boundingBox() + + return !!grown && grown.y < opening.y && Math.abs(grown.y + grown.height - opening.y - opening.height) < 0.5 + }) + .toBe(true) + await field.fill("A line of feedback\n".repeat(30)) + await expect(field).toHaveCSS("height", "160px") + expect(await field.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) await expect .poll(async () => { const surface = await root.locator('[data-component="browser-comment"]').boundingBox() const box = await editor.boundingBox() - return !!surface && !!box && box.y + box.height <= surface.y + surface.height + return !!surface && !!box && box.y >= surface.y && box.y + box.height <= surface.y + surface.height }) .toBe(true) - const submit = editor.getByRole("button", { name: "Comment", exact: true }) + const submit = editor.getByRole("button", { name: "Add element comment", exact: true }) await submit.scrollIntoViewIfNeeded() await expect(submit).toBeInViewport() + // Near the surface's top, growth stops at the opening bottom rather than pushing the card down. + await field.press("Escape") + await root.getByRole("combobox", { name: "Picked element scenario" }).selectOption("default") + await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click() + await root.getByRole("button", { name: "Pick element", exact: true }).click() + await expect(field).toBeFocused() + const nearTop = await editor.boundingBox() + + if (!nearTop) throw new Error("Missing near-top annotation") + await field.fill("A line of feedback\n".repeat(30)) + await expect + .poll(() => field.evaluate((element) => element.clientHeight < 160 && element.scrollHeight > element.clientHeight)) + .toBe(true) + await expect + .poll(async () => { + const box = await editor.boundingBox() + + return !!box && Math.abs(box.y + box.height - nearTop.y - nearTop.height) < 0.5 + }) + .toBe(true) + // A narrow, short pane still keeps the compact editor and action within its surface. + await root.locator("#browser-panel").evaluate((element) => { + const container = element.parentElement + + if (!container) throw new Error("Missing pane container") + container.style.width = "280px" + container.style.height = "150px" + }) + await expect(editor).toHaveCSS("width", "262px") + await expect + .poll(async () => { + const surface = await root.locator('[data-component="browser-comment"]').boundingBox() + const box = await editor.boundingBox() + + return !!surface && !!box && box.y >= surface.y && box.y + box.height <= surface.y + surface.height + }) + .toBe(true) + await expect(submit).toBeInViewport() }) +story( + "wraps the selector, attaches elements without comments, and cancels only empty drafts on clickaway", + async ({ page }) => { + const root = page.getByTestId("browser-pane-fixture") + const scenario = root.getByRole("combobox", { name: "Picked element scenario" }) + const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true }) + const editor = root.locator('[data-slot="browser-comment-editor"]') + const overlay = root.locator('[data-component="browser-comment"]') + await scenario.selectOption("long") + await root.evaluate((element) => element.setAttribute("dir", "rtl")) + await picker.click() + await root.getByRole("button", { name: "Pick element", exact: true }).click() + const preview = editor.locator('[data-slot="line-comment-v2-preview"] [dir="ltr"]') + const selector = `main > section[data-label="إعدادات"] > ${".nested-selector".repeat(24)} > button.primary` + await expect(preview).toHaveText(selector) + await expect(preview).toHaveAttribute("dir", "ltr") + await expect(preview).toHaveCSS("unicode-bidi", "isolate") + await expect(preview).toHaveCSS("-webkit-line-clamp", "2") + await expect(preview).toHaveCSS("height", "40px") + await preview.hover() + await expect(page.getByRole("tooltip")).toHaveText(selector) + await editor.locator("textarea").press("Enter") + await expect(overlay).toHaveCount(0) + const comments = root.getByTestId("fixture-comments").getByRole("listitem") + await expect(comments).toHaveText(["button.primary @e7:"]) + + await scenario.selectOption("default") + await picker.click() + await root.getByRole("button", { name: "Pick element", exact: true }).click() + await editor.getByRole("button", { name: "Add element", exact: true }).click() + await expect(overlay).toHaveCount(0) + await expect(comments).toHaveText(["button.primary @e7:", "button.primary @e7:"]) + expect(JSON.parse(await root.getByTestId("fixture-comment-metadata").innerText())).toEqual( + [selector, "main > button.primary"].map((value) => + expect.objectContaining({ + type: "note", + label: "button.primary", + title: value, + subtitle: "Browser annotation", + comment: "", + subject: `the "button.primary" element in browser tab tab_11111111-1111-1111-1111-111111111111 at https://alpha.example/ (role button; accessible name "Save changes"; selector ${JSON.stringify(value)})`, + live: { + href: "tab_11111111-1111-1111-1111-111111111111#e7", + subject: `the "button.primary" element in browser tab tab_11111111-1111-1111-1111-111111111111 at https://alpha.example/ (role button; accessible name "Save changes"; selector ${JSON.stringify(value)}; browser ref @e7, usable as ref in any browser tool including browser.evaluate until the page navigates)`, + }, + }), + ), + ) + + await scenario.selectOption("fallback") + await picker.click() + await root.getByRole("button", { name: "Pick element", exact: true }).click() + await expect(preview).toHaveText("button.primary") + await editor.locator("textarea").fill("Keep this feedback") + await overlay.click({ position: { x: 620, y: 290 } }) + await expect(editor.locator("textarea")).toHaveValue("Keep this feedback") + await editor.locator("textarea").fill("") + await overlay.click({ position: { x: 620, y: 290 } }) + await expect(overlay).toHaveCount(0) + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") + await expect(comments).toHaveText(["button.primary @e7:", "button.primary @e7:"]) + }, +) + story("shows the empty state over a blank native page and restores navigation", async ({ page }) => { const root = page.getByTestId("browser-pane-fixture") await root.getByRole("button", { name: "Blank page", exact: true }).click() @@ -569,7 +693,7 @@ story("suggests visited pages and opens the first one that completes the typed a const address = root.getByRole("combobox", { name: "Browser address", exact: true }) await address.click() await address.pressSequentially("localhost") - const options = page.getByRole("option") + const options = page.getByRole("listbox", { name: "Suggestions" }).getByRole("option") await expect(options).toHaveCount(1) await expect(options.first()).toHaveAttribute("aria-selected", "true") // Arrow keys that pick IME candidates leave the suggestions alone. diff --git a/packages/app/component-tests/line-comment.spec.ts b/packages/app/component-tests/line-comment.spec.ts index b774dbdfb1e6..d8c204d7a0de 100644 --- a/packages/app/component-tests/line-comment.spec.ts +++ b/packages/app/component-tests/line-comment.spec.ts @@ -11,3 +11,49 @@ story("keeps the comment options button pressed while its menu is open", async ( await expect(trigger).toHaveAttribute("data-expanded", "") await expect(trigger).not.toHaveCSS("background-color", rest) }) + +story("autosizes compact comments without native field-sizing", async ({ mount, page }) => { + // Mock only availability; disable native sizing so real layout exercises the JS fallback. + await page.addInitScript(() => { + const supports = CSS.supports.bind(CSS) + + CSS.supports = (property: string, value?: string) => { + if (property === "field-sizing") return false + + return value === undefined ? supports(property) : supports(property, value) + } + }) + + const component = await mount("ui-line-comment--editor", { args: { variant: "compact" } }) + const textarea = component.getByRole("textbox") + + await page.addStyleTag({ + content: '[data-layout="compact"] [data-slot="line-comment-v2-textarea"] { field-sizing: fixed !important; }', + }) + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + await expect(textarea).toHaveCSS("height", "20px") + + await textarea.fill("First\nSecond\nThird") + await expect(textarea).toHaveCSS("height", "60px") + + await textarea.clear() + await expect(textarea).toHaveCSS("height", "20px") + + await textarea.fill(Array.from({ length: 20 }, () => "Line").join("\n")) + await expect(textarea).toHaveCSS("height", "160px") + await expect.poll(() => textarea.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) +}) + +story("completes compact optional comments with or without text", async ({ mount }) => { + const component = await mount("ui-line-comment--editor", { args: { variant: "compact", allowEmpty: true } }) + const action = component.getByRole("button", { name: "Comment", exact: true }) + const result = component.getByRole("status", { name: "Editor result" }) + + await expect(action).toBeEnabled() + await action.click() + await expect(result).toHaveText("Submitted:") + + await component.getByRole("textbox").fill("Keep this passage") + await action.click() + await expect(result).toHaveText("Submitted: Keep this passage") +}) diff --git a/packages/app/component-tests/terminal-focus.spec.ts b/packages/app/component-tests/terminal-focus.spec.ts index b810a006d3ec..e2962676095c 100644 --- a/packages/app/component-tests/terminal-focus.spec.ts +++ b/packages/app/component-tests/terminal-focus.spec.ts @@ -37,3 +37,20 @@ story("a terminal opened without a focus request leaves focus in the composer", await expect(root.getByRole("textbox", { name: "Composer", exact: true })).toBeFocused() }) + +story("disposing a terminal invalidates its retained quote anchor", async ({ page }) => { + await page.evaluate(async (fixture) => { + const { mountTerminalQuote } = await import(fixture) + await mountTerminalQuote() + }, fixture) + const root = page.getByTestId("terminal-quote-fixture") + await root.getByRole("button", { name: "Select output", exact: true }).click() + await page.getByRole("toolbar", { name: "quote.actions", exact: true }).getByRole("button").click() + await expect(root.getByLabel("Quoted output", { exact: true })).toHaveText("Retained terminal output") + await expect(root.getByLabel("Quote placement", { exact: true })).toHaveText("Source") + await root.getByRole("button", { name: "Dispose source", exact: true }).click() + + await expect(root.getByTestId("terminal-quote-source")).toHaveCount(0) + await expect(root.getByLabel("Quote placement", { exact: true })).toHaveText("Tile") + await expect(root.getByLabel("Quoted output", { exact: true })).toHaveText("Retained terminal output") +}) diff --git a/packages/app/e2e/regression/terminal-input.spec.ts b/packages/app/e2e/regression/terminal-input.spec.ts index 81c95c9e527e..5a69c2d885c8 100644 --- a/packages/app/e2e/regression/terminal-input.spec.ts +++ b/packages/app/e2e/regression/terminal-input.spec.ts @@ -85,7 +85,55 @@ test("quotes selected terminal output into the composer", async ({ page }) => { await expect(chips).toHaveCount(1) await expect(chips).toContainText("Terminal") await expect(toolbar).toBeHidden() + const comment = page.locator('[data-component="composer-quote-comment"] textarea') + await expect(comment).toBeFocused() + await comment.fill("Investigate this output") + + const atTile = async () => { + const popover = await page.locator('[data-component="composer-quote-comment"]').boundingBox() + const tile = await chips.nth(0).boundingBox() + + return ( + popover && tile && Math.abs(popover.x - tile.x) < 1 && Math.abs(popover.y + popover.height - (tile.y - 8)) < 1 + ) + } + + await expect.poll(atTile).toBe(false) + // Cached terminals can hide without a write or grid resize; the unsaved editor still follows its tile. + await page.getByRole("button", { name: "Add tab", exact: true }).click() + await expect(terminal).toBeHidden() + await expect.poll(atTile).toBe(true) + await expect(comment).toHaveValue("Investigate this output") + await page.locator("#review-panel").getByRole("tab", { name: "Terminal 1", exact: true }).click() + await expect(terminal).toBeVisible() + await expect.poll(atTile).toBe(false) + await expect(comment).toHaveValue("Investigate this output") + + // Any output invalidates terminal row identity; the same draft stays open beside its composer tile. + pty.send(Array.from({ length: 80 }, (_, index) => `output row ${index}\r\n`).join("")) + await expect.poll(atTile).toBe(true) + await expect(comment).toHaveValue("Investigate this output") + await comment.fill("") + await comment.press("Enter") + await expect(comment).toHaveCount(0) + await expect(editor).toBeFocused() + + // The plugin's Alt path skips the editor too, and either quote can still be commented from its tile. + const next = await canvas.boundingBox() + + if (!next) throw new Error("The terminal has no canvas") + await page.mouse.move(next.x + 2, next.y + 4) + await page.mouse.down() + await page.mouse.move(next.x + 320, next.y + 26, { steps: 8 }) + await page.mouse.up() + await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) + await expect(chips).toHaveCount(2) + await expect(comment).toHaveCount(0) await expect(editor).toBeFocused() + await chips.nth(0).click() + await comment.fill("Why did the build fail?") + await comment.press("Enter") + await expect(chips.nth(0)).toContainText("Why did the build fail?") }) test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index e1812ce1c64f..8c4ea8354914 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -2,6 +2,7 @@ import { expect, test, type Locator, type Page } from "@playwright/test" import type { SessionMessageAssistant, SessionMessageInfo, ShellInfo } from "@opencode/client/promise" import { timelinePresets } from "@opencode/session-ui/timeline/detail" import { createTwoFilesPatch } from "diff" +import { readSessionDraftText } from "../utils/drafts" import { assistantID, assistantMessage, @@ -1961,12 +1962,16 @@ test.describe("shell completion", () => { test("quotes plain timeline text into the composer, with or without a comment", async ({ page }) => { const prompts: string[] = [] + const metadata: unknown[] = [] await setupTimeline(page, { messages: [ userMessage([userText("Explain the build", { id: "prt_quote_user" })]), assistantMessage([textPart("prt_quote_answer", "It has **two** steps:\n\n- compile\n- bundle")]), ], - onPrompt: (input) => void prompts.push(String(input.body.text)), + onPrompt: (input) => { + prompts.push(String(input.body.text)) + metadata.push(input.body.metadata) + }, }) const user = '[data-slot="user-message-text"]' @@ -1996,21 +2001,37 @@ test("quotes plain timeline text into the composer, with or without a comment", const chips = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]') await expect(chips).toHaveCount(1) await expect(chips).toContainText("Explain the build") + const comment = page.locator('[data-component="composer-quote-comment"] textarea') + await expect(comment).toBeFocused() + await expect(comment).toHaveAttribute("placeholder", "Add an optional comment...") + const popover = page.locator('[data-component="composer-quote-comment"]') + await expect(popover.getByRole("button")).toHaveCount(1) + await expect(popover.getByRole("button", { name: "Done", exact: true })).toBeVisible() + await expect( + popover.getByRole("button", { name: "Done", exact: true }).locator('span[aria-hidden="true"]'), + ).not.toHaveCSS("mask-image", "none") + await popover.getByRole("button", { name: "Done", exact: true }).click() + await expect(page.locator('[data-component="composer-editor"]')).toBeFocused() + await expect(chips).toHaveCount(1) // A quote's chip takes its comment; saving an emptied comment clears it again. await select(answer, answer) await toolbar.getByRole("button", { name: "Quote", exact: true }).click() await expect(chips).toHaveCount(2) - const comment = page.locator('[data-component="composer-quote-comment"] textarea') - await chips.nth(1).click() + await expect(comment).toBeFocused() await comment.fill("Why three?") - await comment.press("Enter") + await expect(popover.getByRole("button")).toHaveCount(1) + await expect(popover.getByRole("button", { name: "Add comment", exact: true })).toBeVisible() + await popover.getByRole("button", { name: "Add comment", exact: true }).click() await expect(comment).toHaveCount(0) await expect(chips.nth(1)).toContainText("Why three?") await chips.nth(1).click() await expect(comment).toHaveValue("Why three?") + await expect(popover.getByRole("button")).toHaveCount(1) + await expect(popover.getByRole("button", { name: "Save", exact: true })).toBeVisible() await comment.fill("") - await comment.press("Enter") + await expect(popover.getByRole("button", { name: "Save", exact: true })).toBeEnabled() + await popover.getByRole("button", { name: "Save", exact: true }).click() await expect(chips.nth(1)).not.toContainText("Why three?") await chips.nth(1).click() await comment.fill("Why two?") @@ -2023,7 +2044,9 @@ test("quotes plain timeline text into the composer, with or without a comment", // The shell turns selection off, so select all in the timeline selects its conversation and offers to quote it. await page.locator(answer).getByText("bundle", { exact: true }).click() - await page.keyboard.press("ControlOrMeta+a") + await page.keyboard.press( + await page.evaluate(() => (/(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta+a" : "Control+a")), + ) await expect(toolbar).toBeVisible() await page.keyboard.press("Escape") await expect(toolbar).toHaveCount(0) @@ -2048,17 +2071,26 @@ test("quotes plain timeline text into the composer, with or without a comment", "The user made the following comment regarding this quote: Why two?", ].join("\n"), ]) + expect(metadata).toEqual([ + expect.objectContaining({ + comments: [ + { type: "quote", text: "Explain the build\n\nIt has two steps:\ncompile\nbundle" }, + { type: "quote", text: "It has two steps:\ncompile\nbundle", comment: "Why two?" }, + ], + }), + ]) await expect(chips).toHaveCount(0) }) -test("highlights a quote's source only while its first comment is written", async ({ page }) => { +test("highlights the exact duplicate passage whenever its quote comment is open", async ({ page }) => { let reject = true await setupTimeline(page, { + seedHistory: true, messages: [ userMessage(), assistantMessage([ - textPart("prt_highlight_first", "The same **quoted** **words**."), - textPart("prt_highlight_second", "The same **quoted** **words**."), + textPart("prt_highlight_first", "The same **quoted** **words**. The same **quoted** **words**."), + textPart("prt_highlight_second", "The same **quoted** **words**. The same **quoted** **words**."), ]), ], onPrompt: () => { @@ -2069,43 +2101,79 @@ test("highlights a quote's source only while its first comment is written", asyn const secondID = renderedPartID("prt_highlight_second") const first = page.locator(`[data-timeline-part-id="${firstID}"] [data-slot="text-part-body"]`) const second = page.locator(`[data-timeline-part-id="${secondID}"] [data-slot="text-part-body"]`) - await expect(second).toHaveText("The same quoted words.") + await expect(second).toHaveText("The same quoted words. The same quoted words.") const highlights = () => page.evaluate(() => ["timeline-quote-source"].map((name) => - [...(CSS.highlights.get(name) ?? [])].map((range) => ({ - part: range.startContainer.parentElement - ?.closest("[data-timeline-part-id]") - ?.getAttribute("data-timeline-part-id"), - text: range.toString(), - })), + [...(CSS.highlights.get(name) ?? [])].map((range) => { + const body = range.startContainer.parentElement?.closest('[data-slot="text-part-body"]') + + if (!body) throw new Error("Highlighted quote has no source body") + + const prefix = document.createRange() + prefix.selectNodeContents(body) + prefix.setEnd(range.startContainer, range.startOffset) + + return { + part: body.closest("[data-timeline-part-id]")?.getAttribute("data-timeline-part-id"), + text: range.toString(), + start: prefix.toString().length, + end: prefix.toString().length + range.toString().length, + } + }), ), ) const selectionCollapsed = () => page.evaluate(() => window.getSelection()?.isCollapsed ?? true) - const select = (target: Locator) => - target.evaluate((element) => { + const select = (target: Locator, start: number) => + target.evaluate((element, start) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) const range = document.createRange() - range.selectNodeContents(element) + let offset = 0 + let node = walker.nextNode() + + while (node) { + const end = offset + (node.textContent?.length ?? 0) + + if (start >= offset && start < end) range.setStart(node, start - offset) + + if (start + 22 > offset && start + 22 <= end) { + range.setEnd(node, start + 22 - offset) + break + } + + offset = end + node = walker.nextNode() + } + window.getSelection()?.removeAllRanges() window.getSelection()?.addRange(range) - }) + }, start) const toolbar = page.getByRole("toolbar", { name: "Quote selection" }) const chips = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]') const editor = page.locator('[data-component="composer-editor"]') const comment = page.locator('[data-component="composer-quote-comment"] textarea') - const firstRange = { part: firstID, text: "The same quoted words." } + + const jump = page.locator('[data-component="composer-quote-comment"]').getByRole("button", { + name: "Jump to quote", + exact: true, + }) + + const firstRange = { part: firstID, text: "The same quoted words.", start: 0, end: 22 } + const secondRange = { part: secondID, text: "The same quoted words.", start: 23, end: 45 } + const scroll = page.locator('[data-slot="session-timeline-scroll"] > [data-scrollable]') await scroll.focus() - await select(first) - await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) + await select(first, 0) + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() await expect(comment).toBeFocused() await comment.fill("Keep this passage visible while I write.") await expect.poll(highlights).toEqual([[firstRange]]) + await expect(jump).toHaveCount(0) for (const palette of [ { colorScheme: "dark" as const, background: "color(srgb 0.635294 0.737255 1 / 0.25)" }, @@ -2135,8 +2203,8 @@ test("highlights a quote's source only while its first comment is written", asyn await expect(comment).toHaveCount(0) await expect.poll(highlights).toEqual([[]]) - await select(second) - await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + await select(second, 23) + await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) await expect(chips).toHaveCount(2) await expect(editor).toBeFocused() await expect.poll(selectionCollapsed).toBe(true) @@ -2148,13 +2216,34 @@ test("highlights a quote's source only while its first comment is written", asyn expect(await scroll.evaluate((element) => element.scrollTop)).toBe(before) await expect.poll(highlights).toEqual([[]]) - // Editing an existing quote's comment shows no source highlight. + // Reopening either identical quote highlights its own part and exact occurrence, never a text search match. await chips.nth(0).click() await expect(comment).toBeFocused() await expect(comment).toHaveValue("Keep this passage visible while I write.") - await expect.poll(highlights).toEqual([[]]) + await expect.poll(highlights).toEqual([[firstRange]]) + expect(await scroll.evaluate((element) => element.scrollTop)).toBe(before) await comment.press("Escape") await expect(comment).toHaveCount(0) + await expect.poll(highlights).toEqual([[]]) + await chips.nth(1).click() + await expect(comment).toBeFocused() + await expect.poll(highlights).toEqual([[secondRange]]) + expect(await scroll.evaluate((element) => element.scrollTop)).toBe(before) + await comment.press("Escape") + await expect.poll(highlights).toEqual([[]]) + + // Reopening a short quote never navigates to its offscreen source. + await scroll.hover({ position: { x: 20, y: 100 } }) + await page.mouse.wheel(0, -10000) + await expect(second).not.toBeInViewport() + const offscreenTop = await scroll.evaluate((element) => element.scrollTop) + await chips.nth(1).click() + await expect(comment).toBeFocused() + await comment.fill("An unsaved short-quote comment") + await expect(second).not.toBeInViewport() + expect(await scroll.evaluate((element) => element.scrollTop)).toBe(offscreenTop) + await comment.press("Escape") + await expect(chips.nth(1)).not.toContainText("An unsaved short-quote comment") await page .locator('[data-component="composer-attachments"]') @@ -2208,7 +2297,7 @@ test("keeps the native selection height after quoting wrapped and formatted text await page .getByRole("toolbar", { name: "Quote selection" }) .getByRole("button", { name: "Quote", exact: true }) - .click({ modifiers: ["Alt"] }) + .click() await expect(page.locator('[data-component="composer-quote-comment"] textarea')).toBeFocused() await page.setViewportSize({ width: 700, height: 900 }) @@ -2224,14 +2313,35 @@ test("keeps the native selection height after quoting wrapped and formatted text ), ) .toBe(true) + + // The source-open preview is compact; editing the attached quote reveals up to four lines of the same passage. + const popover = page.locator('[data-component="composer-quote-comment"]') + const preview = popover.locator('[data-slot="line-comment-v2-preview"]') + + const previewLines = () => + preview.evaluate( + (element) => element.getBoundingClientRect().height / parseFloat(getComputedStyle(element).lineHeight), + ) + + await expect.poll(previewLines).toBeCloseTo(2, 1) + const quotedText = await preview.innerText() + await popover.getByRole("button", { name: "Done", exact: true }).click() + const chip = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]') + await expect(chip).toHaveCount(1) + await chip.click() + await expect(popover.locator("textarea")).toBeFocused() + await expect(preview).toHaveText(quotedText) + await expect.poll(previewLines).toBeCloseTo(4, 1) }) -test("reveals a pending quote's source and restores its retained draft after its row remounts", async ({ page }) => { - await setupTimeline(page, { +test("retains a long quote's source across reload without scrolling when its editor opens", async ({ page }) => { + const passage = `Keep this exact source highlighted. ${Array.from({ length: 60 }, (_, index) => `source-word-${index}`).join(" ")}` + + const timeline = await setupTimeline(page, { seedHistory: true, messages: [ userMessage(), - assistantMessage([textPart("prt_quote_remount", "Keep this exact source highlighted.")]), + assistantMessage([textPart("prt_quote_remount", passage)]), userMessage([userText("Continue with the next step.")], { id: "msg_quote_following_user" }), assistantMessage([textPart("prt_quote_following_answer", "The next step is ready.")], { id: "msg_quote_following_answer", @@ -2239,9 +2349,11 @@ test("reveals a pending quote's source and restores its retained draft after its }), ], }) + const partID = renderedPartID("prt_quote_remount") + const source = page.locator(`[data-timeline-part-id="${partID}"] [data-slot="text-part-body"]`) - await expect(source).toHaveText("Keep this exact source highlighted.") + await expect(source).toHaveText(passage) await source.evaluate((element) => { const range = document.createRange() range.selectNodeContents(element) @@ -2251,7 +2363,7 @@ test("reveals a pending quote's source and restores its retained draft after its await page .getByRole("toolbar", { name: "Quote selection" }) .getByRole("button", { name: "Quote", exact: true }) - .click({ modifiers: ["Alt"] }) + .click() const chip = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]') const comment = page.locator('[data-component="composer-quote-comment"] textarea') await expect(comment).toBeFocused() @@ -2264,31 +2376,14 @@ test("reveals a pending quote's source and restores its retained draft after its ), ) - await expect.poll(highlighted).toEqual(["Keep this exact source highlighted."]) + await expect.poll(highlighted).toEqual([passage]) const scroll = page.locator('[data-slot="session-timeline-scroll"] > [data-scrollable]') - await scroll.hover({ position: { x: 20, y: 100 } }) - await page.mouse.wheel(0, -10000) - await expect(source).toHaveCount(0) - const top = await scroll.evaluate((element) => element.scrollTop) - await chip.hover() - expect(await scroll.evaluate((element) => element.scrollTop)).toBe(top) - - // Clicking the pending card closes its open comment, and reveals the source before it does. - await chip.click() + await comment.fill("Retained quote comment") + await comment.press("Enter") await expect(comment).toHaveCount(0) - await expect(source).toBeInViewport() - await expect.poll(highlighted).toEqual([]) - - // An existing quote's card edits its comment without moving the timeline to its source. - await scroll.hover({ position: { x: 20, y: 100 } }) - await page.mouse.wheel(0, -10000) - await expect(source).toHaveCount(0) - await chip.click() - await expect(comment).toBeFocused() - await expect(source).toHaveCount(0) + await expect(chip).toContainText("Retained quote comment") await expect.poll(highlighted).toEqual([]) - await comment.press("Escape") const href = new URL(page.url()).pathname await page.getByRole("button", { name: "Home", exact: true }).click() @@ -2298,6 +2393,171 @@ test("reveals a pending quote's source and restores its retained draft after its await expect(chip).toContainText("Keep this exact source highlighted.") await expect.poll(highlighted).toEqual([]) await expect(comment).toHaveCount(0) + + // A fresh document must recover the local source anchor, not just the quote's displayed text. + const editor = page.locator('[data-component="composer-editor"]') + await editor.fill("Retained composer draft") + // Reload only after the editor's input has reached the durable draft, not merely its DOM. + await expect.poll(() => readSessionDraftText(page, sessionID)).toBe("Retained composer draft") + await page.reload() + await timeline.transport.waitForConnection() + await expect(editor).toHaveText("Retained composer draft") + await expect(chip).toContainText("Retained quote comment") + await scroll.hover({ position: { x: 20, y: 100 } }) + await page.mouse.wheel(0, -10000) + await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBe(0) + await expect(source).not.toBeInViewport() + const reloadedTop = await scroll.evaluate((element) => element.scrollTop) + await chip.click() + await expect(comment).toBeFocused() + await expect(comment).toHaveValue("Retained quote comment") + await expect(source).not.toBeInViewport() + expect(await scroll.evaluate((element) => element.scrollTop)).toBe(reloadedTop) + await scroll.hover({ position: { x: 20, y: 100 } }) + await page.mouse.wheel(0, 10000) + await expect + .poll(() => scroll.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop))) + .toBeLessThan(1) + await expect(source).toBeInViewport() + await expect.poll(highlighted).toEqual([passage]) + await comment.press("Escape") + await expect.poll(highlighted).toEqual([]) +}) + +test("highlights a horizontally clipped code quote without scrolling its source", async ({ page }) => { + const tail = `quoted tail start ${Array.from({ length: 40 }, (_, index) => `distinct-word-${index}`).join(" ")} quoted tail end` + + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + textPart("prt_quote_code_tail", `\`\`\`plaintext\n${"unquoted prefix ".repeat(80)}${tail}\n\`\`\``), + ]), + ], + }) + + const source = page.locator( + `[data-timeline-part-id="${renderedPartID("prt_quote_code_tail")}"] [data-slot="text-part-body"]`, + ) + + const pre = source.locator("pre") + const code = pre.locator("code") + await expect(code).toContainText(tail) + await expect(pre).toBeInViewport() + await expect.poll(() => pre.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0) + await pre.evaluate((element) => element.scrollTo({ left: element.scrollWidth })) + + const tailVisible = () => + code.evaluate((element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let node = walker.nextNode() + + while (node) { + // The full quote is wider than the scrollport; check its final words after the user scrolls back. + const start = node.textContent?.indexOf("quoted tail end") ?? -1 + + if (start >= 0) { + const range = document.createRange() + range.setStart(node, start) + range.setEnd(node, start + "quoted tail end".length) + + const rect = range.getBoundingClientRect() + const pre = element.closest("pre")! + const bounds = pre.getBoundingClientRect() + const left = bounds.left + pre.clientLeft + + return ( + rect.width > 0 && + rect.left >= left - 1 && + rect.right <= left + pre.clientWidth + 1 && + rect.top >= bounds.top && + rect.bottom <= bounds.bottom + ) + } + + node = walker.nextNode() + } + + throw new Error("The quoted tail text node is missing") + }) + + await expect.poll(tailVisible).toBe(true) + await code.evaluate((element, tail) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let node = walker.nextNode() + + while (node) { + const start = node.textContent?.indexOf(tail) ?? -1 + + if (start >= 0) { + const range = document.createRange() + range.setStart(node, start) + range.setEnd(node, start + tail.length) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + + return + } + + node = walker.nextNode() + } + + throw new Error("The quoted tail text node is missing") + }, tail) + + await page + .getByRole("toolbar", { name: "Quote selection" }) + .getByRole("button", { name: "Quote", exact: true }) + .click() + + const popover = page.locator('[data-component="composer-quote-comment"]') + const comment = popover.locator("textarea") + await expect(comment).toBeFocused() + await popover.getByRole("button", { name: "Done", exact: true }).click() + await expect(comment).toHaveCount(0) + + const chip = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]') + await expect(chip).toContainText(tail.slice(0, 200)) + await expect(chip).not.toContainText("unquoted prefix") + await chip.click() + await expect(comment).toBeFocused() + + const highlighted = () => + page.evaluate(() => [...(CSS.highlights.get("timeline-quote-source") ?? [])].map((range) => range.toString())) + + await expect.poll(highlighted).toEqual([tail]) + await comment.fill("Saved code-tail comment") + await comment.press("Enter") + await expect(comment).toHaveCount(0) + + const timeline = page.locator('[data-slot="session-timeline-scroll"] > [data-scrollable]') + const top = await timeline.evaluate((element) => element.scrollTop) + await pre.evaluate((element) => element.scrollTo({ left: 0 })) + await expect.poll(() => pre.evaluate((element) => element.scrollLeft)).toBe(0) + await expect.poll(tailVisible).toBe(false) + expect(await timeline.evaluate((element) => element.scrollTop)).toBe(top) + + // Opening even a long quote keeps the nested horizontal scroll exactly where the user left it. + await chip.click() + await expect(comment).toBeFocused() + await expect.poll(highlighted).toEqual([tail]) + await expect.poll(tailVisible).toBe(false) + await expect.poll(() => pre.evaluate((element) => element.scrollLeft)).toBe(0) + await expect(comment).toHaveValue("Saved code-tail comment") + await comment.fill("Unsaved code-tail edit\nKeep the source offscreen while editing") + await expect(comment).toHaveValue("Unsaved code-tail edit\nKeep the source offscreen while editing") + await expect.poll(() => pre.evaluate((element) => element.scrollLeft)).toBe(0) + expect(await timeline.evaluate((element) => element.scrollTop)).toBe(top) + + await pre.evaluate((element) => element.scrollTo({ left: element.scrollWidth })) + await expect.poll(tailVisible).toBe(true) + await expect.poll(highlighted).toEqual([tail]) + await comment.press("Escape") + await expect.poll(highlighted).toEqual([]) + await chip.click() + await expect(comment).toHaveValue("Saved code-tail comment") + await expect.poll(tailVisible).toBe(true) + await expect.poll(highlighted).toEqual([tail]) }) test("quotes table rows and diff lines selected across a diff viewer", async ({ page }) => { @@ -2362,7 +2622,7 @@ test("quotes table rows and diff lines selected across a diff viewer", async ({ // Headers are unselectable chrome; body cells quote with tabs, followed by the diff lines and their markers. await drag(page.locator("td").getByText("a.ts", { exact: true }), added) - await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() await expect(chips).toHaveCount(1) await expect(page.locator('[data-component="composer-quote-comment"] textarea')).toBeFocused() @@ -2378,13 +2638,12 @@ test("quotes table rows and diff lines selected across a diff viewer", async ({ await expect .poll(() => diffHighlights("timeline-quote-source")) .toEqual(["export const value = 'before'", "export const value = 'after'"]) - // Only the first comment keeps the source highlighted; saving it clears the highlight. await page.locator('[data-component="composer-quote-comment"] textarea').press("Enter") await expect.poll(() => diffHighlights("timeline-quote-source")).toEqual([]) // The quote stops where a selection inside the diff viewer ends. await drag(page.locator("td").getByText("a.ts", { exact: true }), removed) - await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) await expect(chips).toHaveCount(2) await page.locator('[data-component="composer-editor"]').press("Enter") @@ -2606,14 +2865,48 @@ for (const input of [ if (input.touch) expect(Math.round(action.y + action.height)).toBe(Math.round(view.y + view.height - 8)) else expect(action.y + action.height).toBeLessThanOrEqual(line.y) + + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + const popover = page.locator('[data-component="composer-quote-comment"]') + await expect(popover.locator("textarea")).toBeFocused() + await expect + .poll(() => + popover.evaluate((element) => { + const box = element.getBoundingClientRect() + + return ( + box.left >= 8 && box.right <= window.innerWidth - 8 && box.top >= 8 && box.bottom <= window.innerHeight - 8 + ) + }), + ) + .toBe(true) + + if (input.touch) { + await expect + .poll(async () => { + const box = await popover.boundingBox() + + const viewport = await page + .locator('[data-slot="session-timeline-scroll"] .scroll-view__viewport') + .boundingBox() + + return box && viewport && Math.abs(box.y + box.height - (viewport.y + viewport.height - 8)) < 1 + }) + .toBe(true) + } + await context.close() }) -test("opens a quote's comment with Alt-click", async ({ page }) => { +test("opens an optional comment beside the quote action and skips it with Alt-click", async ({ page }) => { await setupTimeline(page, { messages: [ userMessage(), assistantMessage([ + textPart( + "prt_alt_preamble", + Array.from({ length: 8 }, (_, index) => `Build step ${index + 1} is ready for review.`).join("\n\n"), + ), textPart("prt_alt_first", "The first sentence to quote."), textPart("prt_alt_second", "The second sentence to quote."), ]), @@ -2632,12 +2925,12 @@ test("opens a quote's comment with Alt-click", async ({ page }) => { await expect(toolbar).toBeVisible() } - // A plain click adds the quote, leaves the comment closed, and returns the caret to where the draft left it. + // Alt-click adds the quote, leaves the comment closed, and returns the caret to where the draft left it. const editor = page.locator('[data-component="composer-editor"]') await editor.click() await page.keyboard.type("Draft") await select("The first sentence to quote.") - await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] }) await expect(chips).toHaveCount(1) await expect(comment).toHaveCount(0) await expect(editor).toBeFocused() @@ -2650,32 +2943,138 @@ test("opens a quote's comment with Alt-click", async ({ page }) => { // A slow tooltip names the action and its shortcut. await select("The first sentence to quote.") await toolbar.getByRole("button", { name: "Quote", exact: true }).hover() - const tooltip = page.locator('[data-component="tooltip-v2"]', { hasText: "Quote and comment" }) + const tooltip = page.locator('[data-component="tooltip-v2"]', { hasText: "Quote without comment" }) await expect(tooltip).toBeVisible() await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText([/^(⌥|Alt)$/]) - // Alt-click opens the new quote's comment, focused. - await page.keyboard.down("Alt") + // Plain click replaces the quote action with a focused comment beside the same source. + const selected = await page.evaluate(() => { + const rect = window.getSelection()!.getRangeAt(0).getBoundingClientRect() + + return { top: rect.top } + }) + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() - await page.keyboard.up("Alt") await expect(chips).toHaveCount(1) await expect(comment).toBeFocused() + const popover = page.locator('[data-component="composer-quote-comment"]') + await expect + .poll(async () => { + const box = await popover.boundingBox() + + return box && Math.abs(box.y + box.height + 8 - selected.top) + }) + .toBeLessThan(2) + + // Reflow must follow the current source glyphs, not the selection's old screen coordinates. + await comment.fill("Unsaved reflow comment") + await page.setViewportSize({ width: 700, height: 900 }) + await expect + .poll(async () => { + const left = await page + .locator('[data-slot="text-part-body"]') + .getByText("The first sentence to quote.", { exact: true }) + .evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + const rect = range.getBoundingClientRect() + const bounds = element.closest("[data-scrollable]")?.getBoundingClientRect() + + if (!bounds) throw new Error("The quote source has no scroll viewport") + + return Math.min( + Math.max((rect.left + rect.right - 320) / 2, bounds.left + 8), + Math.max(bounds.left + 8, bounds.right - 320 - 8), + ) + }) + + const box = await popover.boundingBox() + + return box ? Math.abs(box.x - left) : Infinity + }) + .toBeLessThan(1) + await expect(comment).toHaveValue("Unsaved reflow comment") + + // An above-selection opening keeps its bottom/left while the input grows upwards. + await expect + .poll(() => + comment.evaluate((element) => { + const style = getComputedStyle(element) + + return Math.abs( + element.clientHeight - + parseFloat(style.paddingTop) - + parseFloat(style.paddingBottom) - + parseFloat(style.lineHeight), + ) + }), + ) + .toBeLessThan(1) + const opening = await popover.boundingBox() + const singleLineHeight = await comment.evaluate((element) => element.getBoundingClientRect().height) + + if (!opening) throw new Error("The quote comment is not laid out") + await comment.fill("First line\nSecond line\nThird line") + await expect + .poll(async () => { + const box = await popover.boundingBox() + const height = await comment.evaluate((element) => element.getBoundingClientRect().height) + + return ( + !!box && + Math.abs(box.x - opening.x) < 1 && + Math.abs(box.y + box.height - opening.y - opening.height) < 1 && + box.y < opening.y && + box.height > opening.height && + height > singleLineHeight && + height < 160 + ) + }) + .toBe(true) + + await comment.fill("") await page.keyboard.type("Why first?") await page.keyboard.press("Enter") await expect(comment).toHaveCount(0) await expect(chips.nth(0)).toContainText("Why first?") - // Enter on an empty comment keeps the quote without one. await select("The second sentence to quote.") - await page.keyboard.down("Alt") await toolbar.getByRole("button", { name: "Quote", exact: true }).click() - await page.keyboard.up("Alt") await expect(comment).toBeFocused() await page.keyboard.press("Enter") await expect(comment).toHaveCount(0) await expect(chips).toHaveCount(2) await expect(chips.nth(1)).toContainText("The second sentence to quote.") await expect(chips.nth(1)).toContainText("Quote") + await expect(editor).toBeFocused() + + // Reopening a quote uses its tile, not the original selection's position. + await chips.nth(1).click() + await expect(comment).toBeFocused() + await expect + .poll(async () => { + const tile = await chips.nth(1).boundingBox() + const box = await popover.boundingBox() + + return !!tile && !!box && Math.abs(box.y + box.height + 8 - tile.y) < 1 + }) + .toBe(true) + await comment.press("Escape") + await expect(editor).toBeFocused() + + // Escape and an unchanged click-away also keep newly attached quotes, without submitting a prompt. + await select("The first sentence to quote.") + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + await expect(comment).toBeFocused() + await comment.press("Escape") + await expect(chips).toHaveCount(3) + await expect(editor).toBeFocused() + await select("The second sentence to quote.") + await toolbar.getByRole("button", { name: "Quote", exact: true }).click() + await expect(comment).toBeFocused() + await editor.click() + await expect(comment).toHaveCount(0) + await expect(chips).toHaveCount(4) }) test("reaches the quote action from the keyboard and blocks a selection over the limit", async ({ page }) => { diff --git a/packages/app/e2e/utils/drafts.ts b/packages/app/e2e/utils/drafts.ts new file mode 100644 index 000000000000..d4ed08f8291a --- /dev/null +++ b/packages/app/e2e/utils/drafts.ts @@ -0,0 +1,52 @@ +import type { Page } from "@playwright/test" +import { Schema } from "effect" + +// Reads committed inline prompt text so reload tests do not race the draft writer. +export async function readSessionDraftText(page: Page, sessionID: string) { + const raw = await page.evaluate(async (sessionID) => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open("opencode-drafts", 1) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + + try { + const transaction = db.transaction("documents", "readonly") + const documents = transaction.objectStore("documents") + + const keys = await new Promise((resolve, reject) => { + const request = documents.getAllKeys() + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + + const key = keys.find((key) => String(key).endsWith(`:session:${sessionID}:prompt`)) + + if (key === undefined) return null + + return await new Promise((resolve, reject) => { + const request = documents.get(key) + transaction.oncomplete = () => resolve(request.result) + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(transaction.error) + }) + } finally { + db.close() + } + }, sessionID) + + if (raw === null) return "" + + const draft = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + prompt: Schema.Array(Schema.Struct({ type: Schema.String, content: Schema.optional(Schema.Unknown) })), + }), + ), + )(raw) + + return draft.prompt + .filter((part) => part.type === "text") + .map((part) => Schema.decodeUnknownSync(Schema.String)(part.content)) + .join("") +} diff --git a/packages/app/src/composer/comment-note.test.ts b/packages/app/src/composer/comment-note.test.ts index 96300f5d5fab..0ed6b9e2297f 100644 --- a/packages/app/src/composer/comment-note.test.ts +++ b/packages/app/src/composer/comment-note.test.ts @@ -6,6 +6,8 @@ const durable = { type: "note" as const, origin: "example", label: "button#save", + title: "button#save", + subtitle: "Browser annotation", icon: "select-element", subject: 'the "button#save" element', href: "tab_00000000-0000-4000-8000-000000000000", diff --git a/packages/app/src/composer/comment-note.ts b/packages/app/src/composer/comment-note.ts index 205178bda020..227c4c4591b0 100644 --- a/packages/app/src/composer/comment-note.ts +++ b/packages/app/src/composer/comment-note.ts @@ -57,6 +57,8 @@ export function formatAttachmentReference(input: PromptAttachmentReference) { /** A note reads with its live subject while it stays in the app process that attached it. */ export function formatNoteComment(input: NoteComment) { + if (!input.comment.trim()) return `The user referenced ${input.live?.subject ?? input.subject}.` + return `The user made the following comment regarding ${input.live?.subject ?? input.subject}: ${input.comment}` } diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index 7404e1cf4972..0adfc632f1d8 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -266,8 +266,9 @@ export function ComposerEditor(props: ComposerEditorProps) { onCommentRemove={(comment) => props.controller.removeContext(comment.key)} onFileRemove={(file) => props.controller.removeContext(file.key)} editingQuote={props.controller.editingQuote()} + quoteAnchor={props.controller.quoteAnchor()} + onQuoteFocus={props.controller.focusQuotePrompt} onEditQuote={props.controller.editQuote} - quoteSource={props.controller.quoteSource} onQuoteComment={(quote, comment) => props.controller.commentQuote(quote.key, comment)} searchPaths={props.controller.searchPaths} /> @@ -678,8 +679,9 @@ function ComposerAttachments(props: { onCommentRemove?: (comment: ComposerComment) => void onFileRemove?: (file: Extract) => void editingQuote?: string + quoteAnchor?: ReturnType + onQuoteFocus?: () => void onEditQuote?: (key?: string) => void - quoteSource?: ComposerEditorModel["quoteSource"] onQuoteComment?: (quote: Extract, comment: string) => void searchPaths?: (query: string) => Promise }) { @@ -729,18 +731,17 @@ function ComposerAttachments(props: { value={comment.comment} placement="top" openDelay={800} - disabled={comment.type === "quote"} + disabled={comment.type === "quote" || (comment.type === "note" && !comment.comment.trim())} contentClass="max-w-[300px] break-words" > { if (comment.type !== "quote") return props.onCommentClick?.(comment) - props.quoteSource?.reveal(comment.key) props.onEditQuote?.(props.editingQuote === comment.key ? undefined : comment.key) }} /> @@ -874,15 +875,20 @@ function ComposerAttachments(props: { {(anchor) => ( props.searchPaths?.(query) ?? Promise.resolve([])} onSubmit={(comment) => { props.onQuoteComment?.(current, comment) props.onEditQuote?.() + props.onQuoteFocus?.() + }} + onClose={(focus) => { + props.onEditQuote?.() + + if (focus) props.onQuoteFocus?.() }} - onClose={() => props.onEditQuote?.()} /> )}
diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index d59b058751b2..452fed263476 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -25,7 +25,7 @@ import { import { clonePrompt, isAttachment, promptLength } from "../prompt-parts" import type { ComposerQueue } from "../adapter" import { isCommentItem } from "../state" -import type { createComposerQuoteSource } from "../quote-source" +import type { ComposerQuoteAnchor } from "@opencode/plugin/gui" export type ComposerSelectControl = { options: Accessor @@ -79,8 +79,11 @@ export function createComposerEditor(input: { openContext?: (key: string) => void onContextRemove?: (item: ComposerComment) => void onQuoteComment?: (item: Extract, comment: string) => void - quoteComment?: { key: Accessor; set: (key?: string) => void } - quoteSource?: Pick, "reveal"> + quoteComment?: { + key: Accessor + anchor?: Accessor + set: (key?: string) => void + } onEditor?: (element: HTMLElement) => void onSuggestionSelect?: (item: ComposerSuggestion) => (() => void) | void view: ComposerEditorView @@ -572,8 +575,9 @@ export function createComposerEditor(input: { input.openContext?.(id) }, editingQuote: () => quoteComment.key(), - quoteSource: input.quoteSource, + quoteAnchor: () => input.quoteComment?.anchor?.(), editQuote: (key?: string) => quoteComment.set(key), + focusQuotePrompt: () => restoreFocus(), commentQuote(id: string, comment: string) { const item = draft.state.context.items.find((entry) => entry.key === id) diff --git a/packages/app/src/composer/editor/quote-comment.tsx b/packages/app/src/composer/editor/quote-comment.tsx index bc71bf5831a7..f59349d48461 100644 --- a/packages/app/src/composer/editor/quote-comment.tsx +++ b/packages/app/src/composer/editor/quote-comment.tsx @@ -1,23 +1,24 @@ +import { createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" -import { Icon } from "@opencode/ui/icon" import { LineCommentEditor } from "@opencode/ui/line-comment" import { useLanguage } from "@/runtime/i18n/language" +import type { ComposerQuoteAnchor } from "@opencode/plugin/gui" const GAP = 8 -const WIDTH = 380 +const WIDTH = 320 export function QuoteCommentEditor(props: { anchor: HTMLElement + source?: ComposerQuoteAnchor text: string - icon?: string comment: string mention: (query: string) => Promise onSubmit: (comment: string) => void - onClose: () => void + onClose: (focus?: boolean) => void }) { const language = useLanguage() @@ -25,14 +26,47 @@ export function QuoteCommentEditor(props: { draft: props.comment, rect: bounds(props.anchor), viewport: window.innerWidth, + viewportHeight: window.innerHeight, + revision: 0, height: 0, + openingHeight: 0, + }) + + const width = () => Math.min(WIDTH, state.viewport - GAP * 2) + + const source = createMemo(() => { + // DOM scroll is not reactive; scheduled measurements invalidate the source's viewport coordinates. + void state.revision + + if (!state.openingHeight) return + + return props.source?.({ width: width(), height: state.openingHeight }) }) let element: HTMLElement | undefined - const measure = () => setState({ rect: bounds(props.anchor), viewport: window.innerWidth }) - makeEventListener(window, "resize", measure) - makeEventListener(document, "scroll", measure, { capture: true, passive: true }) + let frame: number | undefined + + const schedule = () => { + if (frame !== undefined) return + frame = requestAnimationFrame(() => { + frame = undefined + setState({ + rect: bounds(props.anchor), + viewport: window.innerWidth, + viewportHeight: window.innerHeight, + revision: state.revision + 1, + }) + }) + } + + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + }) + + makeEventListener(window, "resize", schedule) + makeEventListener(document, "scroll", schedule, { capture: true, passive: true }) + createResizeObserver(() => props.anchor.parentElement, schedule) makeEventListener(document, "pointerdown", (event) => { const target = event.target @@ -41,48 +75,69 @@ export function QuoteCommentEditor(props: { if (state.draft.trim() === props.comment.trim()) props.onClose() }) - const width = () => Math.min(WIDTH, state.viewport - GAP * 2) - const left = () => Math.min(Math.max(state.rect.left, GAP), state.viewport - width() - GAP) + const left = () => Math.min(Math.max(source()?.left ?? state.rect.left, GAP), state.viewport - width() - GAP) - const top = () => { - const above = state.rect.top - state.height - GAP + const bottom = createMemo(() => { + // Preserve the opening bottom edge as the draft grows upward. + const above = state.rect.top - state.openingHeight - GAP + const desired = source()?.top ?? (above >= GAP ? above : state.rect.bottom + GAP) - return above >= GAP ? above : state.rect.bottom + GAP - } + return Math.min(state.viewportHeight - GAP, Math.max(GAP + state.openingHeight, desired + state.openingHeight)) + }) + + const availableHeight = () => Math.max(0, bottom() - GAP) + const top = () => Math.max(GAP, bottom() - state.height) return (
{ element = value - createResizeObserver(value, () => setState("height", value.offsetHeight)) + createResizeObserver(value, () => { + setState({ + height: value.offsetHeight, + openingHeight: state.openingHeight || value.offsetHeight, + }) + }) }} data-component="composer-quote-comment" data-prevent-autofocus - class="fixed z-50 overflow-y-auto rounded-[6px] shadow-[var(--v2-elevation-raised)]" + class="fixed z-50" style={{ top: `${top()}px`, left: `${left()}px`, width: `${width()}px`, + "max-height": `${availableHeight()}px`, + "--line-comment-max-height": `${availableHeight()}px`, // Transparent rather than hidden while unmeasured, so the comment field can take focus as it mounts. opacity: state.height === 0 ? 0 : undefined, }} > - setState("draft", value)} - onCancel={props.onClose} - onSubmit={props.onSubmit} - placeholder={language.t("session.quote.commentPlaceholder")} - allowEmpty - mention={{ items: props.mention }} - selection={ - - - {props.text.slice(0, 200)} - - } - /> +
+ setState("draft", value)} + onCancel={() => props.onClose(true)} + onSubmit={props.onSubmit} + placeholder={language.t("session.quote.optionalCommentPlaceholder")} + submitLabel={language.t( + props.comment.trim() + ? "common.save" + : state.draft.trim() + ? "session.quote.addComment" + : "session.quote.done", + )} + allowEmpty + mention={{ items: props.mention }} + selection={{props.text}} + /> +
) diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index d348fd5e1758..2350812be0b7 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -423,7 +423,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? })), onQuoteComment: (item, comment) => prompt.context.updateComment(item.commentID, { comment }), quoteComment: prompt.quoteComment, - quoteSource: prompt.quoteSource, onContextRemove(item) { if (item.type === "file" && item.commentID) comments.remove(item.path, item.commentID) }, diff --git a/packages/app/src/composer/quote-position.ts b/packages/app/src/composer/quote-position.ts new file mode 100644 index 000000000000..eb4a1e17b7c4 --- /dev/null +++ b/packages/app/src/composer/quote-position.ts @@ -0,0 +1,32 @@ +type Rect = { top: number; bottom: number; left: number; right: number } + +/** Shared by the selection toolbar and the comment editor that replaces it. DOMRects use physical coordinates. */ +export function quotePosition( + bounds: Rect, + extent: Rect & { short: boolean; backward: boolean }, + size: { width: number; height: number }, + docked: boolean, +) { + const gap = 8 + const pinned = bounds.bottom - size.height - gap + + if (docked) return { top: pinned, left: (bounds.left + bounds.right - size.width) / 2 } + + const above = extent.top - size.height - gap + const below = extent.bottom + gap + const fitsAbove = above >= bounds.top + gap && extent.top <= bounds.bottom + const fitsBelow = below + size.height <= bounds.bottom - gap && extent.bottom >= bounds.top + + const sides = + extent.short || extent.backward + ? [fitsAbove && above, fitsBelow && below] + : [fitsBelow && below, fitsAbove && above] + + return { + top: sides.find((side) => side !== false) ?? pinned, + left: Math.min( + Math.max((extent.left + extent.right - size.width) / 2, bounds.left + gap), + Math.max(bounds.left + gap, bounds.right - size.width - gap), + ), + } +} diff --git a/packages/app/src/composer/quote-source.ts b/packages/app/src/composer/quote-source.ts index d4448d6bed37..44403bbecf1e 100644 --- a/packages/app/src/composer/quote-source.ts +++ b/packages/app/src/composer/quote-source.ts @@ -1,38 +1,39 @@ import { createStore } from "solid-js/store" +import type { ComposerStore, TimelineQuoteSource } from "./schema" -/** Client-only source coordinates; never included in persisted drafts or requests. */ -export type QuoteSource = { - partID: string - revealID: string - body?: string - shadow?: { host: number; line: string } - start: number - end: number - text: string -}[] +/** App-only source coordinates, persisted with drafts but never included in requests. */ +export type QuoteSource = TimelineQuoteSource + +export function createComposerQuoteSource(draft?: { + get: () => ComposerStore["quoteSources"] + set: (key: string, source: QuoteSource | undefined) => void +}) { + const [sources, setSources] = createStore>({}) -export function createComposerQuoteSource() { const [state, setState] = createStore<{ sources: Record - /** The quote being commented for the first time, the only one whose source stays highlighted. */ + /** The quote whose open comment editor keeps its source highlighted. */ pending?: string - reveal?: { key: string; sequence: number } - }>({ sources: {} }) + }>({ + get sources() { + return draft?.get() ?? sources + }, + }) + + const setSource = (key: string, source: QuoteSource | undefined) => + draft ? draft.set(key, source) : setSources(key, source) return { state, - add: (key: string, source: QuoteSource) => setState("sources", key, source), + add: (key: string, source: QuoteSource) => setSource(key, source), pend: (key: string) => setState("pending", key), settle: () => setState("pending", undefined), retain: (keys: string[]) => { Object.keys(state.sources).forEach((key) => { - if (!keys.includes(key)) setState("sources", key, undefined) + if (!keys.includes(key)) setSource(key, undefined) }) if (state.pending && !keys.includes(state.pending)) setState("pending", undefined) }, - reveal: (key: string) => { - if (state.pending === key) setState("reveal", { key, sequence: (state.reveal?.sequence ?? 0) + 1 }) - }, } } diff --git a/packages/app/src/composer/request.test.ts b/packages/app/src/composer/request.test.ts index 7e0f91dc9bf4..1382d66a24e1 100644 --- a/packages/app/src/composer/request.test.ts +++ b/packages/app/src/composer/request.test.ts @@ -4,6 +4,7 @@ import { Session } from "@opencode/schema/session" import type { ImageAttachmentPart, Prompt } from "@/composer/state" import type { FileSelection } from "@/workspaces/files/model" import { buildPromptRequest } from "./request" +import { readPromptPresentation } from "./comment-note" function inline(filename: string, mime: string, extra?: Partial) { return { type: "image" as const, id: `img_${filename}`, filename, mime, dataUrl: `data:${mime};base64,AAA`, ...extra } @@ -158,6 +159,47 @@ describe("buildPromptRequest", () => { expect(result.files).toEqual([{ uri: "file:///repo/src/button.css", mime: "text/plain", name: "button.css" }]) }) + test("sends uncommented live and durable notes beside a global instruction and preserves their metadata", () => { + const notes = [ + { + type: "note" as const, + origin: "example", + label: "Save", + title: "button#save", + subtitle: "Browser annotation", + icon: "select-element", + subject: 'the "Save" element', + href: "example:save", + live: { subject: 'the "Save" element (live reference @e42)', href: "example:save#e42" }, + comment: "", + }, + { + type: "note" as const, + origin: "another-plugin", + label: "Cancel", + icon: "select-element", + subject: 'the "Cancel" element', + comment: "", + }, + ] + + const result = buildPromptRequest({ + prompt: [{ type: "text", content: "Align these targets", start: 0, end: 19 }], + context: notes.map((note, index) => ({ ...note, key: `note:${index}`, commentID: `${index}`, comment: " " })), + images: [], + text: "Align these targets", + sessionDirectory: "/repo", + }) + + expect(result.text).toBe( + 'Align these targets\nThe user referenced the "Save" element (live reference @e42).\nThe user referenced the "Cancel" element.', + ) + expect(result.displayText).toBe("Align these targets") + expect(result.comments).toEqual(notes) + expect(readPromptPresentation({ displayText: result.displayText, comments: result.comments })?.comments).toEqual(notes) + expect(result.files).toEqual([]) + }) + test("sends quotes as prefixed text naming their source, with or without a comment, and attaches only files the comment mentions", () => { const terminal = { label: "Terminal", icon: "terminal", subject: "the terminal" } diff --git a/packages/app/src/composer/request.ts b/packages/app/src/composer/request.ts index c8f1f4eb6e7b..4f7966b70e95 100644 --- a/packages/app/src/composer/request.ts +++ b/packages/app/src/composer/request.ts @@ -82,7 +82,7 @@ const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path" -/** The sent form of a note: its optional link and live subject travel only when present. */ +/** The sent form of a note: optional display fields, link and live subject travel only when present. */ export function noteComment(item: NoteContextItem, comment: string) { const note: NoteComment = { type: "note", @@ -93,6 +93,10 @@ export function noteComment(item: NoteContextItem, comment: string) { comment, } + if (item.title !== undefined) note.title = item.title + + if (item.subtitle !== undefined) note.subtitle = item.subtitle + if (item.href) note.href = item.href if (item.live) note.live = { ...item.live } @@ -159,7 +163,6 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques if (item.type === "note") { const comment = item.comment.trim() - if (!comment) return [] comments.push(noteComment(item, comment)) return mentioned(comment) diff --git a/packages/app/src/composer/schema.test.ts b/packages/app/src/composer/schema.test.ts index 2f82eda0ec56..8c999696db4c 100644 --- a/packages/app/src/composer/schema.test.ts +++ b/packages/app/src/composer/schema.test.ts @@ -80,6 +80,8 @@ describe("composer persistence schemas", () => { type: "note" as const, origin: "example", label: "button#save", + title: "button#save", + subtitle: "Browser annotation", icon: "select-element", subject: 'the "button#save" element', href: "tab_00000000-0000-4000-8000-000000000000", @@ -103,7 +105,19 @@ describe("composer persistence schemas", () => { key: "browser:tab_00000000-0000-4000-8000-000000000000:c=picked", } - const value = decode({ context: { items: [note, { ...note, subject: null }, browser, { ...browser, url: null }] } }) + const value = decode({ + context: { + items: [ + note, + { ...note, subject: null }, + browser, + { ...browser, url: null }, + { ...note, comment: "", commentID: "empty-live" }, + { ...durable, comment: "", commentID: "empty-durable" }, + ], + }, + }) + expect(value.context.items).toEqual([ { ...durable, key: "note:example:c=note" }, { @@ -118,7 +132,10 @@ describe("composer persistence schemas", () => { commentID: "picked", key: "note:browser:c=picked", }, + { ...durable, comment: "", commentID: "empty-live", key: "note:example:c=empty-live" }, + { ...durable, comment: "", commentID: "empty-durable", key: "note:example:c=empty-durable" }, ]) + expect(decode(Schema.encodeSync(ComposerStore)(value))).toEqual(value) }) test("drops invalid parts without losing valid mentions or optional field recovery", () => { diff --git a/packages/app/src/composer/schema.ts b/packages/app/src/composer/schema.ts index 9e2512d2340c..017cd9af16be 100644 --- a/packages/app/src/composer/schema.ts +++ b/packages/app/src/composer/schema.ts @@ -168,6 +168,8 @@ const NoteFields = { type: Schema.Literal("note"), origin: Schema.String, label: Schema.String, + title: Persistence.optional(Schema.String), + subtitle: Persistence.optional(Schema.String), icon: Schema.String, subject: Schema.String, href: Persistence.optional(Schema.String), @@ -193,6 +195,21 @@ export const QuoteSource = Persistence.struct({ export type QuoteSource = typeof QuoteSource.Type +/** Timeline coordinates kept only with the app's draft, never in quote metadata. */ +export const TimelineQuoteSource = Persistence.array( + Persistence.struct({ + partID: Schema.String, + revealID: Schema.String, + body: Persistence.optional(Schema.String), + shadow: Persistence.optional(Persistence.struct({ host: Schema.Number, line: Schema.String })), + start: Schema.Number, + end: Schema.Number, + text: Schema.String, + }), +) + +export type TimelineQuoteSource = typeof TimelineQuoteSource.Type + const QuoteFields = { type: Schema.Literal("quote"), text: Schema.String, @@ -359,6 +376,7 @@ export const ComposerStore = Persistence.struct({ }), ), context: Persistence.struct({ items: Persistence.array(ContextEntry) }), + quoteSources: Persistence.optional(Persistence.record(Schema.UndefinedOr(TimelineQuoteSource))), }) export type ComposerStore = typeof ComposerStore.Type diff --git a/packages/app/src/composer/state.ts b/packages/app/src/composer/state.ts index 7aad582b96e7..13fdf1669da8 100644 --- a/packages/app/src/composer/state.ts +++ b/packages/app/src/composer/state.ts @@ -1,4 +1,5 @@ -import { batch, createSignal, untrack, type Accessor } from "solid-js" +import { batch, untrack, type Accessor } from "solid-js" +import type { ComposerQuoteAnchor } from "@opencode/plugin/gui" import { createStore, type SetStoreFunction } from "solid-js/store" import { Persist, persisted } from "@/runtime/persistence/storage" import { ServerScope } from "@/runtime/server/scope" @@ -44,7 +45,7 @@ type InitialPrompt = { } export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { - return item.type === "quote" || !!item.comment?.trim() + return item.type === "note" || item.type === "quote" || !!item.comment?.trim() } function createComposerActions(setStore: SetStoreFunction) { @@ -115,7 +116,8 @@ export interface ComposerStateValue { } readonly quoteComment: { readonly key: Accessor - readonly set: (key?: string) => void + readonly anchor: Accessor + readonly set: (key?: string, anchor?: ComposerQuoteAnchor) => void } readonly quoteSource: ReturnType readonly set: (prompt: Prompt, cursorPosition?: number) => void @@ -123,14 +125,15 @@ export interface ComposerStateValue { readonly capture: () => ComposerStateValue } -function createComposerStateValue( - store: ComposerStore, - setStore: SetStoreFunction, -): ComposerStateValue { +function createComposerStateValue(store: ComposerStore, setStore: SetStoreFunction): ComposerStateValue { const actions = createComposerActions(setStore) // The quote whose comment editor is open, by key; a request made before the composer shows waits for it. - const [quoteComment, setQuoteComment] = createSignal() - const quoteSource = createComposerQuoteSource() + const [quoteComment, setQuoteComment] = createStore<{ key?: string; anchor?: ComposerQuoteAnchor }>({}) + + const quoteSource = createComposerQuoteSource({ + get: () => store.quoteSources, + set: (key, source) => setStore("quoteSources", (sources) => ({ ...sources, [key]: source })), + }) const retainQuoteSources = () => quoteSource.retain(store.context.items.map((item) => item.key)) @@ -219,11 +222,18 @@ function createComposerStateValue( }, }, quoteComment: { - key: quoteComment, - set: (key?: string) => { - setQuoteComment(key) + key: () => quoteComment.key, + anchor: () => quoteComment.anchor, + set: (key?: string, anchor?: ComposerQuoteAnchor) => { + setQuoteComment({ key, anchor: key ? anchor : undefined }) + + if (key && quoteSource.state.sources[key]) { + quoteSource.pend(key) + + return + } - if (key !== quoteSource.state.pending) quoteSource.settle() + quoteSource.settle() }, }, quoteSource, diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index 78d992ea37e7..0d0caa00ae36 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -250,6 +250,60 @@ describe("Composer submission", () => { expect(state.context.items()).toEqual([]) }) + test.each(["", "Align these targets"])("hands off and admits uncommented notes with instruction %j", async (text) => { + const draft = createMemoryComposerState({ prompt: text }).capture() + + const notes = [ + { + type: "note" as const, + origin: "example", + label: "Save", + title: "button#save", + subtitle: "Browser annotation", + icon: "select-element", + subject: 'the "Save" element', + href: "example:save", + live: { subject: 'the "Save" element (live reference @e42)', href: "example:save#e42" }, + comment: "", + }, + { + type: "note" as const, + origin: "another-plugin", + label: "Cancel", + icon: "select-element", + subject: 'the "Cancel" element', + comment: "", + }, + ] + + notes.forEach((note, index) => draft.context.add({ ...note, commentID: `${index}` })) + const handoffs: SessionMessageHandoff[] = [] + const first: Parameters[2][] = [] + const admitted = Promise.withResolvers[0]>() + const calls: string[] = [] + + const target = session({ + calls, + handoff: { set: (value) => handoffs.push(value), clear() {} }, + prompt: async (value) => admitted.resolve(value), + }) + + await submitInput( + fresh(draft, async (_selection, _submission, message) => { + first.push(message) + + return { session: target, cleanupReady: Promise.resolve() } + }), + ).submit(new Event("submit")) + + expect(calls).toContain("admit") + const request = await admitted.promise + expect(request.metadata).toMatchObject({ displayText: text, comments: notes }) + expect(first).toMatchObject([{ text, metadata: { displayText: text, comments: notes } }]) + expect(handoffs).toMatchObject([{ message: { text, metadata: { displayText: text, comments: notes } } }]) + expect(draft.context.items()).toEqual([]) + }) + test.each(["steer", "queue"] as const)( "shows a %s in the session as the composer clears, before its selection commits", async (delivery) => { @@ -401,6 +455,21 @@ describe("Composer submission", () => { state.set(prompt) state.context.add({ type: "file", path: "notes.md", description: "the failing version" }) + + const note = { + type: "note" as const, + origin: "example", + label: "Target", + title: "#target", + subtitle: "Browser annotation", + icon: "select-element", + subject: "the target", + live: { subject: "the live target" }, + comment: "", + commentID: "target", + } + + state.context.add(note) const attempts: string[] = [] const calls: string[] = [] const statuses: ("idle" | "running")[] = [] @@ -438,6 +507,7 @@ describe("Composer submission", () => { expect(statuses).toEqual(["running", "idle", "running", "idle"]) expect(state.current()).toEqual(prompt) expect(state.context.items()).toMatchObject([ + note, { type: "file", path: "notes.md", description: "the failing version" }, ]) // The caret returns after the mention text; attachments take no caret positions. diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 8000f0caa3db..05398ad6d78e 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -288,12 +288,12 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser { ), comments: value.context.flatMap((item): PromptComment[] => { if (item.type === "quote") return [quoteComment(item)] + + if (item.type === "note") return [noteComment(item, item.comment.trim())] const comment = item.comment?.trim() if (!comment) return [] - if (item.type === "note") return [noteComment(item, comment)] - return [ { path: item.path, @@ -433,6 +433,8 @@ function restoreSubmission( type: "note", origin: item.origin, label: item.label, + title: item.title, + subtitle: item.subtitle, icon: item.icon, subject: item.subject, href: item.href, diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index eedeb08fd650..ba4da00ea801 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -674,6 +674,10 @@ export const dict = { "session.quote.actions": "Quote selection", "session.quote.add": "Quote", "session.quote.addWithComment": "Quote and comment", + "session.quote.addWithoutComment": "Quote without comment", + "session.quote.optionalCommentPlaceholder": "Add an optional comment...", + "session.quote.done": "Done", + "session.quote.addComment": "Add comment", "session.quote.commentPlaceholder": "Add a comment about this quote", "session.quote.tooLong": "Selection too long", "session.header.open.finder": "Finder", diff --git a/packages/app/src/runtime/plugin/mounted-session.ts b/packages/app/src/runtime/plugin/mounted-session.ts index 58650d42f168..6ae9f24e8d64 100644 --- a/packages/app/src/runtime/plugin/mounted-session.ts +++ b/packages/app/src/runtime/plugin/mounted-session.ts @@ -229,12 +229,12 @@ export function createMountedSession(session: SessionModel) { }, attach: (part) => write((draft) => draft.context.add(part)), quotable: () => !!prompt()?.quotable(), - focus(commentID) { + focus(commentID, options) { if (commentID === undefined) return prompt()?.focus() const item = composer.context.items().find((entry) => entry.type === "quote" && entry.commentID === commentID) - if (item) composer.capture().quoteComment.set(item.key) + if (item) composer.capture().quoteComment.set(item.key, options?.anchor) }, update: (id, patch) => write((draft) => draft.context.updateComment(id, patch)), detach: (id) => write((draft) => draft.context.removeComment(id)), diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index d46c3e309ef4..05ad178ec714 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -23,6 +23,7 @@ import { usePlatform } from "@/runtime/platform/platform" import { uuid } from "@/runtime/persistence/uuid" import { contextItemKey } from "@/composer/schema" import type { QuoteSource } from "@/composer/quote-source" +import type { ComposerQuoteAnchor } from "@opencode/plugin/gui" import { useWorkspaceLocation } from "@/workspaces/location" import { requireServerKey, sessionHref } from "@/shell/routes/session" import { useComposerCommands } from "@/composer/commands" @@ -353,16 +354,16 @@ export function createActiveSessionRegion(input: { source: () => prompt.capture().quoteSource, // A child session has no composer to quote into, and a shell command takes no quotes. available: () => !input.session.data.isChild() && prompt.capture().mode.current() !== "shell", - add: (text: string, comment: boolean, source: QuoteSource) => { + add: (text: string, source: QuoteSource, anchor?: ComposerQuoteAnchor) => { const item = { type: "quote" as const, text, commentID: uuid() } prompt.context.add(item) if (source.length) prompt.capture().quoteSource.add(contextItemKey(item), source) - if (comment) { + if (anchor) { prompt.capture().quoteSource.pend(contextItemKey(item)) - return prompt.capture().quoteComment.set(contextItemKey(item)) + return prompt.capture().quoteComment.set(contextItemKey(item), anchor) } focusPrompt() diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 670536886964..dec2d00a19df 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -256,13 +256,8 @@ function SessionScreenContent(props: { createTimelineQuoteHighlight({ root: timeline.scroller, - visible: timeline.visible, source: composer.quote.source, enabled: () => conversationVisible() && composer.quote.available(), - reveal: (id, partID) => { - if (!timeline.actions.revealPart(partID)) timeline.actions.revealMessage(id, partID) - }, - unpin: timeline.view.unpin, }) const sessionErrorFallback = (cause: unknown, reset: () => void) => { diff --git a/packages/app/src/session/timeline/quote-highlight.ts b/packages/app/src/session/timeline/quote-highlight.ts index 1132d9fbea46..3a062f5b9286 100644 --- a/packages/app/src/session/timeline/quote-highlight.ts +++ b/packages/app/src/session/timeline/quote-highlight.ts @@ -1,4 +1,4 @@ -import { createEffect, on, onCleanup } from "solid-js" +import { createEffect, onCleanup } from "solid-js" import type { QuoteSource, createComposerQuoteSource } from "@/composer/quote-source" import { createQuoteBackground } from "./quote-background" @@ -69,43 +69,23 @@ export function captureQuoteSource(ranges: Range[]): QuoteSource { export function createTimelineQuoteHighlight(input: { root: () => HTMLElement | undefined - visible: () => HTMLElement | undefined source: () => ReturnType enabled: () => boolean - reveal: (id: string, partID: string) => void - unpin: () => void }) { - let pending: { key: string; source: ReturnType } | undefined - - const reveal = () => { + createEffect(() => { const root = input.root() const source = input.source() - const first = pending && pending.source === source && source.state.sources[pending.key]?.[0] + const key = source.state.pending + const pieces = key ? source.state.sources[key] : undefined - if (!first || !root || !input.enabled()) return - const range = resolveQuoteRange(root, first, new Map()) - const rect = range && [...range.getClientRects()].find((rect) => rect.width > 0 && rect.height > 0) + if (!root || !input.enabled() || !key || !pieces?.length) return + const base = typeof Highlight !== "undefined" && CSS.highlights ? new Highlight() : undefined - if (!rect) return - pending = undefined - const viewport = input.visible()?.getBoundingClientRect() ?? root.getBoundingClientRect() - const sticky = root.querySelector("[data-session-title]") - const top = viewport.top + (sticky?.offsetHeight ?? 0) - - if (rect.top >= top && rect.bottom <= viewport.bottom) return - input.unpin() - root.scrollBy({ top: rect.top - top - (viewport.bottom - top) / 2, behavior: "instant" }) - } - - createEffect(() => { - const root = input.root() - const source = input.source() - const pieces = source.state.pending ? source.state.sources[source.state.pending] : undefined + if (base) { + base.priority = -2 + CSS.highlights.set(BASE, base) + } - if (!root || !input.enabled() || !pieces?.length || typeof Highlight === "undefined" || !CSS.highlights) return - const base = new Highlight() - base.priority = -2 - CSS.highlights.set(BASE, base) let frame: number | undefined const background = createQuoteBackground() const observed = new Set() @@ -119,7 +99,7 @@ export function createTimelineQuoteHighlight(input: { } const apply = () => { - base.clear() + base?.clear() observer.disconnect() const shadows = new Set() const containers = new Map>() @@ -132,15 +112,17 @@ export function createTimelineQuoteHighlight(input: { }) if (!range) return - base.add(range) + + base?.add(range) ranges.push(range) }) background.update(ranges) - reveal() observer.observe(root, { childList: true, subtree: true, characterData: true }) observer.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "class", "dir"] }) shadows.forEach((shadow) => observer.observe(shadow, { childList: true, subtree: true, characterData: true })) + const targets = new Set([root, ...containers.keys()]) + observed.forEach((element) => { if (targets.has(element)) return resize.unobserve(element) @@ -156,40 +138,21 @@ export function createTimelineQuoteHighlight(input: { const observer = new MutationObserver(schedule) const resize = new ResizeObserver(schedule) document.fonts.addEventListener("loadingdone", schedule) + window.addEventListener("resize", schedule) apply() onCleanup(() => { observer.disconnect() resize.disconnect() document.fonts.removeEventListener("loadingdone", schedule) + window.removeEventListener("resize", schedule) if (frame !== undefined) cancelAnimationFrame(frame) background.clear() - CSS.highlights.delete(BASE) + + if (base) CSS.highlights.delete(BASE) }) }) - - createEffect( - on( - () => [input.source(), input.source().state.reveal?.sequence] as const, - ([current], previous) => { - const source = input.source() - const key = source.state.reveal?.key - const first = key && source.state.sources[key]?.[0] - const root = input.root() - - pending = undefined - - if (current !== previous?.[0] || !first || !root || !input.enabled()) return - pending = { key, source } - reveal() - - if (!pending) return - input.unpin() - input.reveal(first.revealID, first.partID) - }, - ), - ) } function resolveQuoteRange( diff --git a/packages/app/src/session/timeline/quote.tsx b/packages/app/src/session/timeline/quote.tsx index 69102db4fd4d..84accd639d15 100644 --- a/packages/app/src/session/timeline/quote.tsx +++ b/packages/app/src/session/timeline/quote.tsx @@ -7,8 +7,10 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { Button } from "@opencode/ui/button" import { Keybind } from "@opencode/ui/keybind" import { Tooltip } from "@opencode/ui/tooltip" +import type { ComposerQuoteAnchor } from "@opencode/plugin/gui" import { useLanguage } from "@/runtime/i18n/language" import type { QuoteSource } from "@/composer/quote-source" +import { quotePosition } from "@/composer/quote-position" import { captureQuoteSource } from "./quote-highlight" import { characterRect, @@ -25,8 +27,6 @@ const SETTLE_MS = 150 const SHORT_LINES = 3 -const GAP = 8 - type Rect = { top: number; bottom: number; left: number; right: number } type Extent = Rect & { short: boolean; backward: boolean } @@ -36,14 +36,15 @@ export function createTimelineQuote(input: { root: () => HTMLElement | undefined sessionKey: () => string enabled: () => boolean - add: (text: string, comment: boolean, source: QuoteSource) => void + add: (text: string, source: QuoteSource, anchor?: ComposerQuoteAnchor) => void }) { const [state, setState] = createStore<{ text: string | undefined truncated: boolean extent: Extent | undefined bounds: Rect | undefined - }>({ text: undefined, truncated: false, extent: undefined, bounds: undefined }) + layout: number + }>({ text: undefined, truncated: false, extent: undefined, bounds: undefined, layout: 0 }) let selected: TimelineSelection | undefined let horizontal: { left: number; right: number; origin: number } | undefined @@ -79,48 +80,16 @@ export function createTimelineQuote(input: { const root = input.root() if (!root || !selected) return - const view = root.getBoundingClientRect() - const sticky = root.querySelector("[data-session-title]") - - const bounds = { - top: view.top + (sticky instanceof HTMLElement ? sticky.offsetHeight : 0), - bottom: view.bottom, - left: view.left, - right: view.right, - } // The virtualizer removed the selected rows. if (selected.range.collapsed) return close() - - const rects = selectionRects(selected) - const first = characterRect(selected.start, false) - const last = characterRect(selected.end, true) - const top = first?.top ?? Math.min(...rects.map((rect) => rect.top)) - const bottom = last?.bottom ?? Math.max(...rects.map((rect) => rect.bottom)) - - const visible = rects.filter( - (rect) => rect.bottom > Math.max(bounds.top, top) && rect.top < Math.min(bounds.bottom, bottom), - ) - - const line = - first?.height ?? rects.map((rect) => rect.height).sort((a, b) => a - b)[Math.floor(rects.length / 2)] ?? 0 - + const measured = measureQuote(root, selected, horizontal) setState({ - bounds, - extent: visible.length - ? { - top, - bottom, - left: horizontal - ? horizontal.left + view.left - horizontal.origin - : Math.min(...visible.map((rect) => rect.left)), - right: horizontal - ? horizontal.right + view.left - horizontal.origin - : Math.max(...visible.map((rect) => rect.right)), - short: bottom - top < line * (SHORT_LINES + 0.5), - backward: selected.backward, - } - : undefined, + bounds: measured?.bounds, + extent: + measured && measured.extent.bottom > measured.bounds.top && measured.extent.top < measured.bounds.bottom + ? measured.extent + : undefined, }) } @@ -268,6 +237,8 @@ export function createTimelineQuote(input: { // A resized timeline, such as when a side panel opens, reflows the selected text. createResizeObserver(input.root, () => { + setState("layout", (value) => value + 1) + if (!selected || reflow !== undefined) return reflow = requestAnimationFrame(() => { reflow = undefined @@ -289,15 +260,58 @@ export function createTimelineQuote(input: { return { state, - // With Alt held, the quote opens its comment editor in the composer. - add: (comment: boolean) => { + // Alt skips the optional comment. Otherwise the editor replaces the selection toolbar. + add: (skip: boolean) => { const text = state.text if (!text || state.truncated) return - const source = selected ? captureQuoteSource(quoteText(selected).ranges) : [] + const ranges = selected ? quoteText(selected).ranges : [] + const source = captureQuoteSource(ranges) + const selection = selected + const root = input.root() + const sessionKey = input.sessionKey() + let horizontalBounds = horizontal + let layout = state.layout + const initial = root && selection ? measureQuote(root, selection, horizontalBounds) : undefined + let last = initial + + const anchor: ComposerQuoteAnchor = (size) => { + // Keep the source anchor reactive when a divider resizes the timeline after selection is cleared. + void state.layout + + if (!root?.isConnected || input.sessionKey() !== sessionKey || !input.enabled()) return + + if (layout !== state.layout) { + // Only quoted glyphs determine the width; the enclosing selection also includes block boxes and chrome. + const rects = ranges + .flatMap((range) => (range.startContainer.isConnected ? [...range.getClientRects()] : [])) + .filter((rect) => rect.width > 0 && rect.height > 0) + + horizontalBounds = rects.length + ? { + left: Math.min(...rects.map((rect) => rect.left)), + right: Math.max(...rects.map((rect) => rect.right)), + origin: root.getBoundingClientRect().left, + } + : undefined + layout = state.layout + } + + const measured = selection && measureQuote(root, selection, horizontalBounds) + last = measured ?? last + + if (!last) return + const bounds = measured?.bounds ?? root.getBoundingClientRect() + const extent = measured?.extent ?? { ...last.extent, left: bounds.left, right: bounds.right } + const position = quotePosition(bounds, extent, size, window.matchMedia("(hover: none)").matches) + + // A virtualized source still belongs to the quote. Keep its draft open, pinned at the timeline edge. + return measured ? position : { ...position, top: bounds.bottom - size.height - 8 } + } + window.getSelection()?.removeAllRanges() close() - input.add(text, comment, source) + input.add(text, source, skip ? undefined : anchor) }, setElement: (value: HTMLElement | undefined) => { element = value @@ -320,28 +334,7 @@ export function TimelineQuote(props: { controller: TimelineQuoteController }) { if (!bounds || !extent) return - const pinned = bounds.bottom - size.height - GAP - - if (docked()) return { top: pinned, left: (bounds.left + bounds.right - size.width) / 2 } - - const above = extent.top - size.height - GAP - const below = extent.bottom + GAP - const fitsAbove = above >= bounds.top + GAP && extent.top <= bounds.bottom - const fitsBelow = below + size.height <= bounds.bottom - GAP && extent.bottom >= bounds.top - - // Short selections get it above; taller ones at the end they were dragged towards. - const sides = - extent.short || extent.backward - ? [fitsAbove && above, fitsBelow && below] - : [fitsBelow && below, fitsAbove && above] - - const top = sides.find((side) => side !== false) ?? pinned - const center = (extent.left + extent.right - size.width) / 2 - - return { - top, - left: Math.min(Math.max(center, bounds.left + GAP), bounds.right - size.width - GAP), - } + return quotePosition(bounds, extent, size, docked()) } return ( @@ -374,7 +367,7 @@ export function TimelineQuote(props: { controller: TimelineQuoteController }) { - {language.t("session.quote.addWithComment")} + {language.t("session.quote.addWithoutComment")} } @@ -398,6 +391,58 @@ export function TimelineQuote(props: { controller: TimelineQuoteController }) { ) } +function measureQuote( + root: HTMLElement, + selected: TimelineSelection, + horizontal?: { left: number; right: number; origin: number }, +) { + if (selected.range.collapsed || !selected.start.node.isConnected || !selected.end.node.isConnected) return + const view = root.getBoundingClientRect() + + if (!view.height || !view.width) return + const sticky = root.querySelector("[data-session-title]") + + const bounds = { + top: view.top + (sticky?.offsetHeight ?? 0), + bottom: view.bottom, + left: view.left, + right: view.right, + } + + const rects = selectionRects(selected) + + if (!rects.length) return + const first = characterRect(selected.start, false) + const last = characterRect(selected.end, true) + const top = first?.top ?? Math.min(...rects.map((rect) => rect.top)) + const bottom = last?.bottom ?? Math.max(...rects.map((rect) => rect.bottom)) + + const visible = rects.filter( + (rect) => rect.bottom > Math.max(bounds.top, top) && rect.top < Math.min(bounds.bottom, bottom), + ) + + const line = + first?.height ?? rects.map((rect) => rect.height).sort((a, b) => a - b)[Math.floor(rects.length / 2)] ?? 0 + + const horizontalRects = visible.length ? visible : rects + + return { + bounds, + extent: { + top, + bottom, + left: horizontal + ? horizontal.left + view.left - horizontal.origin + : Math.min(...horizontalRects.map((rect) => rect.left)), + right: horizontal + ? horizontal.right + view.left - horizontal.origin + : Math.max(...horizontalRects.map((rect) => rect.right)), + short: bottom - top < line * (SHORT_LINES + 0.5), + backward: selected.backward, + }, + } +} + function sameRange(a: Range, b: Range) { return a.compareBoundaryPoints(Range.START_TO_START, b) === 0 && a.compareBoundaryPoints(Range.END_TO_END, b) === 0 } diff --git a/packages/gui-plugins/README.md b/packages/gui-plugins/README.md index 003d79c1c99f..3b4741fbd5da 100644 --- a/packages/gui-plugins/README.md +++ b/packages/gui-plugins/README.md @@ -159,6 +159,32 @@ ctx.add(Command, () => ({ })) ``` +### Referencing a subject + +Attach a `type: "note"` part to reference a subject outside workspace lines. Set `comment: ""` to attach only the +subject: the model reads "The user referenced ." With a comment it reads "The user made the following +comment regarding : " instead. + +For an uncommented note, optional `title` supplies the primary heading. Optional `subtitle` supplies a plain-text +footer beside the icon, with or without a comment. Each defaults to `label` when omitted. For example, the browser +uses a selector as `title` and its localized annotation label as `subtitle`; a comment replaces only the heading. +These display fields do not change `subject`. Both survive draft storage, sent-message metadata and restoration. +Only the process-bound `live` reference is dropped when a stored draft or sent message is restored. + +### Quoting selected content + +Attach a `type: "quote"` part with a `commentID`, then call `screen.composer.focus(commentID, { anchor })` to open its +optional comment beside the source. `anchor(size)` returns the editor's physical viewport `{ top, left }` for its +current `width` and frozen opening `height`. It is first called after measurement (never with an unmeasured zero +height), then again on scroll, resize, and changes to reactive values it reads. The editor holds its opening bottom +edge as its input grows upward. Return `undefined` when the source is hidden or gone to fall back to the quote's +composer tile. The anchor is client-only and never persisted. Omitting the options +anchors the editor to the tile, as clicking that tile does. To quote without opening a comment, call +`screen.composer.focus()` instead; the built-in selection toolbars offer this through Alt-click. + +The built-in terminal follows manual scrolling until new output or a grid resize invalidates the captured source. +It then falls back to the quote's composer tile without closing the editor or discarding its unsaved comment. + ## Primitives | Primitive | Use it for | diff --git a/packages/gui-plugins/src/browser/comment.ts b/packages/gui-plugins/src/browser/comment.ts index 4900379fd89d..d4d53e0326f3 100644 --- a/packages/gui-plugins/src/browser/comment.ts +++ b/packages/gui-plugins/src/browser/comment.ts @@ -9,6 +9,7 @@ export type ElementComment = { tabID: Browser.TabID url: string element: Omit & { ref?: Browser.Ref } + subtitle?: string comment: string } @@ -31,6 +32,8 @@ export function commentNote(input: ElementComment): ComposerNote { origin: input.origin, commentID: crypto.randomUUID(), label: input.element.label, + title: input.element.selector || input.element.label, + subtitle: input.subtitle, icon: "select-element", subject: subject(input), comment: input.comment, diff --git a/packages/gui-plugins/src/browser/i18n/en.ts b/packages/gui-plugins/src/browser/i18n/en.ts index ed86aa1076d0..65fd4d1bcc57 100644 --- a/packages/gui-plugins/src/browser/i18n/en.ts +++ b/packages/gui-plugins/src/browser/i18n/en.ts @@ -15,6 +15,12 @@ export default { "refused.credentials": "Addresses with a user name or password can't open here.", inspect: "Select an element to comment on", "inspect.active": "Click an element in the page to comment on it. Press Escape to cancel.", + "comment.label": "Comment on this element", + "comment.placeholder": "Add an optional comment...", + "comment.cancel": "Cancel element comment", + "comment.submit": "Add element comment", + "comment.attach": "Add element", + "comment.annotation": "Browser annotation", "action.stop": "Stop", replaced: "Browser control moved to another desktop window.", unsupported: "This desktop app does not support the browser pane.", diff --git a/packages/gui-plugins/src/browser/panel.fixture.tsx b/packages/gui-plugins/src/browser/panel.fixture.tsx index 8d62b255a8ee..00def65b0358 100644 --- a/packages/gui-plugins/src/browser/panel.fixture.tsx +++ b/packages/gui-plugins/src/browser/panel.fixture.tsx @@ -98,6 +98,7 @@ type PaneFixtureState = { external: string[] /** Cookies the page's address can read. */ cookies: number + selection: string } // A 16px blue square, as main reports a page icon. @@ -155,6 +156,7 @@ export function mountBrowserPane(input: PaneHost) { history: { visits: [] }, external: [], cookies: 3, + selection: "default", }) // Each capture waits until the fixture releases it, so a spec can observe the pending state. @@ -209,7 +211,7 @@ export function mountBrowserPane(input: PaneHost) { // SAFETY: the host embeds call only `embed`, `capture` and `on` on their bridge (`runtime/plugin/embeds.tsx`). const embeds = input.createEmbeds({ bridge: bridge as Bridge, - zoom: () => 1, + zoom: () => (store.selection === "zoomed" ? 1.25 : 1), dialog: () => store.dialog, moving: () => store.moving, }) @@ -222,7 +224,7 @@ export function mountBrowserPane(input: PaneHost) { const base = { id: "browser", keybinds: { keybind: () => [], keys: (bind: string) => bind.split("+") }, - desktop: { zoom: () => 1 }, + desktop: { zoom: () => (store.selection === "zoomed" ? 1.25 : 1) }, embeds, t, // English plural forms, as the host picks them. @@ -413,6 +415,15 @@ export function mountBrowserPane(input: PaneHost) { const model = createModel(plugin) const pick = () => { + const selector = () => { + if (store.selection === "fallback") return "" + + if (store.selection === "long") + return `main > section[data-label="إعدادات"] > ${".nested-selector".repeat(24)} > button.primary` + + return "main > button.primary" + } + setStore("picker", store.session, false) emit(store.session, { type: "inspect", @@ -420,11 +431,11 @@ export function mountBrowserPane(input: PaneHost) { active: false, element: { ref: Browser.Ref.make("e7"), - selector: "main > button.primary", + selector: selector(), label: "button.primary", role: "button", name: "Save changes", - rect: { x: 48, y: 40, width: 160, height: 36 }, + rect: { x: 48, y: store.selection === "low" ? 180 : 40, width: 160, height: 36 }, }, }) } @@ -513,6 +524,14 @@ export function mountBrowserPane(input: PaneHost) { + + {container} + + true, + add: (text, anchor) => setState({ text, anchor }), + }} + /> + + + ) + } + + return ( + + + + {source() ? "Source" : "Tile"} + {state.text} + + + + + + ) + }, host) + + return () => { + dispose() + host.remove() + } +} + +function fixturePlugin() { + // SAFETY: the terminal and quote toolbar read only these plugin members while they mount and focus. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above + return { + t: (key: string) => key, + appearance: { font: () => "monospace" }, + keybinds: { matches: () => false }, + system: { openExternal() {} }, + } as unknown as Context +} diff --git a/packages/gui-plugins/src/terminal/terminal.tsx b/packages/gui-plugins/src/terminal/terminal.tsx index f559a8d7b61d..a467533e6201 100644 --- a/packages/gui-plugins/src/terminal/terminal.tsx +++ b/packages/gui-plugins/src/terminal/terminal.tsx @@ -249,6 +249,7 @@ export const Terminal = (props: TerminalProps) => { let revealed = false // The opened terminal, for the quote toolbar. const [opened, setOpened] = createSignal() + const [written, setWritten] = createSignal(0) const reveal = () => { if (revealed) return @@ -465,6 +466,7 @@ export const Terminal = (props: TerminalProps) => { setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light") output = terminalWriter((data, done) => t.write(data, () => { + setWritten((value) => value + 1) done?.() }), ) @@ -829,7 +831,7 @@ export const Terminal = (props: TerminalProps) => { {(target) => ( - {(term) => } + {(term) => } )} diff --git a/packages/plugin/src/gui/host-apis.ts b/packages/plugin/src/gui/host-apis.ts index f40c5449c3c5..f22fa830ed9b 100644 --- a/packages/plugin/src/gui/host-apis.ts +++ b/packages/plugin/src/gui/host-apis.ts @@ -469,7 +469,8 @@ export interface ComposerFile { } /** - * A comment on something other than workspace lines, such as an element picked in a page. The model reads + * A reference to something other than workspace lines, such as an element picked in a page, with an optional comment. + * The model reads "The user referenced ." when `comment` is empty, otherwise * "The user made the following comment regarding : ". */ export interface ComposerNote { @@ -481,11 +482,15 @@ export interface ComposerNote { commentID: string /** Chip text naming the subject, e.g. `button#save`. */ label: string + /** Primary heading while `comment` is empty, e.g. `button#save`. Defaults to `label`; ignored with a comment. */ + title?: string + /** Plain-text footer with or without a comment, e.g. `Browser annotation`. Defaults to `label` when omitted. */ + subtitle?: string /** Chip icon. */ icon: IconName /** What the comment is about, for the model. Quote untrusted text such as page content. */ subject: string - /** The user's comment. */ + /** The user's comment. An empty string attaches the subject without a comment. */ comment: string /** The link opening the chip routes, with `origin`. */ href?: string @@ -526,6 +531,36 @@ export interface ComposerQuote { } } +/** + * Positions a quote's optional comment editor beside its source, in viewport pixels. Called after the first editor + * measurement, on scroll and resize, and when reactive values it reads change. The editor holds its opening bottom + * edge as its input grows upward. Return undefined when the source is unavailable to use the composer tile instead. + * Never persisted. + * + * @param size - The current editor width and its frozen opening height; never called with an unmeasured zero height. + * @example + * const anchor: ComposerQuoteAnchor = (size) => ({ top: 200, left: 400 - size.width / 2 }) + */ +export type ComposerQuoteAnchor = (size: { + /** Current editor width in pixels. */ + width: number + /** Measured opening height in pixels, unchanged as the input grows. */ + height: number +}) => + | { + /** Physical distance from the viewport's top edge, in pixels. Clamped to the viewport. */ + top: number + /** Physical distance from the viewport's left edge, in pixels. Clamped to the viewport. */ + left: number + } + | undefined + +/** Optional placement for a newly attached quote's comment editor. */ +export interface ComposerFocusOptions { + /** Positions the editor beside its source instead of its tile. Omit for tile placement. */ + anchor?: ComposerQuoteAnchor +} + /** A model the composer sends a prompt with, as the model picker names it. */ export interface ComposerModel { /** The provider's id, e.g. `anthropic`. */ @@ -616,8 +651,9 @@ export interface Composer { * its focus and the comment editor opens once the composer shows. * * @param commentID - The attached quote whose comment editor to open. Omit it to focus the prompt. + * @param options - Optional placement beside the quote's source. Omit to anchor to its composer tile. */ - focus(commentID?: string): void + focus(commentID?: string, options?: ComposerFocusOptions): void /** * Changes an attached part. Files, notes and quotes alike; a note or a quote takes only `comment`. Before the * session's draft loads, it waits and applies in call order. diff --git a/packages/session-ui/component-tests/session-message-projection.spec.ts b/packages/session-ui/component-tests/session-message-projection.spec.ts index 0666cf34b3e6..28c7fc6211e3 100644 --- a/packages/session-ui/component-tests/session-message-projection.spec.ts +++ b/packages/session-ui/component-tests/session-message-projection.spec.ts @@ -308,6 +308,74 @@ story("renders user image, file attachment, file reference, and agent reference" expect(bodyBox!.y - (attachmentsBox!.y + attachmentsBox!.height)).toBe(8) }) +story("shows note selectors and keeps annotation subtitles with or without comments", async ({ mount, page }) => { + const gallery = await mount("current-session-timeline-rows--note-attachments") + + const cases = [ + { name: "Named", heading: "button#save", secondary: "Browser annotation" }, + { name: "Unnamed", heading: "button.icon-only", secondary: "Browser annotation" }, + { name: "Legacy", heading: "Legacy element", secondary: "Legacy element" }, + { name: "Whitespace", heading: "Fallback element", secondary: "Fallback element" }, + { name: "Title only", heading: "button#save", secondary: "Original label" }, + { name: "Commented", heading: "Fix this", secondary: "Browser annotation" }, + { name: "RTL subtitle", heading: "button#save", secondary: "تعليق المتصفح" }, + { name: "RTL legacy", heading: "العنصر الأصلي", secondary: "العنصر الأصلي" }, + { + name: "Long selector", + heading: 'main > section.settings-panel > form > button[data-action="save-all-account-preferences"]', + secondary: "Browser annotation", + }, + ] + + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + + for (const item of cases) { + const section = gallery.getByRole("region", { name: item.name, exact: true }) + const card = section.getByRole("button") + const heading = card.locator('[data-slot="attachment-card-title"]') + const label = card.locator('[data-slot="attachment-card-label"]') + await expect(heading).toHaveText(item.heading) + await expect(label).toHaveText(item.secondary) + } + + const long = gallery.getByRole("region", { name: "Long selector", exact: true }).getByRole("button") + await expect(long).toHaveCSS("width", "160px") + await expect(long).toHaveCSS("height", "52px") + await expect(long.locator('[data-slot="attachment-card-title"]')).toHaveCSS("white-space", "nowrap") + await expect(long.locator('[data-slot="attachment-card-title"]')).toHaveCSS("text-overflow", "ellipsis") + const rtl = gallery.getByRole("region", { name: "RTL subtitle", exact: true }).getByRole("button") + const label = rtl.locator('[data-slot="attachment-card-label"]') + await expect(label).toHaveCSS("white-space", "nowrap") + await expect(label).toHaveCSS("text-overflow", "ellipsis") + await expect(label).toHaveAttribute("dir", "ltr") + await expect(label).toHaveCSS("unicode-bidi", "isolate") + expect( + await long + .locator('[data-slot="attachment-card-title"]') + .evaluate((element) => element.scrollWidth > element.clientWidth), + ).toBe(true) + + const named = gallery.getByRole("region", { name: "Named", exact: true }).getByRole("button") + await page.clock.install() + await named.hover() + await page.clock.runFor(1000) + await expect(page.getByRole("tooltip")).toHaveText("button#save\nBrowser annotation") + await long.hover() + await page.clock.runFor(1000) + await expect(page.getByRole("tooltip", { name: /^main > section/ })).toHaveText( + 'main > section.settings-panel > form > button[data-action="save-all-account-preferences"]\nBrowser annotation', + ) + await page.clock.resume() + await named.click() + await expect(gallery.getByLabel("Selected attachment")).toHaveText("Named") + await rtl.focus() + await page.keyboard.press("Enter") + await expect(gallery.getByLabel("Selected attachment")).toHaveText("RTL subtitle") + await named.focus() + await page.keyboard.press("Space") + await expect(gallery.getByLabel("Selected attachment")).toHaveText("Named") +}) + story("links URLs in user messages without swallowing punctuation or mentions", async ({ mount }) => { const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "links" } }) const links = timeline.locator('[data-slot="user-message-text"] a[data-slot="user-message-link"]') diff --git a/packages/session-ui/src/actions.ts b/packages/session-ui/src/actions.ts index 7771ba7ba225..aa34f8b3d6a1 100644 --- a/packages/session-ui/src/actions.ts +++ b/packages/session-ui/src/actions.ts @@ -15,6 +15,8 @@ export type SessionUserComment = type: "note" comment: string label: string + title?: string + subtitle?: string icon: string } | { diff --git a/packages/session-ui/src/message/comment-card.tsx b/packages/session-ui/src/message/comment-card.tsx index 733956b31c6b..87e126bda4a3 100644 --- a/packages/session-ui/src/message/comment-card.tsx +++ b/packages/session-ui/src/message/comment-card.tsx @@ -12,16 +12,17 @@ import { AttachmentCard } from "./attachment-card" */ export type CommentCardTarget = | { type: "file"; path: string; selection?: { startLine: number; endLine: number } } - | { type: "note"; label: string; icon: string } + | { type: "note"; label: string; icon: string; title?: string; subtitle?: string } | { type: "quote"; text: string; source?: { label: string; icon: string } } export function commentCardTarget( comment: | { type?: "file"; path: string; selection?: { startLine: number; endLine: number } } - | { type: "note"; label: string; icon: string } + | { type: "note"; label: string; icon: string; title?: string; subtitle?: string } | { type: "quote"; text: string; source?: { label: string; icon: string } }, ): CommentCardTarget { - if (comment.type === "note") return { type: "note", label: comment.label, icon: comment.icon } + if (comment.type === "note") + return { type: "note", label: comment.label, icon: comment.icon, title: comment.title, subtitle: comment.subtitle } if (comment.type === "quote") return { type: "quote", text: comment.text, source: comment.source } @@ -52,8 +53,21 @@ export function CommentCard(props: { const [truncated, setTruncated] = createSignal(false) const quote = () => (props.target.type === "quote" ? props.target.text.slice(0, QUOTE_PREVIEW_LENGTH) : undefined) const source = () => (props.target.type === "quote" ? props.target.source : undefined) - const heading = () => props.comment || (quote() ?? "") - const tooltipDisabled = () => !props.tooltip || (!truncated() && !(props.target.type === "quote" && props.comment)) + const note = () => (props.target.type === "note" ? props.target : undefined) + + const subtitle = () => note()?.subtitle?.trim() || note()?.label + + const heading = () => { + const target = note() + + if (target) return props.comment.trim() ? props.comment : target.title?.trim() || target.label + + return props.comment || (quote() ?? "") + } + + const tooltipDisabled = () => + !props.tooltip || + (!truncated() && !(note() && !props.comment.trim()) && !(props.target.type === "quote" && props.comment)) onMount(() => { const element = title @@ -72,7 +86,7 @@ export function CommentCard(props: { - {note().label} + {subtitle()} )} diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index d6f4caa75159..6c0dbe28679a 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -290,7 +290,7 @@ function UserMessageComments(props: { comments: SessionUserComment[]; bounded: b diff --git a/packages/session-ui/src/timeline/timeline-row.stories.tsx b/packages/session-ui/src/timeline/timeline-row.stories.tsx index 053e09618072..e02f15724730 100644 --- a/packages/session-ui/src/timeline/timeline-row.stories.tsx +++ b/packages/session-ui/src/timeline/timeline-row.stories.tsx @@ -1,8 +1,9 @@ import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode/client/promise" -import { createMemo, onCleanup, onMount } from "solid-js" +import { createMemo, For, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import type { SessionDocument } from "../document" import { SessionTimeline } from "./session-timeline" +import { CommentCard, commentCardTarget } from "../message/comment-card" import type { ReasoningMode } from "./projection" import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story" import { @@ -544,6 +545,54 @@ export const PromptWithAttachments = { ), } +export const NoteAttachments = { + render: () => { + const [state, setState] = createStore({ selected: "None" }) + + const notes = [ + { name: "Named", label: "button#save", title: "button#save", subtitle: "Browser annotation" }, + { name: "Unnamed", label: "button", title: "button.icon-only", subtitle: "Browser annotation", comment: " \t " }, + { name: "Legacy", label: "Legacy element" }, + { name: "Whitespace", label: "Fallback element", title: " \n ", subtitle: " \t " }, + { name: "Title only", label: "Original label", title: "button#save" }, + { + name: "Commented", + label: "Original selector", + title: "Ignored title", + subtitle: "Browser annotation", + comment: "Fix this", + }, + { name: "RTL subtitle", label: "زر الحفظ", title: "button#save", subtitle: "تعليق المتصفح", rtl: true }, + { name: "RTL legacy", label: "العنصر الأصلي", rtl: true }, + { + name: "Long selector", + label: 'main > section.settings-panel > form > button[data-action="save-all-account-preferences"]', + title: 'main > section.settings-panel > form > button[data-action="save-all-account-preferences"]', + subtitle: "Browser annotation", + }, + ] + + return ( +
+ + {(note) => ( +
+

{note.name}

+ setState("selected", note.name)} + /> +
+ )} +
+ {state.selected} +
+ ) + }, +} + export const VoiceMessages = { render: () => ( span { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: var(--line-comment-preview-lines, 2); + overflow-wrap: anywhere; + } + } + + [data-slot="line-comment-v2-input"] { + display: flex; + align-items: flex-end; + gap: 12px; + width: 100%; + min-width: 0; + min-height: var(--line-height-base); + flex: 1 1 auto; + } + + [data-slot="line-comment-v2-field"] { + flex: 1 1 auto; + width: auto; + min-height: 0; + align-self: stretch; + } + + [data-slot="line-comment-v2-textarea"] { + field-sizing: content; + height: auto; + flex: 1 1 auto; + min-height: var(--line-height-base); + max-height: 160px; + overflow-y: auto; + scrollbar-gutter: stable; + line-height: var(--line-height-base); + + &::placeholder { + color: var(--v2-text-text-faint); + } + } + + [data-slot="line-comment-v2-submit"] { + position: relative; + flex-shrink: 0; + + @media (hover: none) { + &::after { + content: ""; + position: absolute; + inset: -12px; + } + } + } + + [data-slot="line-comment-v2-submit-icon"] { + width: 16px; + height: 16px; + background: currentColor; + } +} + [data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-list"] { display: flex; flex-direction: column; diff --git a/packages/ui/src/data-display/line-comment/line-comment.stories.tsx b/packages/ui/src/data-display/line-comment/line-comment.stories.tsx index 194586ad5154..fb65d8e97695 100644 --- a/packages/ui/src/data-display/line-comment/line-comment.stories.tsx +++ b/packages/ui/src/data-display/line-comment/line-comment.stories.tsx @@ -13,6 +13,7 @@ Line comment **display** and **editor** cards aligned with OpenCode line-comment ### Editor - \`LineCommentEditor\`: the card is the text field (\`label\` names it, default “Comment”), footer (optional selection meta + Cancel / Comment). - \`Enter\` submits (Shift+Enter newline); \`Escape\` cancels. Controlled via \`value\` / \`onInput\`. +- Compact editors show a cancel action when empty, unless \`allowEmpty\` enables submitting an optional comment. ` export default { @@ -69,17 +70,32 @@ export const DisplayMultiline = { } export const Editor = { - render: () => { + args: { variant: "default", allowEmpty: false }, + argTypes: { + variant: { control: "select", options: ["default", "compact"] }, + allowEmpty: { control: "boolean" }, + }, + render: (args) => { const [value, setValue] = createSignal("") + const [result, setResult] = createSignal("") return (
setValue("")} - onSubmit={() => setValue("")} + onCancel={() => { + setValue("") + setResult("Cancelled") + }} + onSubmit={(comment) => { + setValue("") + setResult(`Submitted: ${comment}`) + }} /> + {result()}
) }, diff --git a/packages/ui/src/data-display/line-comment/line-comment.tsx b/packages/ui/src/data-display/line-comment/line-comment.tsx index 8e6370bc6473..933fe9d815a4 100644 --- a/packages/ui/src/data-display/line-comment/line-comment.tsx +++ b/packages/ui/src/data-display/line-comment/line-comment.tsx @@ -1,8 +1,24 @@ -import { For, Show, createSignal, onMount, splitProps, type ComponentProps, type JSX } from "solid-js" +import { + For, + Show, + createEffect, + createSignal, + on, + onCleanup, + onMount, + splitProps, + type ComponentProps, + type JSX, +} from "solid-js" import { FileIcon } from "@opencode/ui/file-icon" import { useI18n } from "../../context/i18n" import { useFilteredList } from "../../hooks" import { Button } from "@opencode/ui/button" +import { Icon } from "@opencode/ui/icon" +import { IconButton } from "@opencode/ui/icon-button" +import { Keybind } from "@opencode/ui/keybind" +import { Tooltip } from "@opencode/ui/tooltip" +import check from "./quote-check.svg" import "./line-comment.css" /** Horizontal “more” glyph for the display-card overflow control (Figma outline-dots). */ @@ -72,14 +88,16 @@ export interface LineCommentEditorProps extends Omit, "chi onInput: (value: string) => void onCancel: () => void onSubmit: (value: string) => void - /** Optional context in the footer, e.g. the page or element being commented on. */ + /** Optional context in the footer, or above the input in the compact variant. */ selection?: JSX.Element + /** Compact layout with a growing input and inline action. */ + variant?: "default" | "compact" placeholder?: string rows?: number cancelLabel?: string submitLabel?: string autofocus?: boolean - /** Submit an empty value too, such as to clear a comment that exists (default: false). */ + /** Submit an empty value too, such as to clear a comment that exists (default: false). Compact empty actions otherwise cancel. */ allowEmpty?: boolean mention?: LineCommentEditorMention } @@ -108,6 +126,7 @@ export function LineCommentEditor(props: LineCommentEditorProps) { "onCancel", "onSubmit", "selection", + "variant", "placeholder", "rows", "cancelLabel", @@ -120,6 +139,12 @@ export function LineCommentEditor(props: LineCommentEditorProps) { ]) const canSubmit = () => !!local.allowEmpty || local.value.trim().length > 0 + const compactCancel = () => !canSubmit() + + const compactLabel = () => + compactCancel() + ? (local.cancelLabel ?? i18n.t("ui.lineComment.cancel")) + : (local.submitLabel ?? i18n.t("ui.lineComment.submit")) const closeMention = () => { setMentionOpen(false) @@ -215,124 +240,203 @@ export function LineCommentEditor(props: LineCommentEditorProps) { requestAnimationFrame(() => textareaRef?.focus()) }) + onMount(() => { + if (CSS.supports("field-sizing", "content")) return + + createEffect(() => { + const textarea = textareaRef + + if (local.variant !== "compact" || !textarea) return + + const resize = () => { + // Reset before measuring so the field can shrink as well as grow. + textarea.style.height = "0px" + textarea.style.height = `${textarea.scrollHeight}px` + } + + createEffect(on(() => local.value, resize)) + + let width = 0 + + const observer = new ResizeObserver(([entry]) => { + if (!entry || entry.contentRect.width === width) return + width = entry.contentRect.width + resize() + }) + + observer.observe(textarea) + onCleanup(() => { + observer.disconnect() + textarea.style.removeProperty("height") + }) + }) + }) + return (
-
-