From a45feb391519c554c5f3fcb3f828cf5452adf7e5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 2 Aug 2026 06:54:37 +0100 Subject: [PATCH] fix: gate chat session mutations exactly --- .../chat-composer-capability-host.test.ts | 31 +++++++++++- .../chat/chat-composer-capability-host.ts | 17 +++++-- ui/src/pages/chat/chat-composer.test.ts | 23 +++++++++ ui/src/pages/chat/chat-pane-header.ts | 34 ++++++++++++-- ui/src/pages/chat/chat-pane-render.ts | 31 ++++++++++-- ui/src/pages/chat/chat-pane-session.ts | 11 +++++ ui/src/pages/chat/chat-pane-sharing.test.ts | 47 +++++++++++++++++++ ui/src/pages/chat/chat-pane-sharing.ts | 25 ++++++++-- ui/src/pages/chat/chat-pane.test-support.ts | 1 + ui/src/pages/chat/chat-view.test.ts | 32 +++++++++++++ .../chat/components/chat-composer-types.ts | 1 + .../chat/components/chat-composer-view.ts | 8 +++- .../chat/components/chat-model-controls.ts | 13 +++-- .../chat/components/chat-pane-header.test.ts | 7 ++- .../pages/chat/components/chat-pane-header.ts | 10 ++-- 15 files changed, 264 insertions(+), 27 deletions(-) diff --git a/ui/src/pages/chat/chat-composer-capability-host.test.ts b/ui/src/pages/chat/chat-composer-capability-host.test.ts index 77ce80229258..0a67b99a0b1e 100644 --- a/ui/src/pages/chat/chat-composer-capability-host.test.ts +++ b/ui/src/pages/chat/chat-composer-capability-host.test.ts @@ -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; + 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 }, + ) => 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; let stateReads = 0; Object.defineProperty(context.sessions, "state", { diff --git a/ui/src/pages/chat/chat-composer-capability-host.ts b/ui/src/pages/chat/chat-composer-capability-host.ts index 3bd504a05ded..ba8b1be9628d 100644 --- a/ui/src/pages/chat/chat-composer-capability-host.ts +++ b/ui/src/pages/chat/chat-composer-capability-host.ts @@ -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; diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index 1060176a3b8d..4b486c6524b7 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -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( + ".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, diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index abb0af76f4f4..c25aa69a76ac 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -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), ); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 6febadfcd947..bb62904e7731 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -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`
`; } 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, diff --git a/ui/src/pages/chat/chat-pane-session.ts b/ui/src/pages/chat/chat-pane-session.ts index 4a9440304509..560939d8a301 100644 --- a/ui/src/pages/chat/chat-pane-session.ts +++ b/ui/src/pages/chat/chat-pane-session.ts @@ -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 { diff --git a/ui/src/pages/chat/chat-pane-sharing.test.ts b/ui/src/pages/chat/chat-pane-sharing.test.ts index 964251c9b365..5a9a0fbdb674 100644 --- a/ui/src/pages/chat/chat-pane-sharing.test.ts +++ b/ui/src/pages/chat/chat-pane-sharing.test.ts @@ -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(); + 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) { diff --git a/ui/src/pages/chat/chat-pane-sharing.ts b/ui/src/pages/chat/chat-pane-sharing.ts index 33f7d066e8f9..86ba3f580a73 100644 --- a/ui/src/pages/chat/chat-pane-sharing.ts +++ b/ui/src/pages/chat/chat-pane-sharing.ts @@ -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, { diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index 00ad322f4e92..16b21144260e 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -36,6 +36,7 @@ export type TestChatPane = HTMLElement & { connectedCallback: () => void; connectionGeneration: number; createSession: () => Promise; + restoreArchivedSession: (sessionKey: string) => Promise; disconnectedCallback: () => void; acceptTaskSuggestion: (suggestion: TaskSuggestion) => Promise; handleDocumentKeydown: (event: KeyboardEvent) => void; diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 5211c834fba5..30532c5b55a9 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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("[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, diff --git a/ui/src/pages/chat/components/chat-composer-types.ts b/ui/src/pages/chat/components/chat-composer-types.ts index eb69719557a1..9cbd762cd1a2 100644 --- a/ui/src/pages/chat/components/chat-composer-types.ts +++ b/ui/src/pages/chat/components/chat-composer-types.ts @@ -24,6 +24,7 @@ import type { type ChatComposerDisabledBannerContent = { text: string; actionLabel: string; + disabledReason?: string; onAction: () => void; }; diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index 56ec39407b9b..3da8c2ef8ee7 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -115,7 +115,13 @@ export function renderChatComposerView(context: ChatComposerViewContext) { ? html`
${props.disabledBanner.text} - ${props.disabledBanner.kind === "composer-replacement" && showAbortableUi diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index 838ca4164a76..20504b1f3155 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -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(); diff --git a/ui/src/pages/chat/components/chat-pane-header.test.ts b/ui/src/pages/chat/components/chat-pane-header.test.ts index c311f0a3675d..272338f25600 100644 --- a/ui/src/pages/chat/components/chat-pane-header.test.ts +++ b/ui/src/pages/chat/components/chat-pane-header.test.ts @@ -85,7 +85,7 @@ function mount(patch: Partial = {}) { 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", () => { diff --git a/ui/src/pages/chat/components/chat-pane-header.ts b/ui/src/pages/chat/components/chat-pane-header.ts index 79b3464c0599..2a0983e33fc3 100644 --- a/ui/src/pages/chat/components/chat-pane-header.ts +++ b/ui/src/pages/chat/components/chat-pane-header.ts @@ -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`${props.title}` + : props.catalog || !props.session || props.renameDisabledReason + ? html`${props.title}` : html`