mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix: gate chat session mutations exactly
This commit is contained in:
@@ -10,6 +10,7 @@ function createContext(configSnapshot: ConfigSnapshot | null): ApplicationContex
|
||||
return {
|
||||
gateway: {
|
||||
snapshot: {
|
||||
client: {} as GatewayBrowserClient,
|
||||
phase: "connected",
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: ["operator.admin", "operator.write"] },
|
||||
@@ -232,6 +233,34 @@ describe("ChatComposerCapabilityHost", () => {
|
||||
expect(props.webSearchBaseEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks tool override patches without exact sessions.patch access", async () => {
|
||||
const host = new ChatComposerCapabilityHost(vi.fn());
|
||||
const context = createContext({ runtimeConfig: {} });
|
||||
context.gateway.snapshot.hello = {
|
||||
auth: { role: "operator", scopes: ["operator.write"] },
|
||||
features: { methods: ["tools.effective"] },
|
||||
} as NonNullable<typeof context.gateway.snapshot.hello>;
|
||||
const request = vi.fn();
|
||||
const state = createState();
|
||||
state.client = { request } as unknown as GatewayBrowserClient;
|
||||
const session = { key: "main" } as GatewaySessionRow;
|
||||
|
||||
const props = host.props(context, state, session, "main");
|
||||
expect(props.mutationBlockedReason).toBeTruthy();
|
||||
const result = await (
|
||||
host as unknown as {
|
||||
patch: (
|
||||
context: ApplicationContext,
|
||||
state: ChatPageHost,
|
||||
next: { skills: Record<string, boolean> },
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>;
|
||||
}
|
||||
).patch(context, state, { skills: { release: true } });
|
||||
|
||||
expect(result).toEqual({ ok: false, error: props.mutationBlockedReason });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps Everywhere selectable while a missing session row blocks session submit", async () => {
|
||||
const host = new ChatComposerCapabilityHost(vi.fn());
|
||||
const context = createContext({ runtimeConfig: {} });
|
||||
@@ -340,7 +369,7 @@ describe("ChatComposerCapabilityHost", () => {
|
||||
const host = new ChatComposerCapabilityHost(notify);
|
||||
const context = createContext({ runtimeConfig: {} });
|
||||
context.gateway.snapshot.hello = {
|
||||
features: { methods: ["tools.effective"] },
|
||||
features: { methods: ["sessions.patch", "tools.effective"] },
|
||||
} as NonNullable<typeof context.gateway.snapshot.hello>;
|
||||
let stateReads = 0;
|
||||
Object.defineProperty(context.sessions, "state", {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
summarizeMcpServers,
|
||||
} from "../../lib/config/mcp-servers.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
|
||||
import {
|
||||
scopedAgentListParamsForSession,
|
||||
scopedAgentParamsForSession,
|
||||
@@ -300,8 +301,12 @@ export class ChatComposerCapabilityHost {
|
||||
if (!state.connected || !state.client) {
|
||||
return { ok: false, error: t("chat.composer.menu.offlineBlocked") };
|
||||
}
|
||||
if (!readGatewayOperatorAccess(context.gateway.snapshot).canWrite) {
|
||||
return { ok: false, error: t("chat.composer.menu.readOnlyBlocked") };
|
||||
const access = readSessionMethodAccess(context.gateway.snapshot, {
|
||||
method: "sessions.patch",
|
||||
params: { key: state.sessionKey, toolOverrides: next },
|
||||
});
|
||||
if (!access.allowed) {
|
||||
return { ok: false, error: access.reason };
|
||||
}
|
||||
const sessionKey = state.sessionKey;
|
||||
if (this.patchTokens.has(sessionKey)) {
|
||||
@@ -595,12 +600,16 @@ export class ChatComposerCapabilityHost {
|
||||
const toolsEffectiveError =
|
||||
effectiveToolsKey !== null && this.effectiveToolsErrorKey === effectiveToolsKey;
|
||||
const capabilitiesReady = gatewayAvailable && session !== undefined && runtimeConfig !== null;
|
||||
const toolPatchAccess = readSessionMethodAccess(context.gateway.snapshot, {
|
||||
method: "sessions.patch",
|
||||
params: { key: state.sessionKey, toolOverrides: null },
|
||||
});
|
||||
const mutationBlockedReason = !gatewayAvailable
|
||||
? t("chat.composer.menu.offlineBlocked")
|
||||
: !capabilitiesReady
|
||||
? t("common.loading")
|
||||
: !access.canWrite
|
||||
? t("chat.composer.menu.readOnlyBlocked")
|
||||
: !toolPatchAccess.allowed
|
||||
? toolPatchAccess.reason
|
||||
: this.patchTokens.has(state.sessionKey)
|
||||
? t("chat.composer.menu.savingBlocked")
|
||||
: null;
|
||||
|
||||
@@ -221,6 +221,29 @@ describe("renderChatComposer controls", () => {
|
||||
expect(onAbort).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disables an archived-session action with its mutation reason", () => {
|
||||
const onAction = vi.fn();
|
||||
const reason = "Operator write access is required.";
|
||||
const { container } = renderComposer({
|
||||
canSend: false,
|
||||
disabledBanner: {
|
||||
kind: "composer-replacement",
|
||||
text: "This session is archived. Unarchive it to continue the conversation.",
|
||||
actionLabel: "Unarchive",
|
||||
disabledReason: reason,
|
||||
onAction,
|
||||
},
|
||||
});
|
||||
|
||||
const action = container.querySelector<HTMLButtonElement>(
|
||||
".agent-chat__disabled-banner button",
|
||||
);
|
||||
expect(action?.disabled).toBe(true);
|
||||
expect(action?.title).toBe(reason);
|
||||
action?.click();
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the disabled composer mounted for a catalog read-only state", () => {
|
||||
const { container } = renderComposer({
|
||||
canSend: false,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
} from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import { hasOperatorWriteAccess, hasOperatorAdminAccess } from "../../app/operator-access.ts";
|
||||
import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import { listSessionCreators } from "../../components/session-owner-chip.ts";
|
||||
import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts";
|
||||
@@ -122,6 +122,18 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
method: "session.members.remove",
|
||||
requiredScope: "operator.write",
|
||||
});
|
||||
const renameAccess = row
|
||||
? readSessionMethodAccess(this.context.gateway.snapshot, {
|
||||
method: "sessions.patch",
|
||||
params: { key: row.key, label: null },
|
||||
})
|
||||
: null;
|
||||
const renameDisabledReason =
|
||||
this.state?.connected !== true || !renameAccess
|
||||
? t("sessionsView.actionRequiresConnection")
|
||||
: renameAccess.allowed
|
||||
? undefined
|
||||
: renameAccess.reason;
|
||||
return renderChatPaneHeader({
|
||||
paneId: this.paneId,
|
||||
narrow: this.narrow,
|
||||
@@ -148,9 +160,7 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
platform: this.headerPlatform,
|
||||
canReveal,
|
||||
copiedAction: this.headerCopiedAction,
|
||||
canRename:
|
||||
this.state?.connected === true &&
|
||||
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null),
|
||||
renameDisabledReason,
|
||||
terminalAction: renderCatalogTerminalButton(this.state, this.catalogSession),
|
||||
discussionAction: this.renderSessionDiscussionAction(),
|
||||
diffAction: renderSessionDiffToggle(sessionWorkspace),
|
||||
@@ -260,6 +270,14 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
}
|
||||
|
||||
protected beginHeaderRename(row: GatewaySessionRow): void {
|
||||
const access = readSessionMethodAccess(this.context.gateway.snapshot, {
|
||||
method: "sessions.patch",
|
||||
params: { key: row.key, label: null },
|
||||
});
|
||||
if (!access.allowed) {
|
||||
this.publishHeaderError(access.reason);
|
||||
return;
|
||||
}
|
||||
const customLabel = row.label?.trim() || null;
|
||||
this.headerRenameSessionKey = row.key;
|
||||
this.headerRenameInitialLabel = customLabel;
|
||||
@@ -294,6 +312,14 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
|
||||
if (!key || !state || unchangedDerivedTitle || unchangedLabel) {
|
||||
return;
|
||||
}
|
||||
const access = readSessionMethodAccess(this.context.gateway.snapshot, {
|
||||
method: "sessions.patch",
|
||||
params: { key, label },
|
||||
});
|
||||
if (!access.allowed) {
|
||||
this.publishHeaderError(access.reason);
|
||||
return;
|
||||
}
|
||||
void patchChatSessionLabel(state, this.context.sessions, key, label).catch((error: unknown) =>
|
||||
this.publishHeaderError(error),
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
projectSessionObserverDigest,
|
||||
resolveChatPaneObserverRunId,
|
||||
} from "../../lib/observer-digest.ts";
|
||||
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
|
||||
import { buildAgentMainSessionKey } from "../../lib/sessions/session-key.ts";
|
||||
import { renderBoardSessionSurface } from "./board-session-surface.ts";
|
||||
import { clearChatHistory } from "./chat-history.ts";
|
||||
@@ -79,6 +80,14 @@ export class ChatPane extends ChatPaneHeader {
|
||||
return html`<main class="app-shell app-shell--booting" aria-busy="true"></main>`;
|
||||
}
|
||||
const selectedSession = selectedChatSessionRow(state);
|
||||
const runtimePatchAccess = readSessionMethodAccess(this.context.gateway.snapshot, {
|
||||
method: "sessions.patch",
|
||||
params: { key: state.sessionKey, model: null },
|
||||
});
|
||||
const unarchiveAccess = readSessionMethodAccess(this.context.gateway.snapshot, {
|
||||
method: "sessions.patch",
|
||||
params: { key: state.sessionKey, archived: false },
|
||||
});
|
||||
const projectedObserverDigest = projectSessionObserverDigest(
|
||||
selectedSession?.key ?? state.sessionKey,
|
||||
selectedSession?.observerDigest,
|
||||
@@ -336,7 +345,12 @@ export class ChatPane extends ChatPaneHeader {
|
||||
kind: "composer-replacement",
|
||||
text: t("chat.archivedSessionDisabled"),
|
||||
actionLabel: t("common.unarchive"),
|
||||
onAction: () => void this.restoreArchivedSession(state.sessionKey),
|
||||
disabledReason: unarchiveAccess.allowed ? undefined : unarchiveAccess.reason,
|
||||
onAction: () => {
|
||||
if (unarchiveAccess.allowed) {
|
||||
void this.restoreArchivedSession(state.sessionKey);
|
||||
}
|
||||
},
|
||||
}
|
||||
: modelSetupRequired
|
||||
? createChatModelSetupBanner(() => this.context.navigate("model-setup"))
|
||||
@@ -395,17 +409,26 @@ export class ChatPane extends ChatPaneHeader {
|
||||
modelSelectionRuntimeId: selectedSession?.agentRuntime?.id,
|
||||
modelSwitching: Boolean(state.chatModelSwitchPromises[state.sessionKey]),
|
||||
modelsLoading: state.chatModelsLoading,
|
||||
mutationDisabledReason: runtimePatchAccess.allowed
|
||||
? undefined
|
||||
: runtimePatchAccess.reason,
|
||||
sending: state.chatSending,
|
||||
sessionKey: state.sessionKey,
|
||||
sessionsResult: state.sessionsResult,
|
||||
stream: state.chatStream,
|
||||
onRequestUpdate: () => state.requestUpdate?.(),
|
||||
onFastModeSelect: (next, targetSessionKey) =>
|
||||
switchChatFastMode(state, next, targetSessionKey),
|
||||
runtimePatchAccess.allowed
|
||||
? switchChatFastMode(state, next, targetSessionKey)
|
||||
: Promise.resolve(false),
|
||||
onModelSelect: (next, targetSessionKey) =>
|
||||
switchChatModel(state, next, targetSessionKey),
|
||||
runtimePatchAccess.allowed
|
||||
? switchChatModel(state, next, targetSessionKey)
|
||||
: Promise.resolve(false),
|
||||
onThinkingSelect: (next, targetSessionKey) =>
|
||||
switchChatThinkingLevel(state, next, targetSessionKey),
|
||||
runtimePatchAccess.allowed
|
||||
? switchChatThinkingLevel(state, next, targetSessionKey)
|
||||
: Promise.resolve(false),
|
||||
},
|
||||
onboarding: state.onboarding,
|
||||
settings: state.settings,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { selectApplicationSession } from "../../app/agent-selection.ts";
|
||||
import { clampText } from "../../lib/format.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
|
||||
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
|
||||
import {
|
||||
scopedSessionPullRequestKey,
|
||||
SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
|
||||
@@ -338,6 +339,16 @@ export abstract class ChatPaneSession extends ChatPaneSharing {
|
||||
if (!scope || scope.state.sessionKey !== sessionKey) {
|
||||
return;
|
||||
}
|
||||
const access = readSessionMethodAccess(scope.context.gateway.snapshot, {
|
||||
method: "sessions.patch",
|
||||
params: { key: sessionKey, archived: false },
|
||||
});
|
||||
if (!access.allowed) {
|
||||
scope.state.lastError = access.reason;
|
||||
scope.state.chatError = access.reason;
|
||||
scope.state.requestUpdate?.();
|
||||
return;
|
||||
}
|
||||
const agentId = parseAgentSessionKey(sessionKey)?.agentId ?? resolveChatAgentId(scope.state);
|
||||
let failure: string | null = null;
|
||||
try {
|
||||
|
||||
@@ -391,6 +391,53 @@ describe.each(mutations)("chat pane $name mutation connection ownership", (mutat
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["resolve", "reject"] as const)(
|
||||
"drops a stale same-key mutation when the replaced session later %s",
|
||||
async (completion) => {
|
||||
const response = createDeferred<unknown>();
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method !== mutation.method) {
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
}
|
||||
return response.promise;
|
||||
});
|
||||
const sessions = {
|
||||
refreshReplacement: vi.fn(),
|
||||
} as unknown as SessionCapability;
|
||||
const { pane: testPane, state } = createSharingTestChatPane({
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
sessions,
|
||||
});
|
||||
const pane = testPane as SharingPane;
|
||||
const stale = sessionRow();
|
||||
const pending = mutation.invoke(pane, stale);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
mutation.method,
|
||||
expect.objectContaining({ sessionKey: stale.key }),
|
||||
);
|
||||
|
||||
const replacement = { ...stale, sessionId: "session-replacement" };
|
||||
state.sessionsResult = sharingSessionsResult(replacement);
|
||||
const cacheKey = pane.sessionSharingCacheKey(replacement.key);
|
||||
const replacementState: ChatSessionSharingState = {
|
||||
loading: false,
|
||||
result: sharingResult(replacement),
|
||||
};
|
||||
pane.sessionSharingStates = new Map([[cacheKey, replacementState]]);
|
||||
|
||||
if (completion === "resolve") {
|
||||
response.resolve({});
|
||||
} else {
|
||||
response.reject(new Error("stale mutation failed"));
|
||||
}
|
||||
await pending;
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(sessions.refreshReplacement).not.toHaveBeenCalled();
|
||||
expect(pane.sessionSharingStates.get(cacheKey)).toBe(replacementState);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the current connection failure in the sharing cache", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === mutation.method) {
|
||||
|
||||
@@ -162,7 +162,10 @@ export abstract class ChatPaneSharing extends ChatPaneBase {
|
||||
}
|
||||
try {
|
||||
await scope.client.request("session.visibility.set", params);
|
||||
if (!this.isConnectionScopeCurrent(scope)) {
|
||||
if (
|
||||
!this.isConnectionScopeCurrent(scope) ||
|
||||
!this.currentSessionSharingRow(scope, currentRow)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await scope.sessions.refreshReplacement(agentId);
|
||||
@@ -172,7 +175,10 @@ export abstract class ChatPaneSharing extends ChatPaneBase {
|
||||
}
|
||||
await this.loadSessionSharing(refreshedRow, true);
|
||||
} catch (error) {
|
||||
if (!this.isConnectionScopeCurrent(scope)) {
|
||||
if (
|
||||
!this.isConnectionScopeCurrent(scope) ||
|
||||
!this.currentSessionSharingRow(scope, currentRow)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.setSessionSharingState(cacheKey, {
|
||||
@@ -211,16 +217,25 @@ export abstract class ChatPaneSharing extends ChatPaneBase {
|
||||
}
|
||||
try {
|
||||
await scope.client.request(method, params);
|
||||
if (!this.isConnectionScopeCurrent(scope)) {
|
||||
if (
|
||||
!this.isConnectionScopeCurrent(scope) ||
|
||||
!this.currentSessionSharingRow(scope, currentRow)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.loadSessionSharing(currentRow, true);
|
||||
if (!this.isConnectionScopeCurrent(scope)) {
|
||||
if (
|
||||
!this.isConnectionScopeCurrent(scope) ||
|
||||
!this.currentSessionSharingRow(scope, currentRow)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await scope.sessions.refreshReplacement(agentId);
|
||||
} catch (error) {
|
||||
if (!this.isConnectionScopeCurrent(scope)) {
|
||||
if (
|
||||
!this.isConnectionScopeCurrent(scope) ||
|
||||
!this.currentSessionSharingRow(scope, currentRow)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.setSessionSharingState(cacheKey, {
|
||||
|
||||
@@ -36,6 +36,7 @@ export type TestChatPane = HTMLElement & {
|
||||
connectedCallback: () => void;
|
||||
connectionGeneration: number;
|
||||
createSession: () => Promise<boolean>;
|
||||
restoreArchivedSession: (sessionKey: string) => Promise<void>;
|
||||
disconnectedCallback: () => void;
|
||||
acceptTaskSuggestion: (suggestion: TaskSuggestion) => Promise<void>;
|
||||
handleDocumentKeydown: (event: KeyboardEvent) => void;
|
||||
|
||||
@@ -5023,6 +5023,38 @@ describe("chat model controls", () => {
|
||||
expect(onModelSelect).toHaveBeenCalledWith(modelOption?.dataset.chatModelOption, "main");
|
||||
});
|
||||
|
||||
it("disables runtime overrides with the exact mutation reason", () => {
|
||||
const { state } = createChatHeaderState({
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
models: [
|
||||
{ id: "gpt-5.4", name: "GPT-5.4", provider: "openai" },
|
||||
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" },
|
||||
],
|
||||
});
|
||||
const onFastModeSelect = vi.fn(async () => true);
|
||||
const onModelSelect = vi.fn(async () => true);
|
||||
const onThinkingSelect = vi.fn(async () => true);
|
||||
const reason = "Operator admin access is required.";
|
||||
const container = renderModelControls(state, {
|
||||
mutationDisabledReason: reason,
|
||||
onFastModeSelect,
|
||||
onModelSelect,
|
||||
onThinkingSelect,
|
||||
});
|
||||
|
||||
const modelSelect = getChatModelSelect(container);
|
||||
expect(modelSelect.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(modelSelect.getAttribute("title")).toBe(reason);
|
||||
modelSelect.click();
|
||||
container.querySelector<HTMLButtonElement>("[data-chat-speed-toggle]")?.click();
|
||||
getThinkingSlider(container)?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(onFastModeSelect).not.toHaveBeenCalled();
|
||||
expect(onModelSelect).not.toHaveBeenCalled();
|
||||
expect(onThinkingSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the inherited default muted and resets an override from the provenance row", () => {
|
||||
const { state } = createChatHeaderState({
|
||||
model: null,
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
type ChatComposerDisabledBannerContent = {
|
||||
text: string;
|
||||
actionLabel: string;
|
||||
disabledReason?: string;
|
||||
onAction: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -115,7 +115,13 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
? html`
|
||||
<div class="agent-chat__disabled-banner callout info callout--action" role="status">
|
||||
<span class="callout__content">${props.disabledBanner.text}</span>
|
||||
<button type="button" class="btn btn--xs" @click=${props.disabledBanner.onAction}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--xs"
|
||||
?disabled=${Boolean(props.disabledBanner.disabledReason)}
|
||||
title=${props.disabledBanner.disabledReason ?? nothing}
|
||||
@click=${props.disabledBanner.onAction}
|
||||
>
|
||||
${props.disabledBanner.actionLabel}
|
||||
</button>
|
||||
${props.disabledBanner.kind === "composer-replacement" && showAbortableUi
|
||||
|
||||
@@ -42,6 +42,7 @@ export type ChatModelControlsProps = {
|
||||
modelSelectionRuntimeId?: string;
|
||||
modelSwitching: boolean;
|
||||
modelsLoading?: boolean;
|
||||
mutationDisabledReason?: string;
|
||||
showFastMode?: boolean;
|
||||
sending: boolean;
|
||||
sessionKey: string;
|
||||
@@ -254,17 +255,20 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
|
||||
busy ||
|
||||
props.modelSwitching ||
|
||||
(props.modelsLoading && selectOptions.length === 0) ||
|
||||
!props.gatewayAvailable;
|
||||
!props.gatewayAvailable ||
|
||||
Boolean(props.mutationDisabledReason);
|
||||
const thinkingDisabled =
|
||||
!props.connected ||
|
||||
busy ||
|
||||
props.modelSwitching ||
|
||||
!props.gatewayAvailable ||
|
||||
(thinking.options.length === 0 && thinking.currentOverride === "");
|
||||
(thinking.options.length === 0 && thinking.currentOverride === "") ||
|
||||
Boolean(props.mutationDisabledReason);
|
||||
return renderChatModelReasoningSelect({
|
||||
defaultModelLabel: formatCombinedPickerModelLabel(pickerDefaultLabel),
|
||||
disabled,
|
||||
fastMode,
|
||||
disabledReason: props.mutationDisabledReason,
|
||||
fastMode: { ...fastMode, disabled: fastMode.disabled || disabled },
|
||||
modelSelectionLocked: props.modelSelectionLocked === true,
|
||||
modelOptions,
|
||||
onRequestUpdate: props.onRequestUpdate,
|
||||
@@ -369,6 +373,7 @@ function renderChatModelReasoningSelect(params: {
|
||||
defaultModelLabel: string;
|
||||
fastMode: ChatFastModeSelectState;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
modelSelectionLocked: boolean;
|
||||
modelOptions: ChatModelProviderOption[];
|
||||
selectedModelValue: string;
|
||||
@@ -388,6 +393,7 @@ function renderChatModelReasoningSelect(params: {
|
||||
const {
|
||||
defaultModelLabel,
|
||||
disabled,
|
||||
disabledReason,
|
||||
fastMode,
|
||||
modelSelectionLocked,
|
||||
modelOptions,
|
||||
@@ -641,6 +647,7 @@ function renderChatModelReasoningSelect(params: {
|
||||
"chat.selectors.thinkingLevel",
|
||||
)}: ${triggerTitle}"
|
||||
aria-disabled=${disabled ? "true" : "false"}
|
||||
title=${disabledReason ?? triggerTitle}
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -85,7 +85,7 @@ function mount(patch: Partial<ChatPaneHeaderProps> = {}) {
|
||||
platform: "darwin",
|
||||
canReveal: true,
|
||||
copiedAction: null,
|
||||
canRename: true,
|
||||
renameDisabledReason: undefined,
|
||||
terminalAction: nothing,
|
||||
discussionAction: nothing,
|
||||
diffAction: nothing,
|
||||
@@ -330,11 +330,14 @@ describe("chat pane header", () => {
|
||||
});
|
||||
|
||||
it("keeps read-only gateway session titles static", () => {
|
||||
const { container } = mount({ canRename: false });
|
||||
const { container } = mount({ renameDisabledReason: "Operator write access is required." });
|
||||
expect(container.querySelector(".chat-pane__session-title-button")).toBeNull();
|
||||
expect(container.querySelector(".chat-pane__session-title")?.textContent).toContain(
|
||||
"Session title",
|
||||
);
|
||||
expect(container.querySelector(".chat-pane__session-title")?.getAttribute("title")).toBe(
|
||||
"Operator write access is required.",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows copied feedback on the workspace chip", () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ type ChatPaneHeaderProps = {
|
||||
platform: string | null;
|
||||
canReveal: boolean;
|
||||
copiedAction: ChatPaneHeaderAction | null;
|
||||
canRename: boolean;
|
||||
renameDisabledReason?: string;
|
||||
terminalAction: TemplateResult | typeof nothing;
|
||||
discussionAction: TemplateResult | typeof nothing;
|
||||
diffAction: TemplateResult | typeof nothing;
|
||||
@@ -299,8 +299,12 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
|
||||
}}
|
||||
@blur=${props.onCommitRename}
|
||||
/>`
|
||||
: props.catalog || !props.session || !props.canRename
|
||||
? html`<span class="chat-pane__session-title" title=${props.title}>${props.title}</span>`
|
||||
: props.catalog || !props.session || props.renameDisabledReason
|
||||
? html`<span
|
||||
class="chat-pane__session-title"
|
||||
title=${props.renameDisabledReason ?? props.title}
|
||||
>${props.title}</span
|
||||
>`
|
||||
: html`<button
|
||||
class="chat-pane__session-title chat-pane__session-title-button"
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user