From 7a78f36fc1843fa1fd5d5d75841e8c2ef1814d02 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 14:01:01 +0800 Subject: [PATCH 1/3] refactor(app): extract session turn changes --- .../src/pages/session/message-timeline.tsx | 292 +---------------- .../session/session-turn-changes.test.ts | 68 ++++ .../pages/session/session-turn-changes.tsx | 294 ++++++++++++++++++ 3 files changed, 366 insertions(+), 288 deletions(-) create mode 100644 packages/app/src/pages/session/session-turn-changes.test.ts create mode 100644 packages/app/src/pages/session/session-turn-changes.tsx diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 9b96407bc..e8d6a511f 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -6,7 +6,6 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Dialog } from "@opencode-ai/ui/dialog" import { Spinner } from "@opencode-ai/ui/spinner" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" @@ -34,15 +33,9 @@ import { } from "@/pages/session/session-message-comments" import { taskDescription } from "@/pages/session/task-description" import { buildTurnMessagesByUserID, emptyAssistantMessages } from "@/pages/session/session-messages" -import { - turnFetchSignature, - turnFetchTargets, - type TurnFetchAssistantLite, - type TurnFetchInput, -} from "@/pages/session/turn-change-fetch" +import { createSessionTurnChanges } from "@/pages/session/session-turn-changes" import { createSessionRunning } from "@/pages/session/session-running-state" import { SessionContextUsage } from "@/components/session-context-usage" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSessionRouteKey } from "@/pages/session/session-layout" import { usePlatform } from "@/context/platform" @@ -72,30 +65,6 @@ type UserActions = { revert?: (input: { sessionID: string; messageID: string }) => Promise | void } -type TurnChangeDisplay = { - sessionID: string - turnID: string - messageID: string - undoAvailable: boolean - redoAvailable: boolean - truncated?: boolean - omittedCount?: number - skippedCount?: number - files: Array<{ - path: string - openPath?: string - status: "added" | "modified" | "deleted" - additions?: number - deletions?: number - patch?: string - sensitive?: boolean - binary?: boolean - large?: boolean - restoreAvailable?: boolean - expandable: boolean - }> -} - export { taskDescription } export function MessageTimeline(props: { @@ -145,7 +114,6 @@ export function MessageTimeline(props: { const sdk = useSDK() const sync = useSync() const settings = useSettings() - const dialog = useDialog() const language = useLanguage() const shellSurface = useShellSurface() const { params } = useSessionRouteKey() @@ -184,261 +152,10 @@ export function MessageTimeline(props: { const sessionID = createMemo(() => props.sessionID) const sessionMessages = createMemo(() => props.sessionMessages) const turnMessagesByUserID = createMemo(() => buildTurnMessagesByUserID(sessionMessages())) + const turnChangeController = createSessionTurnChanges({ sessionID, sessionMessages }) const webSearchToastSurfaced = new Set() const webSearchPartCursor = new Map() const webSearchPendingParts = new Map>() - const [turnChanges, setTurnChanges] = createStore>({}) - const fetchedTurnChanges = new Set() - const turnChangeRetryTimers = new Map>() - const cancelTurnChangeRetries = () => { - for (const timer of turnChangeRetryTimers.values()) clearTimeout(timer) - turnChangeRetryTimers.clear() - } - onCleanup(cancelTurnChangeRetries) - createEffect( - on( - sessionID, - () => { - cancelTurnChangeRetries() - fetchedTurnChanges.clear() - }, - { defer: true }, - ), - ) - - const authHeaders = () => { - const current = server.current - if (!current?.http.password) return {} as Record - return { - Authorization: `Basic ${btoa(`${current.http.username ?? "opencode"}:${current.http.password}`)}`, - } - } - - const blockedDescription = (body: any) => { - const base = - body?.reason === "conflict" - ? language.t("session.turnChange.blocked.conflict") - : body?.reason === "unsupported_size" - ? language.t("session.turnChange.blocked.unsupportedSize") - : body?.reason === "permission_denied" - ? language.t("session.turnChange.blocked.permissionDenied") - : body?.reason === "rollback_failed" - ? language.t("session.turnChange.blocked.rollbackFailed") - : language.t("session.turnChange.blocked.generic") - const files = Array.isArray(body?.files) - ? body.files.filter((file: any) => typeof file?.path === "string").map((file: any) => file.path as string) - : [] - if (!files.length) return base - const visible = files.slice(0, 3).join(", ") - const rest = files.length > 3 ? language.t("session.turnChange.blocked.more", { count: files.length - 3 }) : "" - return `${base} ${language.t("session.turnChange.blocked.files", { files: `${visible}${rest}` })}` - } - - const turnChangeFetch = async ( - userMessageID: string, - action?: "undo" | "redo", - options?: { force?: boolean }, - ): Promise => { - const current = server.current - const id = sessionID() - if (!current || !id) return - const url = `${current.http.url}/session/${id}/turn/${userMessageID}/changes${action ? `/${action}` : ""}` - let res: Response - try { - res = await fetch(url, { - method: action ? "POST" : "GET", - headers: { - ...authHeaders(), - ...(action ? { "Content-Type": "application/json" } : {}), - }, - ...(action ? { body: JSON.stringify({ force: !!options?.force }) } : {}), - }) - } catch (err) { - if (action) { - showToast({ - title: - action === "undo" - ? language.t("session.turnChange.undoBlocked") - : language.t("session.turnChange.redoBlocked"), - description: language.t("session.turnChange.blocked.generic"), - variant: "error", - }) - } - return turnChanges[userMessageID] ?? undefined - } - if (!res.ok) { - if (action) { - showToast({ - title: - action === "undo" - ? language.t("session.turnChange.undoBlocked") - : language.t("session.turnChange.redoBlocked"), - description: language.t("session.turnChange.blocked.generic"), - variant: "error", - }) - } - return turnChanges[userMessageID] ?? undefined - } - let body: any - try { - body = await res.json() - } catch { - if (action) { - showToast({ - title: - action === "undo" - ? language.t("session.turnChange.undoBlocked") - : language.t("session.turnChange.redoBlocked"), - description: language.t("session.turnChange.blocked.generic"), - variant: "error", - }) - } - return turnChanges[userMessageID] ?? undefined - } - if (!action) { - setTurnChanges(userMessageID, body ?? null) - return body ?? undefined - } - if (body?.status === "applied") { - const rawDisplay: TurnChangeDisplay | null = body.display ?? null - let display: TurnChangeDisplay | null = rawDisplay - if (rawDisplay && Array.isArray(body.skipped) && body.skipped.length) { - const skippedCount = body.skipped.reduce( - (sum: number, item: any) => sum + (Array.isArray(item?.files) ? item.files.length : 0), - 0, - ) - if (skippedCount > 0) display = { ...rawDisplay, skippedCount } - } - setTurnChanges(userMessageID, display) - return display ?? undefined - } - if (action && body?.status === "blocked" && body.reason === "conflict" && !options?.force) { - const conflictPaths = Array.isArray(body.files) - ? (body.files as Array<{ path?: unknown }>) - .map((file) => (typeof file?.path === "string" ? file.path : "")) - .filter((path) => path.length > 0) - : [] - return await new Promise((resolve) => { - let settled = false - const finish = (value: TurnChangeDisplay | undefined) => { - if (settled) return - settled = true - resolve(value) - } - dialog.show( - () => ( - -
- 0}> -
- - {(item) => ( -
- {item} -
- )} -
- 6}> -
- {language.t("ui.sessionTurn.turnChanges.confirmListMore", { - count: conflictPaths.length - 6, - })} -
-
-
-
-
- - -
-
-
- ), - () => finish(undefined), - ) - }) - } - showToast({ - title: - action === "undo" - ? language.t("session.turnChange.undoBlocked") - : language.t("session.turnChange.redoBlocked"), - description: blockedDescription(body), - variant: "error", - }) - return turnChanges[userMessageID] ?? undefined - } - - const turnFetchInput = (): TurnFetchInput | null => { - const id = sessionID() - if (!id) return null - const assistants: TurnFetchAssistantLite[] = [] - for (const message of sessionMessages()) { - if (message.role !== "assistant") continue - assistants.push({ - id: message.id, - parentID: message.parentID, - completed: message.time.completed, - }) - } - return { sessionID: id, assistants } - } - - createEffect( - on( - () => { - const input = turnFetchInput() - return input ? turnFetchSignature(input) : "" - }, - () => { - const input = turnFetchInput() - if (!input) return - for (const target of turnFetchTargets(input)) { - if (fetchedTurnChanges.has(target.key)) continue - fetchedTurnChanges.add(target.key) - void turnChangeFetch(target.userMessageID) - .then((display) => { - if (display) return - if (turnChangeRetryTimers.has(target.key)) return - const timer = setTimeout(() => { - turnChangeRetryTimers.delete(target.key) - void turnChangeFetch(target.userMessageID).catch(() => undefined) - }, 500) - turnChangeRetryTimers.set(target.key, timer) - }) - .catch(() => { - fetchedTurnChanges.delete(target.key) - setTurnChanges(target.userMessageID, null) - }) - } - }, - ), - ) onMount(() => { void emitRendererDiagnostic({ @@ -932,10 +649,9 @@ export function MessageTimeline(props: { showReasoningSummaries={settings.general.showReasoningSummaries()} shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} editToolDefaultOpen={settings.general.editToolPartsExpanded()} - turnChanges={turnChanges} + turnChanges={turnChangeController.turnChanges} turnChangeActions={{ - undo: (userMessageID, options) => turnChangeFetch(userMessageID, "undo", options), - redo: (userMessageID, options) => turnChangeFetch(userMessageID, "redo", options), + ...turnChangeController.actions, openFile: (path) => { void platform.openPath?.(path) }, diff --git a/packages/app/src/pages/session/session-turn-changes.test.ts b/packages/app/src/pages/session/session-turn-changes.test.ts new file mode 100644 index 000000000..3d5172ba7 --- /dev/null +++ b/packages/app/src/pages/session/session-turn-changes.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { blockedTurnChangeDescription, buildTurnFetchInput } from "./session-turn-changes" +import type { Message as MessageType } from "@opencode-ai/sdk/v2" + +const user = (id: string): MessageType => + ({ + id, + role: "user", + time: { created: 1 }, + }) as MessageType + +const assistant = (id: string, parentID?: string | null, completed?: number): MessageType => + ({ + id, + role: "assistant", + parentID, + time: { created: 1, completed }, + }) as MessageType + +const t = (key: string, params?: Record) => { + if (params?.files) return `${key}[${params.files}]` + if (params?.count) return `${key}[${params.count}]` + return key +} + +describe("buildTurnFetchInput", () => { + test("returns null without a session id", () => { + expect(buildTurnFetchInput(undefined, [assistant("a1", "u1", 100)])).toBeNull() + }) + + test("keeps only assistant messages needed by the turn fetch contract", () => { + expect(buildTurnFetchInput("ses_1", [user("u1"), assistant("a1", "u1", 100), assistant("a2")])).toEqual({ + sessionID: "ses_1", + assistants: [ + { id: "a1", parentID: "u1", completed: 100 }, + { id: "a2", parentID: undefined, completed: undefined }, + ], + }) + }) +}) + +describe("blockedTurnChangeDescription", () => { + test("maps known blocked reasons", () => { + expect(blockedTurnChangeDescription({ reason: "unsupported_size" }, t)).toBe( + "session.turnChange.blocked.unsupportedSize", + ) + expect(blockedTurnChangeDescription({ reason: "permission_denied" }, t)).toBe( + "session.turnChange.blocked.permissionDenied", + ) + expect(blockedTurnChangeDescription({ reason: "rollback_failed" }, t)).toBe( + "session.turnChange.blocked.rollbackFailed", + ) + }) + + test("adds a bounded file summary for conflict descriptions", () => { + expect( + blockedTurnChangeDescription( + { + reason: "conflict", + files: [{ path: "a.ts" }, { path: "b.ts" }, { path: "c.ts" }, { path: "d.ts" }, { nope: true }], + }, + t, + ), + ).toBe( + "session.turnChange.blocked.conflict session.turnChange.blocked.files[a.ts, b.ts, c.tssession.turnChange.blocked.more[1]]", + ) + }) +}) diff --git a/packages/app/src/pages/session/session-turn-changes.tsx b/packages/app/src/pages/session/session-turn-changes.tsx new file mode 100644 index 000000000..a213759b8 --- /dev/null +++ b/packages/app/src/pages/session/session-turn-changes.tsx @@ -0,0 +1,294 @@ +import { createEffect, For, on, onCleanup, Show, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" +import { Button } from "@opencode-ai/ui/button" +import { Dialog } from "@opencode-ai/ui/dialog" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { showToast } from "@opencode-ai/ui/toast" +import type { Message as MessageType } from "@opencode-ai/sdk/v2" +import { useLanguage } from "@/context/language" +import { useServer } from "@/context/server" +import { + turnFetchSignature, + turnFetchTargets, + type TurnFetchAssistantLite, + type TurnFetchInput, +} from "@/pages/session/turn-change-fetch" + +type Translate = (key: string, params?: Record) => string + +export type TurnChangeDisplay = { + sessionID: string + turnID: string + messageID: string + undoAvailable: boolean + redoAvailable: boolean + truncated?: boolean + omittedCount?: number + skippedCount?: number + files: Array<{ + path: string + openPath?: string + status: "added" | "modified" | "deleted" + additions?: number + deletions?: number + patch?: string + sensitive?: boolean + binary?: boolean + large?: boolean + restoreAvailable?: boolean + expandable: boolean + }> +} + +export function buildTurnFetchInput(sessionID: string | undefined, messages: MessageType[]): TurnFetchInput | null { + if (!sessionID) return null + const assistants: TurnFetchAssistantLite[] = [] + for (const message of messages) { + if (message.role !== "assistant") continue + assistants.push({ + id: message.id, + parentID: message.parentID, + completed: message.time.completed, + }) + } + return { sessionID, assistants } +} + +export function blockedTurnChangeDescription(body: any, t: Translate) { + const base = + body?.reason === "conflict" + ? t("session.turnChange.blocked.conflict") + : body?.reason === "unsupported_size" + ? t("session.turnChange.blocked.unsupportedSize") + : body?.reason === "permission_denied" + ? t("session.turnChange.blocked.permissionDenied") + : body?.reason === "rollback_failed" + ? t("session.turnChange.blocked.rollbackFailed") + : t("session.turnChange.blocked.generic") + const files = Array.isArray(body?.files) + ? body.files.filter((file: any) => typeof file?.path === "string").map((file: any) => file.path as string) + : [] + if (!files.length) return base + const visible = files.slice(0, 3).join(", ") + const rest = files.length > 3 ? t("session.turnChange.blocked.more", { count: files.length - 3 }) : "" + return `${base} ${t("session.turnChange.blocked.files", { files: `${visible}${rest}` })}` +} + +export function createSessionTurnChanges(input: { + sessionID: Accessor + sessionMessages: Accessor +}) { + const server = useServer() + const language = useLanguage() + const dialog = useDialog() + const translate: Translate = (key, params) => language.t(key as any, params as any) + + const [turnChanges, setTurnChanges] = createStore>({}) + const fetchedTurnChanges = new Set() + const turnChangeRetryTimers = new Map>() + + const cancelTurnChangeRetries = () => { + for (const timer of turnChangeRetryTimers.values()) clearTimeout(timer) + turnChangeRetryTimers.clear() + } + + const authHeaders = () => { + const current = server.current + if (!current?.http.password) return {} as Record + return { + Authorization: `Basic ${btoa(`${current.http.username ?? "opencode"}:${current.http.password}`)}`, + } + } + + const showActionError = (action: "undo" | "redo") => { + showToast({ + title: + action === "undo" ? language.t("session.turnChange.undoBlocked") : language.t("session.turnChange.redoBlocked"), + description: language.t("session.turnChange.blocked.generic"), + variant: "error", + }) + } + + const fetchTurnChange = async ( + userMessageID: string, + action?: "undo" | "redo", + options?: { force?: boolean }, + ): Promise => { + const current = server.current + const id = input.sessionID() + if (!current || !id) return + const url = `${current.http.url}/session/${id}/turn/${userMessageID}/changes${action ? `/${action}` : ""}` + let res: Response + try { + res = await fetch(url, { + method: action ? "POST" : "GET", + headers: { + ...authHeaders(), + ...(action ? { "Content-Type": "application/json" } : {}), + }, + ...(action ? { body: JSON.stringify({ force: !!options?.force }) } : {}), + }) + } catch { + if (action) showActionError(action) + return turnChanges[userMessageID] ?? undefined + } + if (!res.ok) { + if (action) showActionError(action) + return turnChanges[userMessageID] ?? undefined + } + let body: any + try { + body = await res.json() + } catch { + if (action) showActionError(action) + return turnChanges[userMessageID] ?? undefined + } + if (!action) { + setTurnChanges(userMessageID, body ?? null) + return body ?? undefined + } + if (body?.status === "applied") { + const rawDisplay: TurnChangeDisplay | null = body.display ?? null + let display: TurnChangeDisplay | null = rawDisplay + if (rawDisplay && Array.isArray(body.skipped) && body.skipped.length) { + const skippedCount = body.skipped.reduce( + (sum: number, item: any) => sum + (Array.isArray(item?.files) ? item.files.length : 0), + 0, + ) + if (skippedCount > 0) display = { ...rawDisplay, skippedCount } + } + setTurnChanges(userMessageID, display) + return display ?? undefined + } + if (body?.status === "blocked" && body.reason === "conflict" && !options?.force) { + const conflictPaths = Array.isArray(body.files) + ? (body.files as Array<{ path?: unknown }>) + .map((file) => (typeof file?.path === "string" ? file.path : "")) + .filter((path) => path.length > 0) + : [] + return await new Promise((resolve) => { + let settled = false + const finish = (value: TurnChangeDisplay | undefined) => { + if (settled) return + settled = true + resolve(value) + } + dialog.show( + () => ( + +
+ 0}> +
+ + {(item) => ( +
+ {item} +
+ )} +
+ 6}> +
+ {language.t("ui.sessionTurn.turnChanges.confirmListMore", { + count: conflictPaths.length - 6, + })} +
+
+
+
+
+ + +
+
+
+ ), + () => finish(undefined), + ) + }) + } + showToast({ + title: + action === "undo" ? language.t("session.turnChange.undoBlocked") : language.t("session.turnChange.redoBlocked"), + description: blockedTurnChangeDescription(body, translate), + variant: "error", + }) + return turnChanges[userMessageID] ?? undefined + } + + const turnFetchInput = () => buildTurnFetchInput(input.sessionID(), input.sessionMessages()) + + onCleanup(cancelTurnChangeRetries) + createEffect( + on( + input.sessionID, + () => { + cancelTurnChangeRetries() + fetchedTurnChanges.clear() + }, + { defer: true }, + ), + ) + createEffect( + on( + () => { + const next = turnFetchInput() + return next ? turnFetchSignature(next) : "" + }, + () => { + const next = turnFetchInput() + if (!next) return + for (const target of turnFetchTargets(next)) { + if (fetchedTurnChanges.has(target.key)) continue + fetchedTurnChanges.add(target.key) + void fetchTurnChange(target.userMessageID) + .then((display) => { + if (display) return + if (turnChangeRetryTimers.has(target.key)) return + const timer = setTimeout(() => { + turnChangeRetryTimers.delete(target.key) + void fetchTurnChange(target.userMessageID).catch(() => undefined) + }, 500) + turnChangeRetryTimers.set(target.key, timer) + }) + .catch(() => { + fetchedTurnChanges.delete(target.key) + setTurnChanges(target.userMessageID, null) + }) + } + }, + ), + ) + + return { + turnChanges, + actions: { + undo: (userMessageID: string, options?: { force?: boolean }) => fetchTurnChange(userMessageID, "undo", options), + redo: (userMessageID: string, options?: { force?: boolean }) => fetchTurnChange(userMessageID, "redo", options), + }, + } +} From 730de89166ce45dffb6142a8b9fea9c86412fd72 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 21:48:22 +0800 Subject: [PATCH 2/3] fix(app): separate blocked turn change overflow copy --- packages/app/src/pages/session/session-turn-changes.test.ts | 2 +- packages/app/src/pages/session/session-turn-changes.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/session-turn-changes.test.ts b/packages/app/src/pages/session/session-turn-changes.test.ts index 3d5172ba7..ee23b7a50 100644 --- a/packages/app/src/pages/session/session-turn-changes.test.ts +++ b/packages/app/src/pages/session/session-turn-changes.test.ts @@ -62,7 +62,7 @@ describe("blockedTurnChangeDescription", () => { t, ), ).toBe( - "session.turnChange.blocked.conflict session.turnChange.blocked.files[a.ts, b.ts, c.tssession.turnChange.blocked.more[1]]", + "session.turnChange.blocked.conflict session.turnChange.blocked.files[a.ts, b.ts, c.ts, session.turnChange.blocked.more[1]]", ) }) }) diff --git a/packages/app/src/pages/session/session-turn-changes.tsx b/packages/app/src/pages/session/session-turn-changes.tsx index a213759b8..275b78a75 100644 --- a/packages/app/src/pages/session/session-turn-changes.tsx +++ b/packages/app/src/pages/session/session-turn-changes.tsx @@ -70,7 +70,7 @@ export function blockedTurnChangeDescription(body: any, t: Translate) { : [] if (!files.length) return base const visible = files.slice(0, 3).join(", ") - const rest = files.length > 3 ? t("session.turnChange.blocked.more", { count: files.length - 3 }) : "" + const rest = files.length > 3 ? `, ${t("session.turnChange.blocked.more", { count: files.length - 3 })}` : "" return `${base} ${t("session.turnChange.blocked.files", { files: `${visible}${rest}` })}` } From 8981175f02454ea94ff8ad0f67bd08bb98c1ed09 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 21:54:32 +0800 Subject: [PATCH 3/3] fix(app): let locale own turn change overflow separator --- packages/app/src/pages/session/session-turn-changes.test.ts | 2 +- packages/app/src/pages/session/session-turn-changes.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/session-turn-changes.test.ts b/packages/app/src/pages/session/session-turn-changes.test.ts index ee23b7a50..b6478efb6 100644 --- a/packages/app/src/pages/session/session-turn-changes.test.ts +++ b/packages/app/src/pages/session/session-turn-changes.test.ts @@ -19,7 +19,7 @@ const assistant = (id: string, parentID?: string | null, completed?: number): Me const t = (key: string, params?: Record) => { if (params?.files) return `${key}[${params.files}]` - if (params?.count) return `${key}[${params.count}]` + if (params?.count) return `, ${key}[${params.count}]` return key } diff --git a/packages/app/src/pages/session/session-turn-changes.tsx b/packages/app/src/pages/session/session-turn-changes.tsx index 275b78a75..a213759b8 100644 --- a/packages/app/src/pages/session/session-turn-changes.tsx +++ b/packages/app/src/pages/session/session-turn-changes.tsx @@ -70,7 +70,7 @@ export function blockedTurnChangeDescription(body: any, t: Translate) { : [] if (!files.length) return base const visible = files.slice(0, 3).join(", ") - const rest = files.length > 3 ? `, ${t("session.turnChange.blocked.more", { count: files.length - 3 })}` : "" + const rest = files.length > 3 ? t("session.turnChange.blocked.more", { count: files.length - 3 }) : "" return `${base} ${t("session.turnChange.blocked.files", { files: `${visible}${rest}` })}` }