mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(ui): retain confirmed model selection after refresh failure (#150101)
* fix(ui): retain confirmed model selection after refresh failure * fix(ui): preserve model patch completion contract
This commit is contained in:
parent
2cb55600a0
commit
a2cbcc25aa
8 changed files with 353 additions and 39 deletions
|
|
@ -204,7 +204,7 @@ Chat error banners, including cloud runner failures, show short messages in full
|
|||
- **Review** retains task and detail selections independently of file tabs. A pending file or artifact updates only its own open tab: it cannot select itself over a newer tab, reopen a closed preview, or return after you leave the chat page. Switching tabs or hiding the whole side panel preserves the pending preview without changing your chosen layout when it finishes. Text attachments retain their Preview or View Raw Text mode while switching between open files.
|
||||
- Each task has a main view and a unified side panel. The task toolbar's **Swap** button exchanges the main view and active side-panel tab; its tooltip names both views, for example **Swap Chat and Dashboard**. Chat, Dashboard, Browser, Terminal, Files, and Review can all be main. Other side-panel tabs remain available. **Focus** in the main pane header gives that view the full task area; **Restore split** brings the side panel back. Swapping or focusing preserves live content and drafts. Closing the whole side panel hides it without changing the main view, and the browser remembers each task's arrangement.
|
||||
- The task toolbar's **Layout** menu positions the side panel left, right, or below the main area. It adapts to each pane's own width rather than the window, falls back to a bottom strip in a narrow pane or compact window, and hides its dock controls until the pane widens. Phone-sized viewports still open review content full-screen.
|
||||
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. For catalog-backed OpenAI models, the effort picker offers **Off** only when the model advertises disabled reasoning. Inheriting the model's default effort does not turn reasoning off.
|
||||
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. A confirmed model selection stays visible if the following session refresh fails; later Gateway updates can still change it. For catalog-backed OpenAI models, the effort picker offers **Off** only when the model advertises disabled reasoning. Inheriting the model's default effort does not turn reasoning off.
|
||||
- Diff syntax highlighting uses each file's language and the current theme; unknown file types and oversized previews remain plain text. Inline and session diffs do not require the optional [Diffs plugin](/tools/diffs), which creates standalone viewer links and PNG/PDF attachments.
|
||||
- **Split view:** open it from the chat title bar (beside the thread diff, background tasks, and thread files toggles), then split the active pane right or down for as many panes as fit. Each pane has its own thread, transcript, composer, and tool stream.
|
||||
- Agents with the `screen` tool can request the same pane, sidebar, terminal, browser, focus, and navigation changes while a capable Control UI is connected. Protocol v1 applies the command to every connected capable Control UI; see [Screen](/tools/screen).
|
||||
|
|
|
|||
|
|
@ -286,6 +286,7 @@ export type SessionsPatchResult = SessionsPatchResultBase<{
|
|||
lastActivityAt?: number;
|
||||
lastInteractionAt?: number;
|
||||
permissionMode?: GatewaySessionRow["permissionMode"];
|
||||
modelOverrideSource?: GatewayWireSessionsPatchResult["entry"]["modelOverrideSource"];
|
||||
boardPresentation?: GatewaySessionRow["boardPresentation"];
|
||||
archivedAt?: number;
|
||||
archivedBy?: GatewaySessionRow["archivedBy"];
|
||||
|
|
|
|||
112
ui/src/e2e/chat-model-selection-recovery.e2e.test.ts
Normal file
112
ui/src/e2e/chat-model-selection-recovery.e2e.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import type { SessionsPatchResult } from "../../../src/gateway/session-utils.types.js";
|
||||
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
|
||||
import { controlUiSessionUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({ name: "Model selection recovery" });
|
||||
|
||||
suite.define(() => {
|
||||
it.each([false, true])(
|
||||
"retains a confirmed model selection when the roster refresh fails=%s",
|
||||
async (refreshFails) => {
|
||||
await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => {
|
||||
const artifactDir = createControlUiE2eArtifactDir("model-selection-recovery");
|
||||
const key = "agent:main:model-selection-proof";
|
||||
const row = {
|
||||
key,
|
||||
sessionId: "synthetic-model-selection",
|
||||
kind: "direct",
|
||||
label: "Synthetic model selection",
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
modelOverrideSource: "user",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const selected = { ...row, model: "gpt-5.5", updatedAt: 2 };
|
||||
const gateway = await installMockGateway(page, {
|
||||
agentModel: "openai/gpt-5.4",
|
||||
sessionKey: key,
|
||||
sessionInfo: row,
|
||||
sessions: [row],
|
||||
models: [
|
||||
{ id: "gpt-5.4", name: "GPT-5.4", provider: "openai", available: true },
|
||||
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true },
|
||||
],
|
||||
deferredMethods: ["sessions.patch"],
|
||||
methodResponses: {
|
||||
"sessions.list": {
|
||||
ts: 1,
|
||||
path: "",
|
||||
count: 1,
|
||||
defaults: { model: "gpt-5.4", modelProvider: "openai", contextTokens: 128_000 },
|
||||
sessions: [row],
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, key));
|
||||
const picker = page.locator(
|
||||
'openclaw-chat-pane[aria-hidden="false"] .chat-controls__model-picker',
|
||||
);
|
||||
const trigger = picker.locator("[data-chat-model-select]");
|
||||
await expect.poll(() => trigger.getAttribute("aria-disabled")).toBe("false");
|
||||
await trigger.click();
|
||||
await picker.locator('[data-chat-model-option="openai/gpt-5.5"]').waitFor();
|
||||
await page.screenshot({ path: path.join(artifactDir, "before-selection.png") });
|
||||
await picker.locator('[data-chat-model-option="openai/gpt-5.5"]').click();
|
||||
const patch = await gateway.waitForRequest("sessions.patch");
|
||||
expect(patch.params).toMatchObject({ key, model: "openai/gpt-5.5" });
|
||||
await expect.poll(() => trigger.getAttribute("aria-disabled")).toBe("true");
|
||||
|
||||
if (refreshFails) {
|
||||
await gateway.setMethodResponse("sessions.list", {
|
||||
__mockError: { code: "UNAVAILABLE", message: "Synthetic roster refresh unavailable" },
|
||||
});
|
||||
}
|
||||
const listCount = (await gateway.getRequests("sessions.list")).length;
|
||||
// The fixture commits the successful patch to its canonical session owner.
|
||||
// No lifecycle event is emitted; the receipt must survive a failed read.
|
||||
await gateway.resolveDeferred("sessions.patch", {
|
||||
ok: true,
|
||||
key,
|
||||
path: "",
|
||||
entry: {
|
||||
sessionId: row.sessionId,
|
||||
updatedAt: selected.updatedAt,
|
||||
providerOverride: "openai",
|
||||
modelOverride: selected.model,
|
||||
modelOverrideSource: "user",
|
||||
},
|
||||
resolved: { modelProvider: "openai", model: selected.model },
|
||||
} satisfies SessionsPatchResult);
|
||||
await gateway.waitForRequest("sessions.list", { after: listCount });
|
||||
await expect.poll(() => trigger.getAttribute("aria-disabled")).toBe("false");
|
||||
await trigger.click();
|
||||
await picker.locator(".chat-controls__model-menu").waitFor();
|
||||
const observedSelection = await trigger.getAttribute("data-chat-select-value");
|
||||
const observedLabel = await trigger.textContent();
|
||||
// Preserve the actual settled UI and transport evidence before the regression assertion.
|
||||
await page.screenshot({ path: path.join(artifactDir, "after-confirmed-selection.png") });
|
||||
await writeFile(
|
||||
path.join(artifactDir, "selection.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
refreshFails,
|
||||
confirmed: selected,
|
||||
observedSelection,
|
||||
observedLabel,
|
||||
patches: await gateway.getRequests("sessions.patch"),
|
||||
lists: await gateway.getRequests("sessions.list"),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
expect(observedSelection).toBe("openai/gpt-5.5");
|
||||
expect(observedLabel).toContain("GPT-5.5");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// Focused model-override lifecycle coverage; the main capability suite sits
|
||||
// at the max-lines cap.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionsPatchResult } from "../../../../src/gateway/session-utils.types.js";
|
||||
import { createDeferred } from "../../../../test/helpers/promise.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import {
|
||||
|
|
@ -160,4 +161,175 @@ describe("session model override lifecycle", () => {
|
|||
expect(sessions.state.modelOverrides[key]).toBeUndefined();
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
it.each(["selected", "default"] as const)(
|
||||
"keeps a confirmed %s model after failed reads, then admits an external change",
|
||||
async (selection) => {
|
||||
const key = "agent:main:main";
|
||||
let failReads = false;
|
||||
let model = "gpt-5.4";
|
||||
let updatedAt = 1;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.patch") {
|
||||
return {
|
||||
ok: true,
|
||||
key,
|
||||
path: "",
|
||||
entry: {
|
||||
sessionId: "main-session",
|
||||
updatedAt: 2,
|
||||
modelOverrideSource: selection === "default" ? "default" : "user",
|
||||
},
|
||||
resolved: {
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
...(selection === "selected"
|
||||
? {
|
||||
thinkingLevel: "high",
|
||||
thinkingLevels: [{ id: "high", label: "High" }],
|
||||
contextWindow: "large",
|
||||
contextWindows: [{ id: "large", label: "Large", contextWindow: 200_000 }],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
} satisfies SessionsPatchResult;
|
||||
}
|
||||
if (method === "sessions.subscribe") {
|
||||
return { subscribed: true };
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
if (failReads) {
|
||||
throw new Error("roster unavailable");
|
||||
}
|
||||
return sessionsResult(
|
||||
[
|
||||
{
|
||||
key,
|
||||
kind: "direct",
|
||||
sessionId: "main-session",
|
||||
updatedAt,
|
||||
model,
|
||||
modelProvider: "openai",
|
||||
modelOverrideSource: "user",
|
||||
thinkingLevel: "low",
|
||||
contextWindow: "standard",
|
||||
},
|
||||
],
|
||||
updatedAt,
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const { gateway } = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
|
||||
const sessions = createTestSessionCapability(gateway);
|
||||
await sessions.refresh({ force: true });
|
||||
failReads = true;
|
||||
await sessions.patch(key, {
|
||||
model: selection === "default" ? null : "openai/gpt-5.5",
|
||||
});
|
||||
expect(sessions.state.result?.sessions[0]).toMatchObject({
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
modelOverrideSource: selection === "default" ? null : "user",
|
||||
});
|
||||
expect(sessions.state.modelOverrides).toEqual({});
|
||||
expect(sessions.state.error).toContain("roster unavailable");
|
||||
expect(sessions.state.result?.sessions[0]).toMatchObject(
|
||||
selection === "selected"
|
||||
? {
|
||||
thinkingLevel: "high",
|
||||
contextWindow: "large",
|
||||
thinkingLevels: [{ id: "high", label: "High" }],
|
||||
}
|
||||
: { thinkingLevel: "low", contextWindow: "standard" },
|
||||
);
|
||||
failReads = false;
|
||||
model = "gpt-5.4";
|
||||
updatedAt = 3;
|
||||
await sessions.refresh({ force: true });
|
||||
expect(sessions.state.result?.sessions[0]?.model).toBe("gpt-5.4");
|
||||
sessions.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["replacement", "newer-fails", "newer-succeeds"] as const)(
|
||||
"fences a confirmed model receipt during %s",
|
||||
async (scenario) => {
|
||||
const key = "agent:main:main";
|
||||
const first = createDeferred<unknown>();
|
||||
const second = createDeferred<unknown>();
|
||||
let patchCount = 0;
|
||||
let failReads = false;
|
||||
let sessionId = "main-session";
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.patch") {
|
||||
return ++patchCount === 1 ? first.promise : second.promise;
|
||||
}
|
||||
if (method === "sessions.subscribe") {
|
||||
return { subscribed: true };
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
if (failReads) {
|
||||
throw new Error("roster unavailable");
|
||||
}
|
||||
return sessionsResult(
|
||||
[
|
||||
{
|
||||
key,
|
||||
kind: "direct",
|
||||
sessionId,
|
||||
updatedAt: sessionId === "main-session" ? 1 : 3,
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
},
|
||||
],
|
||||
1,
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const { gateway } = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
|
||||
const sessions = createTestSessionCapability(gateway);
|
||||
await sessions.refresh({ force: true });
|
||||
const operation = sessions.patch(key, { model: "openai/gpt-5.5" });
|
||||
if (scenario === "replacement") {
|
||||
sessionId = "replacement-session";
|
||||
await sessions.refresh({ force: true });
|
||||
expect(sessions.state.result?.sessions[0]?.sessionId).toBe(sessionId);
|
||||
}
|
||||
const newer =
|
||||
scenario !== "replacement" ? sessions.patch(key, { model: "openai/gpt-5.4" }) : null;
|
||||
failReads = true;
|
||||
if (scenario === "newer-succeeds") {
|
||||
second.resolve({
|
||||
ok: true,
|
||||
key,
|
||||
path: "",
|
||||
entry: { sessionId, updatedAt: 3, modelOverrideSource: "user" },
|
||||
resolved: { modelProvider: "openai", model: "gpt-5.4" },
|
||||
} satisfies SessionsPatchResult);
|
||||
await newer;
|
||||
}
|
||||
first.resolve({
|
||||
ok: true,
|
||||
key,
|
||||
path: "",
|
||||
entry: { sessionId: "main-session", updatedAt: 2, modelOverrideSource: "user" },
|
||||
resolved: { modelProvider: "openai", model: "gpt-5.5" },
|
||||
} satisfies SessionsPatchResult);
|
||||
await operation;
|
||||
if (newer && scenario === "newer-fails") {
|
||||
expect(sessions.state.modelOverrides[key]).toBe("openai/gpt-5.4");
|
||||
const rejected = expect(newer).rejects.toThrow("model rejected");
|
||||
second.reject(new Error("model rejected"));
|
||||
await rejected;
|
||||
}
|
||||
expect(sessions.state.modelOverrides).toEqual({});
|
||||
expect(sessions.state.result?.sessions[0]).toMatchObject({
|
||||
sessionId,
|
||||
model: scenario === "newer-fails" ? "gpt-5.5" : "gpt-5.4",
|
||||
});
|
||||
sessions.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type {
|
|||
SessionsAssignOwnerParams,
|
||||
SessionsAssignOwnerResult,
|
||||
} from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import { deriveSessionUnread } from "../../../../src/shared/session-unread.ts";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { formatUiError } from "../format-error.ts";
|
||||
|
|
@ -27,6 +26,7 @@ import type {
|
|||
} from "./session-capability.ts";
|
||||
import { areUiSessionKeysEquivalent } from "./session-key.ts";
|
||||
import type { SessionRefreshOutcome } from "./session-list-query.ts";
|
||||
import { projectSessionPatchRowFields } from "./session-patch-row-facts.ts";
|
||||
import {
|
||||
createOptimisticRowPatches,
|
||||
resolvePendingConversation,
|
||||
|
|
@ -288,6 +288,7 @@ export function createSessionMutations(host: SessionMutationsHost) {
|
|||
const normalizedKey = key.trim();
|
||||
const patchSnapshot = host.snapshot();
|
||||
const pendingConversation =
|
||||
managesModelOverride ||
|
||||
patchParams.pinned !== undefined ||
|
||||
patchParams.unread === false ||
|
||||
patchParams.archived !== undefined ||
|
||||
|
|
@ -374,7 +375,7 @@ export function createSessionMutations(host: SessionMutationsHost) {
|
|||
}
|
||||
if (host.connection.isCurrent(scope) && ownsModelOverride()) {
|
||||
if (completed && !options.deferListRefresh) {
|
||||
// The refreshed row already carries the Gateway-confirmed selection.
|
||||
// The canonical row carries the Gateway-confirmed selection.
|
||||
// Keeping an overlay would hide subsequent external model changes.
|
||||
setModelOverride(key, undefined);
|
||||
} else {
|
||||
|
|
@ -454,47 +455,15 @@ export function createSessionMutations(host: SessionMutationsHost) {
|
|||
resolvePendingConversation(patchSnapshot, result.key, pendingTarget.agentId)?.identity ===
|
||||
pendingTarget.identity;
|
||||
if (pendingTarget && rowPatchConfirmed && confirmFields) {
|
||||
const { entry } = result;
|
||||
const readCutoff = host.readRevision();
|
||||
const pin = { pinned: entry.pinnedAt !== undefined, pinnedAt: entry.pinnedAt };
|
||||
const read = {
|
||||
unread: deriveSessionUnread(entry),
|
||||
lastReadAt: entry.lastReadAt,
|
||||
markedUnreadAt: entry.markedUnreadAt,
|
||||
};
|
||||
if (typeof patchParams.archived === "boolean") {
|
||||
for (const fields of projectSessionPatchRowFields(patchParams, result)) {
|
||||
confirmFields({
|
||||
key: pendingTarget.key,
|
||||
agentId: pendingTarget.agentId,
|
||||
sessionId: pendingTarget.sessionId,
|
||||
updatedAt: entry.updatedAt ?? null,
|
||||
updatedAt: result.entry.updatedAt ?? null,
|
||||
readCutoff,
|
||||
fields: projectSessionArchiveFields(patchParams.archived, entry),
|
||||
});
|
||||
}
|
||||
if (patchParams.boardPresentation !== undefined) {
|
||||
confirmFields({
|
||||
key: pendingTarget.key,
|
||||
agentId: pendingTarget.agentId,
|
||||
sessionId: pendingTarget.sessionId,
|
||||
updatedAt: entry.updatedAt ?? null,
|
||||
readCutoff,
|
||||
fields: { boardPresentation: entry.boardPresentation },
|
||||
});
|
||||
}
|
||||
if (patchParams.pinned !== undefined || patchParams.unread === false) {
|
||||
confirmFields({
|
||||
key: pendingTarget.key,
|
||||
agentId: pendingTarget.agentId,
|
||||
sessionId: pendingTarget.sessionId,
|
||||
updatedAt: entry.updatedAt ?? null,
|
||||
readCutoff,
|
||||
fields:
|
||||
patchParams.pinned === undefined
|
||||
? read
|
||||
: patchParams.unread === false
|
||||
? { ...pin, ...read }
|
||||
: pin,
|
||||
fields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
ui/src/lib/sessions/session-patch-row-facts.ts
Normal file
42
ui/src/lib/sessions/session-patch-row-facts.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { deriveSessionUnread } from "../../../../src/shared/session-unread.ts";
|
||||
import type { SessionPatch, SessionPatchResult } from "./patch.ts";
|
||||
import { projectSessionArchiveFields } from "./session-archive-state.ts";
|
||||
import type { SessionPatchRowFact } from "./session-pending-rows.ts";
|
||||
|
||||
/** Translate a committed patch receipt into the row fields its write confirmed. */
|
||||
export function projectSessionPatchRowFields(
|
||||
patch: SessionPatch,
|
||||
result: SessionPatchResult,
|
||||
): SessionPatchRowFact["fields"][] {
|
||||
const { entry } = result;
|
||||
const fields: SessionPatchRowFact["fields"][] = [];
|
||||
if (Object.hasOwn(patch, "model") && result.resolved) {
|
||||
fields.push({
|
||||
...result.resolved,
|
||||
...(entry.modelOverrideSource !== undefined
|
||||
? {
|
||||
modelOverrideSource:
|
||||
entry.modelOverrideSource === "default" ? null : entry.modelOverrideSource,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (typeof patch.archived === "boolean") {
|
||||
fields.push(projectSessionArchiveFields(patch.archived, entry));
|
||||
}
|
||||
if (patch.boardPresentation !== undefined) {
|
||||
fields.push({ boardPresentation: entry.boardPresentation });
|
||||
}
|
||||
if (patch.pinned !== undefined || patch.unread === false) {
|
||||
const pin = { pinned: entry.pinnedAt !== undefined, pinnedAt: entry.pinnedAt };
|
||||
const read = {
|
||||
unread: deriveSessionUnread(entry),
|
||||
lastReadAt: entry.lastReadAt,
|
||||
markedUnreadAt: entry.markedUnreadAt,
|
||||
};
|
||||
fields.push(
|
||||
patch.pinned === undefined ? read : patch.unread === false ? { ...pin, ...read } : pin,
|
||||
);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
|
@ -44,6 +44,18 @@ export type SessionPatchRowFact = {
|
|||
| SessionReadFields
|
||||
| (SessionPinFields & SessionReadFields)
|
||||
| SessionArchiveFields
|
||||
| Pick<
|
||||
GatewaySessionRow,
|
||||
| "model"
|
||||
| "modelProvider"
|
||||
| "modelOverrideSource"
|
||||
| "agentRuntime"
|
||||
| "runtimeSelectionLocked"
|
||||
| "contextWindow"
|
||||
| "contextWindows"
|
||||
| "thinkingLevel"
|
||||
| "thinkingLevels"
|
||||
>
|
||||
| { boardPresentation: GatewaySessionRow["boardPresentation"] };
|
||||
};
|
||||
export type PendingRowTarget = Readonly<{
|
||||
|
|
|
|||
|
|
@ -96,7 +96,13 @@ function createDashboardHarness(
|
|||
}
|
||||
const result = await patchReply;
|
||||
if (result && result.entry.sessionId === current.sessionId) {
|
||||
current = { ...current, ...result.entry };
|
||||
current = {
|
||||
...current,
|
||||
...(result.entry.updatedAt !== undefined ? { updatedAt: result.entry.updatedAt } : {}),
|
||||
...(Object.hasOwn(result.entry, "boardPresentation")
|
||||
? { boardPresentation: result.entry.boardPresentation }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue