From 038b40fbfe926f5a94552773e1c48936ca7dcd6b Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Fri, 23 Oct 2026 18:47:47 +0800 Subject: [PATCH 1/4] feat(web): simplify normal conversation composer controls --- apps/web/e2e/app.spec.ts | 57 ++-- ...-normal-conversation-feed-composer.spec.ts | 76 +++-- apps/web/scripts/check-ui2-icons.mjs | 1 + .../features/workspace/ConversationHome.tsx | 13 +- .../workspace/ConversationModelControl.tsx | 183 ++++++++++++ .../ConversationPermissionControl.tsx | 71 +++++ .../workspace/NormalConversationComposer.tsx | 278 ++---------------- apps/web/src/styles.css | 1 + .../src/styles/conversation-model-control.css | 267 +++++++++++++++++ .../conversation-composer-unification.md | 4 +- 10 files changed, 621 insertions(+), 330 deletions(-) create mode 100644 apps/web/src/features/workspace/ConversationModelControl.tsx create mode 100644 apps/web/src/features/workspace/ConversationPermissionControl.tsx create mode 100644 apps/web/src/styles/conversation-model-control.css diff --git a/apps/web/e2e/app.spec.ts b/apps/web/e2e/app.spec.ts index b2bef32d7..3634c1c66 100644 --- a/apps/web/e2e/app.spec.ts +++ b/apps/web/e2e/app.spec.ts @@ -1997,50 +1997,35 @@ test("conversation home model picker uses the model workbench default without ch await navigation.getByRole("button", { name: "New conversation" }).click(); const home = page.getByLabel("Conversation home"); await expect(home.getByRole("button", { name: "Selected model gemini-2.5-pro" })).toBeVisible(); - await expect(home.getByRole("button", { name: "Selected thinking mode Off" })).toBeVisible(); - await home.getByRole("button", { name: "Selected thinking mode Off" }).click(); - await expect(page.getByRole("menu", { name: "Thinking mode" }).getByRole("menuitemradio")).toHaveText(["Off", "Minimal", "Low", "Medium", "High", "XHigh"]); - await page.keyboard.press("Escape"); - - await home.getByRole("button", { name: "Selected model gemini-2.5-pro" }).click(); - const modelMenu = page.locator(".conversation-model-menu"); - await expect(modelMenu).toBeVisible(); - await expect(modelMenu).toHaveAttribute("role", "menu"); + const modelTrigger = home.getByRole("button", { name: "Selected model gemini-2.5-pro" }); + await expect(modelTrigger).toContainText("Off"); + await modelTrigger.click(); + const reasoning = page.getByRole("dialog", { name: "Thinking mode" }); + await expect(reasoning.getByRole("slider")).toHaveAttribute("max", "5"); + await reasoning.getByRole("button", { name: "Model options" }).click(); + const modelMenu = page.getByRole("dialog", { name: "Model options" }); await expect(modelMenu.locator(".conversation-model-option")).toHaveCount(4); await expect(modelMenu.locator(".conversation-model-option").first()).toContainText("gemini-2.5-pro"); - await expect(modelMenu.locator(".conversation-model-option").first()).toContainText("Default"); + await expect(modelMenu.locator(".conversation-model-default-option")).toContainText("Default"); await expect(modelMenu).not.toContainText("gpt-image-2"); - await expect(modelMenu).not.toContainText("gemini/"); - await expect(modelMenu).not.toContainText("anthropic/"); - const modelSearch = modelMenu.getByRole("textbox", { name: "Search models" }); - await modelSearch.focus(); - await expect.poll(() => modelMenu.locator(".conversation-model-search").evaluate((searchField) => { - const fieldRect = searchField.getBoundingClientRect(); - const inputRect = searchField.querySelector("input")?.getBoundingClientRect(); - if (!inputRect) { - return false; - } - return inputRect.left >= fieldRect.left && - inputRect.right <= fieldRect.right && - inputRect.top >= fieldRect.top && - inputRect.bottom <= fieldRect.bottom; - })).toBe(true); + await expect(modelSearch).toBeFocused(); await modelSearch.fill("gpt-4o"); await expect(modelMenu.locator(".conversation-model-option")).toHaveCount(1); - await modelMenu.getByRole("menuitemradio", { name: /cliproxy-gpt-4o/ }).click(); + await modelMenu.getByRole("button", { name: /cliproxy-gpt-4o/ }).click(); await expect(home.getByRole("button", { name: "Selected model cliproxy-gpt-4o" })).toBeVisible(); - await expect(home.getByRole("button", { name: /Selected thinking mode/ })).toHaveCount(0); - await home.getByRole("button", { name: "Selected model cliproxy-gpt-4o" }).click(); + await expect(modelMenu).toBeVisible(); + await expect(modelMenu.getByRole("slider")).toHaveCount(0); await modelSearch.fill("sonnet"); - await expect(modelMenu.locator(".conversation-model-option")).toHaveCount(1); - await expect(modelMenu.locator(".conversation-model-option")).toContainText("claude-sonnet-4"); - await modelMenu.getByRole("menuitemradio", { name: /claude-sonnet-4/ }).click(); - await expect(home.getByRole("button", { name: "Selected model claude-sonnet-4" })).toBeVisible(); - await home.getByRole("button", { name: "Selected thinking mode Off" }).click(); - await page.getByRole("menu", { name: "Thinking mode" }).getByRole("menuitemradio", { name: "High", exact: true }).click(); - await expect(home.getByRole("button", { name: "Selected thinking mode High" })).toBeVisible(); + await modelMenu.getByRole("button", { name: /claude-sonnet-4/ }).click(); + const sonnet = home.getByRole("button", { name: "Selected model claude-sonnet-4" }); + await sonnet.click(); + await reasoning.getByRole("slider").focus(); + await reasoning.getByRole("slider").press("End"); + await reasoning.getByRole("slider").press("ArrowLeft"); + await expect(sonnet).toContainText("High"); + await page.keyboard.press("Escape"); await home.getByRole("textbox", { name: "Conversation message" }).fill("Use Sonnet for this conversation"); await home.getByRole("button", { name: "Send" }).click(); @@ -2054,7 +2039,7 @@ test("conversation home model picker uses the model workbench default without ch await navigation.locator(".sidebar-recent-row").filter({ hasText: "Use Sonnet for this conversation" }).click(); await expect(conversationPage.getByRole("button", { name: "Selected model claude-sonnet-4" })).toBeVisible(); - await expect(conversationPage.getByRole("button", { name: "Selected thinking mode High" })).toBeVisible(); + await expect(conversationPage.getByRole("button", { name: "Selected model claude-sonnet-4" })).toContainText("High"); }); test("settings keeps configuration tools embedded while Work Board stays hidden from global navigation", async ({ page }) => { diff --git a/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts b/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts index 008eaa42b..0cca8d46f 100644 --- a/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts +++ b/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts @@ -106,6 +106,7 @@ test("normal conversation composer uses Radix menus and preserves draft, upload, await prepareSurface(page, "light", 1440, 960); const runtimeErrors = collectRuntimeErrors(page); const state = createBaseState({ approvalStatus: "expired" }); + state.workspaceSettings.defaultReasoningSummary = "concise"; installProviders(state); installStableConversationFixture(state); state.conversationStreamDelayMs = 650; @@ -125,43 +126,56 @@ test("normal conversation composer uses Radix menus and preserves draft, upload, await permissionTrigger.click(); const permissionMenu = page.locator('[data-ling-overlay="menu"]').filter({ has: page.getByRole("menuitemradio", { name: /Workspace write/ }) }); await expect(permissionMenu).toBeVisible(); - await expect(permissionMenu.getByRole("menuitemradio")).toHaveCount(3); + await expect(permissionMenu.getByRole("group", { name: "Sandbox settings" }).getByRole("menuitemradio")).toHaveCount(3); + await expect(permissionMenu.getByRole("group", { name: "Authorization mode" }).getByRole("menuitemradio")).toHaveCount(3); + await permissionMenu.getByRole("menuitemradio", { name: "Request approval", exact: true }).click(); + await permissionTrigger.click(); + await expect(permissionMenu.getByRole("menuitemradio", { name: "Request approval", exact: true })).toBeChecked(); await page.keyboard.press("Escape"); await expect(permissionMenu).toHaveCount(0); await expect(permissionTrigger).toBeFocused(); - const thinkingTrigger = composer.getByRole("button", { name: "Selected thinking mode Off" }); - await thinkingTrigger.click(); - const thinkingMenu = page.locator(".conversation-thinking-menu"); - await expect(thinkingMenu).toHaveAttribute("data-ling-overlay", "menu"); - await expect(thinkingMenu.getByRole("menuitemradio")).toHaveText(["Off", "Minimal", "Low", "Medium", "High", "XHigh"]); - await thinkingMenu.getByRole("menuitemradio", { name: "High", exact: true }).click(); - const activeThinkingTrigger = composer.getByRole("button", { name: "Selected thinking mode High" }); - await expect(activeThinkingTrigger).toBeFocused(); - await expectCompactMutedComposerControl(permissionTrigger); - await expectCompactMutedComposerControl(activeThinkingTrigger); - const composerControlStyles = await Promise.all([permissionTrigger, activeThinkingTrigger].map((control) => control.evaluate((element) => { - const style = getComputedStyle(element); - return { - color: style.color, - fontSize: style.fontSize, - fontWeight: style.fontWeight, - }; - }))); - expect(composerControlStyles[1]).toEqual(composerControlStyles[0]); - - const modelTrigger = composer.getByRole("button", { name: /Selected model gpt-5\.5/ }); + await expect(composer.getByRole("button", { name: /Selected thinking mode|Selected reasoning summary|Selected authorization mode/ })).toHaveCount(0); + const modelTrigger = composer.getByRole("button", { name: "Selected model gpt-5.5" }); + await expect(modelTrigger).toContainText("Off"); await modelTrigger.click(); - const modelMenu = page.locator(".conversation-model-menu"); - await expect(modelMenu).toHaveAttribute("data-ling-overlay", "menu"); + const reasoningPanel = page.getByRole("dialog", { name: "Thinking mode" }); + const slider = reasoningPanel.getByRole("slider", { name: "Thinking mode" }); + await expect(reasoningPanel).toHaveAttribute("data-ling-overlay", "popover"); + await slider.focus(); + await slider.press("End"); + await expect(slider).toHaveAttribute("aria-valuetext", "XHigh"); + await slider.press("ArrowLeft"); + await expect(slider).toHaveAttribute("aria-valuetext", "High"); + await expect(modelTrigger).toContainText("High"); + await expect(modelTrigger).toHaveAccessibleName("Selected model gpt-5.5 · High"); + await expectCompactMutedComposerControl(permissionTrigger); + await expectCompactMutedComposerControl(modelTrigger); + await captureEvidence(page, "1440x960-ling-light-normal-composer-reasoning.png"); + await reasoningPanel.getByRole("button", { name: "Model options" }).click(); + const modelMenu = page.getByRole("dialog", { name: "Model options" }); const modelSearch = modelMenu.getByRole("textbox", { name: "Search models" }); await expect(modelSearch).toBeFocused(); await expectInputContained(modelSearch, modelMenu.locator(".conversation-model-search")); await captureEvidence(page, "1440x960-ling-light-normal-composer-model-menu.png"); + await modelSearch.fill("nothing matches this query"); + await expect(modelMenu).toContainText("No matching models"); await modelSearch.fill("sonnet"); - await expect(modelMenu.getByRole("menuitemradio")).toHaveCount(1); - await modelMenu.getByRole("menuitemradio", { name: /claude-sonnet-4/ }).click(); - await expect(composer.getByRole("button", { name: /Selected model claude-sonnet-4/ })).toBeFocused(); + await expect(modelMenu.locator(".conversation-model-option")).toHaveCount(1); + await modelMenu.getByRole("button", { name: /claude-sonnet-4/ }).click(); + const sonnetTrigger = composer.getByRole("button", { name: "Selected model claude-sonnet-4" }); + await expect(sonnetTrigger).toBeFocused(); + await expect(sonnetTrigger).toContainText("High"); + await sonnetTrigger.click(); + await expect(slider).toHaveAttribute("max", "2"); + await reasoningPanel.getByRole("button", { name: "Reset", exact: true }).click(); + await expect(slider).toHaveAttribute("aria-valuetext", "Off"); + await expect(reasoningPanel.getByRole("button", { name: "Reset", exact: true })).toBeDisabled(); + await slider.focus(); + await slider.press("End"); + await expect(slider).toHaveAttribute("aria-valuetext", "High"); + await page.keyboard.press("Escape"); + await expect(sonnetTrigger).toBeFocused(); await composer.locator('input[type="file"]').setInputFiles({ name: "composer-evidence.txt", @@ -172,7 +186,15 @@ test("normal conversation composer uses Radix menus and preserves draft, upload, await expect(pendingAssets).toContainText("composer-evidence.txt"); await expect(input).toHaveAttribute("data-value", "Keep this draft while menus open"); + const submission = page.waitForRequest((request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/conversations/stream")); await composer.getByRole("button", { name: "Send" }).click(); + expect((await submission).postDataJSON()).toMatchObject({ + providerPresetId: "provider_ui2_normal_sonnet", + thinkingLevel: "high", + reasoningSummary: "concise", + authorizationMode: "ask", + workspaceAccessMode: "workspace_write", + }); const send = composer.getByRole("button", { name: "Send" }); await expect(send).toBeDisabled(); await expect(send.locator(".ling-ui-spinner")).toHaveCount(1); diff --git a/apps/web/scripts/check-ui2-icons.mjs b/apps/web/scripts/check-ui2-icons.mjs index 9e1e983a2..ecdaf5318 100644 --- a/apps/web/scripts/check-ui2-icons.mjs +++ b/apps/web/scripts/check-ui2-icons.mjs @@ -60,6 +60,7 @@ const typedActionOwners = [ "src/features/workspace/ChannelHeaderToolbar.tsx", "src/features/workspace/ChannelNameField.tsx", "src/features/workspace/ChannelTeammateManagerPanel.tsx", + "src/features/workspace/ConversationModelControl.tsx", "src/features/workspace/NormalConversationComposer.tsx", "src/features/workspace/NormalConversationFeed.tsx", "src/features/workspace/ThreadReplyPanel.tsx", diff --git a/apps/web/src/features/workspace/ConversationHome.tsx b/apps/web/src/features/workspace/ConversationHome.tsx index 2110570b3..1006c7cee 100644 --- a/apps/web/src/features/workspace/ConversationHome.tsx +++ b/apps/web/src/features/workspace/ConversationHome.tsx @@ -705,7 +705,10 @@ export function ConversationHome({ return; } setSelectedProviderPresetId(option.id); - setSelectedThinkingLevel("off"); + setSelectedThinkingLevel((current) => { + const supported = normalConversationThinkingOptions(option.thinkingModes ?? []); + return supported.includes(current) ? current : "off"; + }); actionFeedback.clear("validation"); } @@ -717,12 +720,6 @@ export function ConversationHome({ actionFeedback.clear("validation"); } - function selectReasoningSummary(mode: ReasoningSummaryMode): void { - if (composerBusy || activeReply) return; - setSelectedReasoningSummary(mode); - actionFeedback.clear("validation"); - } - const composerNode = ( onStopConversation(activeReply) : undefined} /> diff --git a/apps/web/src/features/workspace/ConversationModelControl.tsx b/apps/web/src/features/workspace/ConversationModelControl.tsx new file mode 100644 index 000000000..e3e0fbe44 --- /dev/null +++ b/apps/web/src/features/workspace/ConversationModelControl.tsx @@ -0,0 +1,183 @@ +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { ArrowLeft, Check, ChevronDown, ChevronRight, RotateCcw, Search } from "lucide-react"; +import { useI18n } from "../../i18n.js"; +import { Button, IconButton, Input, Popover, PopoverContent, PopoverTrigger } from "../../shared/ui/index.js"; +import type { ProviderThinkingMode } from "../../types.js"; +import { normalConversationThinkingLabel, normalConversationThinkingOptions } from "./normal-conversation-composer-options.js"; +import type { ConversationHomeModelOption } from "./normal-conversation-types.js"; + +interface ConversationModelControlProps { + readonly modelOptions: readonly ConversationHomeModelOption[]; + readonly selectedProviderPresetId: string; + readonly defaultProviderPresetId: string; + readonly defaultProviderLabel: string; + readonly thinkingLevel: ProviderThinkingMode; + readonly disabled: boolean; + readonly onModelChange: (option: ConversationHomeModelOption) => void; + readonly onThinkingLevelChange: (level: ProviderThinkingMode) => void; +} + +export function ConversationModelControl({ + modelOptions, selectedProviderPresetId, defaultProviderPresetId, defaultProviderLabel, + thinkingLevel, disabled, onModelChange, onThinkingLevelChange, +}: ConversationModelControlProps): React.JSX.Element { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [view, setView] = useState<"reasoning" | "models">("reasoning"); + const [search, setSearch] = useState(""); + const searchRef = useRef(null); + const modelSwitchRef = useRef(null); + const selectedId = selectedProviderPresetId || defaultProviderPresetId; + const selectedModel = modelOptions.find((option) => option.id === selectedId); + const defaultModel = modelOptions.find((option) => option.id === defaultProviderPresetId); + // Routing details stay in the model list; the composer only needs the model name. + const modelLabel = selectedModel?.model || defaultProviderLabel; + const levels = useMemo(() => normalConversationThinkingOptions(selectedModel?.thinkingModes ?? []), [selectedModel?.thinkingModes]); + const supportsReasoning = levels.length > 0; + const levelIndex = Math.max(0, levels.indexOf(thinkingLevel)); + const levelLabel = normalConversationThinkingLabel(levels[levelIndex] ?? "off", t); + const visibleModels = modelOptions.filter((option) => + [option.model, option.label, option.displayName, option.providerId].join(" ").toLowerCase().includes(search.trim().toLowerCase()), + ); + + useEffect(() => { + if (disabled) setOpen(false); + }, [disabled]); + + useEffect(() => { + if (!open || view !== "models") return; + const frame = requestAnimationFrame(() => searchRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [open, view]); + + function selectModel(option: ConversationHomeModelOption): void { + if (disabled) return; + onModelChange(option); + setOpen(false); + } + + return ( + { + setOpen(nextOpen && !disabled); + setView(supportsReasoning ? "reasoning" : "models"); + setSearch(""); + }}> + + + + { + if (view !== "models" || !supportsReasoning) return; + event.preventDefault(); + setView("reasoning"); + setSearch(""); + requestAnimationFrame(() => modelSwitchRef.current?.focus()); + }} + > + {view === "reasoning" ? ( + <> +
+ + onThinkingLevelChange("off")} + /> +
+
+ + { + const level = levels[Number(event.currentTarget.value)]; + if (level && !disabled) onThinkingLevelChange(level); + }} + /> +
+ + ) : ( + <> +
+ {supportsReasoning ? { setView("reasoning"); setSearch(""); requestAnimationFrame(() => modelSwitchRef.current?.focus()); }} /> : null} + {t("Model options")} +
+ } + value={search} + onChange={(event) => setSearch(event.target.value)} + placeholder={t("Search models")} + aria-label={t("Search models")} + /> +
+ {!search.trim() && defaultModel ? ( + + ) : null} + {visibleModels.map((option) => ( + + ))} + {visibleModels.length === 0 ? {t("No matching models")} : null} +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/features/workspace/ConversationPermissionControl.tsx b/apps/web/src/features/workspace/ConversationPermissionControl.tsx new file mode 100644 index 000000000..c69e4c9f8 --- /dev/null +++ b/apps/web/src/features/workspace/ConversationPermissionControl.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; +import { Check, ChevronDown, Eye, Shield, ShieldCheck, SlidersHorizontal } from "lucide-react"; +import { useI18n } from "../../i18n.js"; +import { conversationAuthorizationModeMenuOptions, conversationPermissionPresetMenuOptions } from "../../lib/session-permissions.js"; +import { Button, Menu, MenuContent, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger } from "../../shared/ui/index.js"; +import type { AuthorizationMode, PermissionPreset } from "../../types.js"; + +const permissionIcons = { read_only: Eye, workspace_collaboration: Shield, full_access: ShieldCheck, custom: SlidersHorizontal }; + +export function ConversationPermissionControl({ + authorizationMode, permissionPreset, fullAccessConfirmed, disabled, onAuthorizationModeChange, onPermissionPresetChange, +}: { + readonly authorizationMode: AuthorizationMode; + readonly permissionPreset: PermissionPreset; + readonly fullAccessConfirmed: boolean; + readonly disabled: boolean; + readonly onAuthorizationModeChange: (mode: AuthorizationMode) => void; + readonly onPermissionPresetChange: (preset: PermissionPreset) => void; +}): React.JSX.Element { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const permissions = conversationPermissionPresetMenuOptions(t, fullAccessConfirmed); + const authorizationModes = conversationAuthorizationModeMenuOptions(t); + const selectedPermission = permissions.find((option) => option.preset === permissionPreset); + const selectedAuthorization = authorizationModes.find((option) => option.mode === authorizationMode); + const Icon = permissionIcons[permissionPreset]; + + useEffect(() => { if (disabled) setOpen(false); }, [disabled]); + + return ( + setOpen(value && !disabled)}> + + + + + + {permissions.map((option) => { + const OptionIcon = permissionIcons[option.preset]; + return { + event.preventDefault(); + if (disabled) return; + if (permissionPreset !== option.preset) onPermissionPresetChange(option.preset); + setOpen(false); + }}>{option.label}{option.preset === permissionPreset ? : null}; + })} + + + {t("Authorization mode")} + + {authorizationModes.map((option) => { + event.preventDefault(); + if (disabled) return; + onAuthorizationModeChange(option.mode); + setOpen(false); + }}>{option.label}{option.mode === authorizationMode ? : null})} + + + + ); +} diff --git a/apps/web/src/features/workspace/NormalConversationComposer.tsx b/apps/web/src/features/workspace/NormalConversationComposer.tsx index 3c56bf217..2d8677d78 100644 --- a/apps/web/src/features/workspace/NormalConversationComposer.tsx +++ b/apps/web/src/features/workspace/NormalConversationComposer.tsx @@ -1,14 +1,8 @@ -import { useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent, type Ref } from "react"; -import { ArrowUp, Brain, Check, ChevronDown, CircleAlert, FileText, ListTree, Plus, RotateCcw, Search, SlidersHorizontal, Square } from "lucide-react"; +import { type DragEvent, type KeyboardEvent, type Ref } from "react"; +import { ArrowUp, CircleAlert, FileText, Plus, RotateCcw, SlidersHorizontal, Square } from "lucide-react"; import type { ComposerSlashCommandOption } from "@ling-stack/core/composer-slash-intents"; import { useI18n } from "../../i18n.js"; import { - conversationAuthorizationModeMenuOptions, - conversationPermissionPresetMenuOptions, -} from "../../lib/session-permissions.js"; -import { - ComposerAuthorizationMenu, - ComposerPermissionMenu, ConversationComposerShell, ConversationStatusStrip, TokenizedComposerInput, @@ -16,26 +10,15 @@ import { type TokenizedComposerInputProps, } from "../../shared/conversation/index.js"; import { - Button, IconButton, InlineFeedback, - Input, - Menu, - MenuContent, - MenuRadioGroup, - MenuRadioItem, - MenuTrigger, StatusIndicator, } from "../../shared/ui/index.js"; import { ComposerPendingAssetStrip, type ActiveSlashCommandPicker, type PendingAttachmentItem } from "../../shared/components/index.js"; -import type { AuthorizationMode, ConversationAttachment, PermissionPreset, ProviderThinkingMode, ReasoningSummaryMode } from "../../types.js"; -import { - normalConversationModelLabel, - normalConversationThinkingLabel, - normalConversationThinkingOptions, - reasoningSummaryLabel, - reasoningSummaryOptions, -} from "./normal-conversation-composer-options.js"; +import type { AuthorizationMode, ConversationAttachment, PermissionPreset, ProviderThinkingMode } from "../../types.js"; +import { normalConversationModelLabel } from "./normal-conversation-composer-options.js"; +import { ConversationModelControl } from "./ConversationModelControl.js"; +import { ConversationPermissionControl } from "./ConversationPermissionControl.js"; import type { ConversationHomeModelOption } from "./normal-conversation-types.js"; export interface NormalConversationComposerProps { @@ -54,7 +37,6 @@ export interface NormalConversationComposerProps { readonly defaultProviderPresetId: string; readonly defaultProviderLabel: string; readonly selectedThinkingLevel: ProviderThinkingMode; - readonly selectedReasoningSummary: ReasoningSummaryMode; readonly sendBusy: boolean; readonly scopeReady?: boolean; readonly recoveringSubmission?: boolean; @@ -81,7 +63,6 @@ export interface NormalConversationComposerProps { readonly onPermissionPresetChange: (preset: PermissionPreset) => void; readonly onModelChange: (option: ConversationHomeModelOption) => void; readonly onThinkingLevelChange: (level: ProviderThinkingMode) => void; - readonly onReasoningSummaryChange: (mode: ReasoningSummaryMode) => void; readonly onSubmit: () => void | Promise; readonly onStop?: () => void | Promise; } @@ -117,14 +98,12 @@ export function NormalConversationComposer({ onStop, onSubmit, onThinkingLevelChange, - onReasoningSummaryChange, pendingAttachments, authorizationMode, permissionPreset, fullAccessConfirmed, selectedProviderPresetId, selectedThinkingLevel, - selectedReasoningSummary, sendBusy, scopeReady = true, recoveringSubmission = false, @@ -134,74 +113,10 @@ export function NormalConversationComposer({ stopping, }: NormalConversationComposerProps): React.JSX.Element { const { t } = useI18n(); - const [modelMenuOpen, setModelMenuOpen] = useState(false); - const [thinkingMenuOpen, setThinkingMenuOpen] = useState(false); - const [reasoningSummaryMenuOpen, setReasoningSummaryMenuOpen] = useState(false); - const [modelSearch, setModelSearch] = useState(""); - const modelSearchRef = useRef(null); const activeProviderPresetId = selectedProviderPresetId || defaultProviderPresetId; const activeModelOption = modelOptions.find((option) => option.id === activeProviderPresetId); const modelReady = Boolean(activeModelOption?.runnable ?? activeModelOption?.enabled); const activeModelLabel = activeModelOption ? normalConversationModelLabel(activeModelOption) : defaultProviderLabel; - const thinkingOptions = useMemo( - () => normalConversationThinkingOptions(activeModelOption?.thinkingModes ?? []), - [activeModelOption?.thinkingModes], - ); - const thinkingEnabled = thinkingOptions.length > 0; - const activeThinkingLabel = normalConversationThinkingLabel(selectedThinkingLevel, t); - const thinkingOff = selectedThinkingLevel === "off"; - const activeReasoningSummaryLabel = reasoningSummaryLabel(selectedReasoningSummary, t); - const normalizedModelSearch = modelSearch.trim().toLowerCase(); - const visibleModelOptions = useMemo(() => { - if (!normalizedModelSearch) { - return modelOptions; - } - return modelOptions.filter((option) => [ - option.displayName, - option.label ?? "", - option.model, - option.providerId ?? "", - ].join(" ").toLowerCase().includes(normalizedModelSearch)); - }, [modelOptions, normalizedModelSearch]); - - useEffect(() => { - if (!sendBusy && !activeRun) { - return; - } - setModelMenuOpen(false); - setThinkingMenuOpen(false); - setReasoningSummaryMenuOpen(false); - }, [activeRun, sendBusy]); - - useEffect(() => { - if (!thinkingEnabled && thinkingMenuOpen) { - setThinkingMenuOpen(false); - } - }, [thinkingEnabled, thinkingMenuOpen]); - - useEffect(() => { - if (!modelMenuOpen) { - return; - } - const frame = globalThis.requestAnimationFrame(() => modelSearchRef.current?.focus()); - return () => globalThis.cancelAnimationFrame(frame); - }, [modelMenuOpen]); - - function selectModel(option: ConversationHomeModelOption): void { - onModelChange(option); - setModelMenuOpen(false); - setModelSearch(""); - } - - function selectThinkingLevel(level: ProviderThinkingMode): void { - onThinkingLevelChange(level); - setThinkingMenuOpen(false); - } - - function selectReasoningSummary(mode: ReasoningSummaryMode): void { - onReasoningSummaryChange(mode); - setReasoningSummaryMenuOpen(false); - } const configurationLocked = sendBusy || activeRun || !scopeReady; const attachmentsLocked = sendBusy || !scopeReady; @@ -306,177 +221,28 @@ export function NormalConversationComposer({ }} /> - - )} rightToolsNode={( <> - {thinkingEnabled ? ( - - - - - - - {thinkingOptions.map((level) => { - const selected = level === selectedThinkingLevel; - return ( - { - event.preventDefault(); - selectThinkingLevel(level); - }} - > - {normalConversationThinkingLabel(level, t)} - {selected ? : null} - - ); - })} - - - - ) : null} - {thinkingEnabled ? ( - - - - - - - {reasoningSummaryOptions.map((mode) => ( - { - event.preventDefault(); - selectReasoningSummary(mode); - }} - > - {reasoningSummaryLabel(mode, t)} - {mode === selectedReasoningSummary ? : null} - - ))} - - - - ) : null} - { - setModelMenuOpen(open); - if (!open) setModelSearch(""); - }} - > - - - - - } - value={modelSearch} - onChange={(event) => setModelSearch(event.target.value)} - onKeyDown={(event) => event.stopPropagation()} - placeholder={t("Search models")} - aria-label={t("Search models")} - /> - - {visibleModelOptions.length > 0 ? visibleModelOptions.map((option) => { - const selected = option.id === activeProviderPresetId; - return ( - { - event.preventDefault(); - selectModel(option); - }} - > - {normalConversationModelLabel(option)} - {option.id === defaultProviderPresetId ? {t("Default")} : null} - {selected ? : null} - - ); - }) : ( - {t("No matching models")} - )} - - - + .ling-ui-button-label { + display: flex; + min-width: 0; + align-items: center; + gap: var(--ling-space-3); +} + +.conversation-model-trigger-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-model-trigger-effort { + flex: 0 0 auto; + color: var(--ling-text-muted); +} + +.conversation-model-popover { + width: min(320px, calc(100vw - 24px)); + max-width: var(--radix-popover-content-available-width); + max-height: var(--radix-popover-content-available-height); + padding: var(--ling-space-4); + border-radius: var(--ling-radius-lg); +} + +.conversation-reasoning-panel { + display: grid; + gap: var(--ling-space-6); + padding: var(--ling-space-4) var(--ling-space-5) var(--ling-space-5); +} + +.conversation-reasoning-header { + position: relative; + display: flex; + min-width: 0; + justify-content: center; + padding: 0 var(--ling-control-sm); +} + +.conversation-reasoning-model-switch.ling-ui-button { + display: grid; + max-width: 100%; + height: auto; + min-height: 58px; + gap: var(--ling-space-1); + padding: var(--ling-space-3) var(--ling-space-4); +} + +.conversation-reasoning-model-switch strong { + color: var(--ling-action-accent); + font-size: var(--ling-font-md); + font-weight: 650; + line-height: var(--ling-line-md); +} + +.conversation-reasoning-model-switch > .ling-ui-button-label { + display: grid; + min-width: 0; + gap: var(--ling-space-1); +} + +.conversation-reasoning-model-switch .ling-ui-button-label > span { + display: flex; + min-width: 0; + align-items: center; + justify-content: center; + gap: var(--ling-space-1); + color: var(--ling-text-muted); + font-size: var(--ling-font-sm); + overflow-wrap: anywhere; +} + +.conversation-reasoning-model-switch svg { + flex-shrink: 0; +} + +.conversation-reasoning-reset { + position: absolute; + top: var(--ling-space-1); + right: 0; + color: var(--ling-text-muted); +} + +.conversation-reasoning-slider { + position: relative; + height: 30px; + border-radius: var(--ling-radius-pill); + background: var(--ling-bg-pressed); +} + +.conversation-reasoning-slider::before { + position: absolute; + inset: 0 auto 0 0; + /* Match the thumb's travel, including its rounded right edge. */ + width: calc(30px + (100% - 30px) * var(--reasoning-progress)); + border-radius: inherit; + background: var(--ling-action-accent); + content: ""; + pointer-events: none; +} + +.conversation-reasoning-slider input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + border-radius: inherit; + appearance: none; + background: transparent; + cursor: pointer; +} + +.conversation-reasoning-slider input::-webkit-slider-thumb { + box-sizing: border-box; + width: 30px; + height: 30px; + border: 2px solid var(--ling-bg-elevated); + border-radius: 50%; + appearance: none; + background: var(--ling-text-strong); + box-shadow: var(--ling-shadow-medium); +} + +.conversation-reasoning-slider input::-moz-range-thumb { + box-sizing: border-box; + width: 30px; + height: 30px; + border: 2px solid var(--ling-bg-elevated); + border-radius: 50%; + background: var(--ling-text-strong); +} + +.conversation-reasoning-slider input:focus-visible { + outline: 2px solid var(--ling-border-focus); + outline-offset: 4px; +} + +.conversation-reasoning-ticks { + position: absolute; + inset: 0 15px; + pointer-events: none; +} + +.conversation-reasoning-ticks span { + position: absolute; + top: 13px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--ling-text-muted); + transform: translateX(-50%); +} + +.conversation-model-menu-header { + display: flex; + min-width: 0; + align-items: center; + gap: var(--ling-space-2); + color: var(--ling-text-muted); + font-size: var(--ling-font-xs); + font-weight: 500; +} + +.conversation-model-popover .conversation-model-list { + max-height: min(320px, 45vh); +} + +.conversation-model-popover .conversation-model-option, +.conversation-model-default-option { + display: flex; + width: 100%; + height: auto; + min-height: 40px; + justify-content: space-between; + gap: var(--ling-space-4); + padding: var(--ling-space-3) var(--ling-space-4); + text-align: start; +} + +.conversation-model-popover .conversation-model-option > .ling-ui-button-label, +.conversation-model-default-option > .ling-ui-button-label { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--ling-space-4); +} + +.conversation-model-popover .conversation-model-option-copy, +.conversation-model-default-option .ling-ui-button-label > span { + display: grid; + min-width: 0; + gap: var(--ling-space-1); +} + +.conversation-model-option-copy small, +.conversation-model-default-option small { + overflow: hidden; + color: var(--ling-text-muted); + font-size: var(--ling-font-xs); + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-model-default-option { + margin-bottom: var(--ling-space-2); + background: var(--ling-bg-subtle); +} + +.conversation-model-popover .conversation-model-option[aria-pressed="true"] { + background: var(--ling-action-selection); +} + +.conversation-permission-menu { + width: min(272px, calc(100vw - 24px)); +} + +.conversation-permission-menu .ling-ui-menu-radio-item { + display: flex; + min-height: 36px; + align-items: center; + gap: var(--ling-space-4); +} + +.conversation-permission-menu .ling-ui-menu-radio-item > span { + flex: 1; +} + +.conversation-permission-menu-heading { + display: block; + padding: var(--ling-space-3) var(--ling-space-4) var(--ling-space-2); + color: var(--ling-text-muted); + font-size: var(--ling-font-xs); +} + +@media (max-width: 760px) { + [data-conversation-composer][data-surface="normal_conversation"] .conversation-model-trigger { + max-width: 42vw; + gap: var(--ling-space-2); + padding-inline: var(--ling-space-2); + } + + [data-conversation-composer][data-surface="normal_conversation"] .conversation-permission-trigger { + min-width: 0; + max-width: 34vw; + padding-inline: var(--ling-space-2); + } + + [data-conversation-composer][data-surface="normal_conversation"] .conversation-permission-trigger .ling-ui-button-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/docs/design/conversation-composer-unification.md b/docs/design/conversation-composer-unification.md index 8dfac2703..d1060d70f 100644 --- a/docs/design/conversation-composer-unification.md +++ b/docs/design/conversation-composer-unification.md @@ -385,8 +385,8 @@ right tools: model/thinking controls when editable, read-only model status when Toolbar rules: - icon buttons are 30-32px with transparent default background, -- authorization uses the same compact trigger across channel, normal, and private - surfaces, +- normal conversation places authorization choices inside its compact permission menu; + channel and private chat retain separate compact authorization triggers, - model picker is right aligned and appears only for normal conversation, - teammate private chat can show read-only effective model status, but not a model picker, From 3c9b219679d3c6e7e98fdae6957ef36c06acad09 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Fri, 23 Oct 2026 18:47:52 +0800 Subject: [PATCH 2/4] docs: note compact conversation composer controls --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08a755c88..7d398eb5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ release versions will follow [Semantic Versioning](https://semver.org/spec/v2.0. ### Changed +- Normal conversations now combine model selection and reasoning strength in one + compact popover, with authorization choices inside the permission menu. - Simplified the public README and added source setup, troubleshooting, and platform development-package instructions with a fresh workspace screenshot. From 270efa843a5f3e4aaf834357de9ffce95942c893 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Fri, 23 Oct 2026 20:22:56 +0800 Subject: [PATCH 3/4] fix(web): keep approval shortcuts in the conversation composer --- CHANGELOG.md | 3 +- ...i2-conversation-composer-ownership.spec.ts | 2 +- ...i2-full-access-confirmation-parity.spec.ts | 93 ++++++++++++++++++- ...ayout-legacy-prototype-dead-family.spec.ts | 2 +- ...-normal-conversation-feed-composer.spec.ts | 8 +- apps/web/src/app/AppShell.tsx | 3 +- .../features/workspace/ConversationHome.tsx | 26 +++++- .../ConversationPermissionControl.tsx | 60 ++++++------ .../workspace/NormalConversationComposer.tsx | 3 - .../workspace/normal-conversation-types.ts | 1 + .../src/styles/conversation-model-control.css | 30 +++++- .../conversation-composer-unification.md | 7 +- ...sation-session-full-access-confirmation.md | 18 ++-- 13 files changed, 196 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d398eb5c..ca36f4818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ release versions will follow [Semantic Versioning](https://semver.org/spec/v2.0. ### Changed - Normal conversations now combine model selection and reasoning strength in one - compact popover, with authorization choices inside the permission menu. + compact popover, with three approval shortcuts beside the input. Sandbox and + independent authorization defaults remain in Settings. - Simplified the public README and added source setup, troubleshooting, and platform development-package instructions with a fresh workspace screenshot. diff --git a/apps/web/e2e/ui2-conversation-composer-ownership.spec.ts b/apps/web/e2e/ui2-conversation-composer-ownership.spec.ts index 666115ef4..e15a6b529 100644 --- a/apps/web/e2e/ui2-conversation-composer-ownership.spec.ts +++ b/apps/web/e2e/ui2-conversation-composer-ownership.spec.ts @@ -270,7 +270,7 @@ test("T13-O.31-E Dark 1280 Normal keeps slash, permission, reduced motion, long const permissionTrigger = composer.getByRole("button", { name: /Selected permission preset/u }); await permissionTrigger.click(); const permissionOverlay = page.locator('[data-ling-overlay="menu"]').filter({ - has: page.getByRole("menuitemradio", { name: /Read only/u }), + has: page.getByRole("menuitemradio", { name: "Request approval", exact: true }), }); await expect(permissionOverlay).toBeVisible(); await expectRegionWithinViewport(page, permissionOverlay); diff --git a/apps/web/e2e/ui2-full-access-confirmation-parity.spec.ts b/apps/web/e2e/ui2-full-access-confirmation-parity.spec.ts index 6a6e54593..39b22293c 100644 --- a/apps/web/e2e/ui2-full-access-confirmation-parity.spec.ts +++ b/apps/web/e2e/ui2-full-access-confirmation-parity.spec.ts @@ -80,6 +80,7 @@ test("normal conversation requires explicit confirmation and preserves the prese await prepareSurface(page, "dark", 1280, 800, true); const runtimeErrors = collectRuntimeErrors(page); const state = createFullAccessState(); + state.workspaceSettings.defaultWorkspaceAccessMode = "read_only"; await mockDaemon(page, state); await page.goto("/"); await page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }).getByRole("button", { name: "New conversation" }).click(); @@ -95,7 +96,8 @@ test("normal conversation requires explicit confirmation and preserves the prese expect(state.runs).toHaveLength(0); await selectFullAccess(page, permissionTrigger); await confirmFullAccessSelection(page); - await expect(permissionTrigger).toContainText("Full access · Confirmed for this conversation"); + await expect(permissionTrigger).toHaveText("Full access"); + await expect(permissionTrigger).toHaveAccessibleName(/Confirmed for this conversation/); await captureP6FeedbackEvidence( page, "shell/full-access-confirmation-parity", @@ -116,7 +118,7 @@ test("normal conversation requires explicit confirmation and preserves the prese const activeConversation = page.getByLabel("Conversation page"); const activePermissionTrigger = activeConversation.getByRole("button", { name: /Selected permission preset/ }); - await expect(activePermissionTrigger).toContainText("Full access · Confirmed for this conversation"); + await expect(activePermissionTrigger).toHaveText("Full access"); await activeConversation.getByRole("textbox", { name: "Conversation message" }).fill("Continue in the same trusted conversation"); await activeConversation.getByRole("button", { name: "Send" }).click(); await expect(fullAccessDialog(page)).toHaveCount(0); @@ -132,7 +134,7 @@ test("normal conversation requires explicit confirmation and preserves the prese const nextPermissionTrigger = nextHome.getByRole("button", { name: /Selected permission preset/ }); await selectFullAccess(page, nextPermissionTrigger); await confirmFullAccessSelection(page); - await expect(nextPermissionTrigger).toContainText("Full access · Confirmed for this conversation"); + await expect(nextPermissionTrigger).toHaveText("Full access"); await nextHome.getByRole("textbox", { name: "Conversation message" }).fill("Start another trusted conversation"); await nextHome.getByRole("button", { name: "Send" }).click(); await expect(fullAccessDialog(page)).toHaveCount(0); @@ -145,14 +147,14 @@ test("normal conversation requires explicit confirmation and preserves the prese await expectSubmissionAcknowledged(page, [cwd, "normal", "", ""]); await expect(page.getByLabel("Conversation page").getByRole("button", { name: /Selected permission preset/ })) - .toContainText("Full access · Confirmed for this conversation"); + .toHaveText("Full access"); await page.reload(); await page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }).getByRole("button", { name: /Open recent conversation/ }).first().click(); const restoredConversation = page.getByLabel("Conversation page"); const restoredPermissionTrigger = restoredConversation.getByRole("button", { name: /Selected permission preset/ }); await selectFullAccess(page, restoredPermissionTrigger); await expect(fullAccessDialog(page)).toHaveCount(0); - await expect(restoredPermissionTrigger).toContainText("Full access · Confirmed for this conversation"); + await expect(restoredPermissionTrigger).toHaveText("Full access"); await restoredConversation.getByRole("textbox", { name: "Conversation message" }).fill("Continue after restarting the app"); await restoredConversation.getByRole("button", { name: "Send" }).click(); await expect(fullAccessDialog(page)).toHaveCount(0); @@ -162,6 +164,12 @@ test("normal conversation requires explicit confirmation and preserves the prese mode: "full_access", reason: "user_confirmed_full_access", }); + await restoredPermissionTrigger.click(); + await page.getByRole("menuitemradio", { name: "Help me approve", exact: true }).click(); + await restoredConversation.getByRole("textbox", { name: "Conversation message" }).fill("Restore the workspace sandbox after reopening"); + const restoredRequest = page.waitForRequest((request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/conversations/stream")); + await restoredConversation.getByRole("button", { name: "Send", exact: true }).click(); + expect((await restoredRequest).postDataJSON()).toMatchObject({ authorizationMode: "smart", workspaceAccessMode: "read_only" }); await expectNoDocumentOverflow(page); expect(runtimeErrors).toEqual([]); }); @@ -203,6 +211,81 @@ test("normal conversation confirms inherited full access on the first run", asyn expect(runtimeErrors).toEqual([]); }); +for (const fixture of [ + { access: "read_only" as const, theme: "light" as const }, + { access: "workspace_write" as const, theme: "dark" as const }, +]) { + test(`normal conversation approval shortcuts preserve ${fixture.access} and restore it after full access`, async ({ page }) => { + await prepareSurface(page, fixture.theme, 1280, 800, true); + const runtimeErrors = collectRuntimeErrors(page); + const state = createFullAccessState(); + state.workspaceSettings = { + ...state.workspaceSettings, + defaultAuthorizationMode: "ask", + defaultWorkspaceAccessMode: fixture.access, + }; + await mockDaemon(page, state); + await page.goto("/"); + await page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }).getByRole("button", { name: "New conversation" }).click(); + + const composer = page.getByLabel("New conversation composer"); + const trigger = composer.getByRole("button", { name: /Selected permission preset/ }); + await expect(trigger).toHaveText("Request approval"); + await selectFullAccess(page, trigger); + await fullAccessDialog(page).getByRole("button", { name: "Cancel" }).last().click(); + await expect(trigger).toHaveText("Request approval"); + expect(await sendPrompt("After cancelling full access")).toMatchObject({ authorizationMode: "ask", workspaceAccessMode: fixture.access }); + + await trigger.click(); + const menu = page.getByRole("menu").filter({ has: page.getByRole("menuitemradio", { name: "Request approval", exact: true }) }); + await expect(menu.getByRole("menuitemradio")).toHaveCount(3); + await captureP6FeedbackEvidence(page, "shell/full-access-confirmation-parity", `1280x800-ling-${fixture.theme}-normal-approval-shortcuts.png`); + await menu.getByRole("menuitemradio", { name: "Help me approve", exact: true }).click(); + expect(await sendPrompt("With approval review")).toMatchObject({ authorizationMode: "smart", workspaceAccessMode: fixture.access }); + + await selectFullAccess(page, trigger); + await confirmFullAccessSelection(page); + await expect(trigger).toHaveText("Full access"); + expect(await sendPrompt("With confirmed full access")).toMatchObject({ authorizationMode: "always", workspaceAccessMode: "full_access" }); + + await trigger.click(); + await menu.getByRole("menuitemradio", { name: "Help me approve", exact: true }).click(); + expect(await sendPrompt("Back to approval review")).toMatchObject({ authorizationMode: "smart", workspaceAccessMode: fixture.access }); + await expectNoDocumentOverflow(page); + expect(runtimeErrors).toEqual([]); + + async function sendPrompt(message: string): Promise> { + await composer.getByRole("textbox", { name: "Conversation message" }).fill(message); + const request = page.waitForRequest((candidate) => candidate.method() === "POST" && new URL(candidate.url()).pathname.endsWith("/conversations/stream")); + await composer.getByRole("button", { name: "Send", exact: true }).click(); + const body = (await request).postDataJSON() as Record; + await expect(page.getByLabel("Conversation page")).toBeVisible(); + await expect(trigger).toBeEnabled(); + return body; + } + }); +} + +test("normal conversation distinguishes inherited always authorization from full access", async ({ page }) => { + await prepareSurface(page, "light", 1280, 800, true); + const state = createFullAccessState(); + state.workspaceSettings = { ...state.workspaceSettings, defaultAuthorizationMode: "always", defaultWorkspaceAccessMode: "read_only" }; + await mockDaemon(page, state); + await page.goto("/"); + await page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }).getByRole("button", { name: "New conversation" }).click(); + const composer = page.getByLabel("New conversation composer"); + const trigger = composer.getByRole("button", { name: /Selected permission preset/ }); + await expect(trigger).toHaveText("Always authorize"); + await trigger.click(); + const menu = page.getByRole("menu").filter({ has: page.getByRole("menuitemradio", { name: "Request approval", exact: true }) }); + await expect(menu.getByRole("menuitemradio", { name: "Full access", exact: true })).not.toBeChecked(); + await menu.getByRole("menuitemradio", { name: "Request approval", exact: true }).click(); + await composer.getByRole("textbox", { name: "Conversation message" }).fill("Keep the configured read-only sandbox"); + const request = page.waitForRequest((candidate) => candidate.method() === "POST" && new URL(candidate.url()).pathname.endsWith("/conversations/stream")); + await composer.getByRole("button", { name: "Send", exact: true }).click(); + expect((await request).postDataJSON()).toMatchObject({ authorizationMode: "ask", workspaceAccessMode: "read_only" }); +}); + test("teammate private chat confirms explicit selection per conversation", async ({ page }) => { await prepareSurface(page, "light", 1440, 960); const runtimeErrors = collectRuntimeErrors(page); diff --git a/apps/web/e2e/ui2-layout-legacy-prototype-dead-family.spec.ts b/apps/web/e2e/ui2-layout-legacy-prototype-dead-family.spec.ts index 12b7cb3cc..f521d05d5 100644 --- a/apps/web/e2e/ui2-layout-legacy-prototype-dead-family.spec.ts +++ b/apps/web/e2e/ui2-layout-legacy-prototype-dead-family.spec.ts @@ -163,7 +163,7 @@ test("1280x800 Ling Dark keeps Normal and Teammate command menus and focus conta const normalPermission = normalComposer.getByRole("button", { name: /Selected permission preset/u }); await normalPermission.click(); - const normalMenu = permissionMenu(page); + const normalMenu = page.getByRole("menu").filter({ has: page.getByRole("menuitemradio", { name: "Request approval", exact: true }) }); await expect(normalMenu).toBeVisible(); await expectOverlayContained(page, normalMenu); await page.keyboard.press("Escape"); diff --git a/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts b/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts index 0cca8d46f..6faa38224 100644 --- a/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts +++ b/apps/web/e2e/ui2-normal-conversation-feed-composer.spec.ts @@ -124,10 +124,14 @@ test("normal conversation composer uses Radix menus and preserves draft, upload, const permissionTrigger = composer.getByRole("button", { name: /Selected permission preset/ }); await permissionTrigger.click(); - const permissionMenu = page.locator('[data-ling-overlay="menu"]').filter({ has: page.getByRole("menuitemradio", { name: /Workspace write/ }) }); + const permissionMenu = page.getByRole("menu").filter({ has: page.getByRole("menuitemradio", { name: "Request approval", exact: true }) }); await expect(permissionMenu).toBeVisible(); - await expect(permissionMenu.getByRole("group", { name: "Sandbox settings" }).getByRole("menuitemradio")).toHaveCount(3); await expect(permissionMenu.getByRole("group", { name: "Authorization mode" }).getByRole("menuitemradio")).toHaveCount(3); + await expect(permissionMenu.getByRole("menuitemradio")).toHaveText([ + /Request approval/, /Help me approve/, /Full access/, + ]); + await expect(permissionMenu.getByRole("menuitemradio", { name: /Read only|Workspace write|Always authorize/ })).toHaveCount(0); + await captureEvidence(page, "1440x960-ling-light-normal-composer-approval-menu.png"); await permissionMenu.getByRole("menuitemradio", { name: "Request approval", exact: true }).click(); await permissionTrigger.click(); await expect(permissionMenu.getByRole("menuitemradio", { name: "Request approval", exact: true })).toBeChecked(); diff --git a/apps/web/src/app/AppShell.tsx b/apps/web/src/app/AppShell.tsx index b6ffb6bc5..83e6e38ee 100644 --- a/apps/web/src/app/AppShell.tsx +++ b/apps/web/src/app/AppShell.tsx @@ -143,7 +143,7 @@ import { } from "../lib/providers.js"; import { removeRecordKey } from "../lib/records.js"; import { permissionPresetForWorkspaceAccessMode, workspaceAccessModeForPermissionPreset } from "../lib/session-permissions.js"; -import { authorizationAndAccessToLegacyPermission } from "../lib/conversation-authorization.js"; +import { authorizationAndAccessToLegacyPermission, resolveWorkspaceAccessMode } from "../lib/conversation-authorization.js"; import { StatusBadge } from "../shared/components/index.js"; import { CurrentSessionHeaderCapabilityProvider } from "../shared/current-session/CurrentSessionHeaderCapability.js"; import { useFocusRevalidationToken } from "../shared/hooks/use-focus-revalidation-token.js"; @@ -2780,6 +2780,7 @@ export function AppShell(): React.JSX.Element { initialReasoningSummary={selectedConversationDetail?.defaults.reasoningSummary ?? workspaceSettings?.defaultReasoningSummary ?? "auto"} initialAuthorizationMode={selectedConversationDetail?.defaults.authorizationMode ?? workspaceSettings?.defaultAuthorizationMode} initialWorkspaceAccessMode={selectedConversationDetail?.defaults.workspaceAccessMode ?? workspaceSettings?.defaultWorkspaceAccessMode} + defaultWorkspaceAccessMode={resolveWorkspaceAccessMode(workspaceSettings?.defaultWorkspaceAccessMode, workspaceSettings?.defaultPermissionMode, workspaceSettings?.defaultPermissionPreset)} initialPermissionMode={selectedConversationDetail?.defaults.permissionMode ?? workspaceSettings?.defaultPermissionMode} initialPermissionPreset={selectedConversationDetail?.defaults.permissionPreset ?? workspaceSettings?.defaultPermissionPreset} detailContext={selectedConversationDetail ? { diff --git a/apps/web/src/features/workspace/ConversationHome.tsx b/apps/web/src/features/workspace/ConversationHome.tsx index 1006c7cee..f7e2f34e9 100644 --- a/apps/web/src/features/workspace/ConversationHome.tsx +++ b/apps/web/src/features/workspace/ConversationHome.tsx @@ -79,6 +79,7 @@ export function ConversationHome({ initialReasoningSummary = "auto", initialAuthorizationMode, initialWorkspaceAccessMode, + defaultWorkspaceAccessMode = "workspace_write", initialPermissionMode, initialPermissionPreset, detailContext, @@ -149,6 +150,8 @@ export function ConversationHome({ const replacementRetryingMessageId = replacementRetryOperation.activeValue; const composerInputRef = useRef(null); const permissionSelectionBoundThreadRef = useRef(""); + const permissionBeforeFullAccessRef = useRef(undefined); + const permissionSelectionSequenceRef = useRef(0); const sendBusy = sending || localSending || guidanceOperation.busy; const composerBusy = sendBusy || !conversationIdentityController.ready; const activeReply = sendLifecycle.activeReplies[0] ?? (activeRun && activeReplyStatus(activeRun.status) ? { @@ -409,6 +412,8 @@ export function ConversationHome({ return; } permissionSelectionBoundThreadRef.current = ""; + permissionBeforeFullAccessRef.current = undefined; + permissionSelectionSequenceRef.current += 1; setSelectedAuthorizationMode(resolveAuthorizationMode( initialAuthorizationMode, initialPermissionMode, @@ -763,21 +768,32 @@ export function ConversationHome({ onRetryAttachment={attachmentController.retryAttachment} attachmentCleanupFailures={attachmentController.cleanupFailures} onRetryAttachmentCleanup={attachmentController.retryCleanup} - onAuthorizationModeChange={setSelectedAuthorizationMode} - onPermissionPresetChange={(preset) => { - if (preset !== "full_access") { - setSelectedPermissionPreset(preset); + onAuthorizationModeChange={(mode) => { + const selectionSequence = ++permissionSelectionSequenceRef.current; + if (mode !== "always") { + if (selectedAuthorizationMode === "always" && selectedPermissionPreset === "full_access") { + setSelectedPermissionPreset(permissionBeforeFullAccessRef.current ?? permissionPresetForWorkspaceAccessMode(defaultWorkspaceAccessMode)); + } + permissionBeforeFullAccessRef.current = undefined; + setSelectedAuthorizationMode(mode); return; } if (!onConfirmFullAccessSelection) { actionFeedback.report("validation", t("Use full access for this conversation after explicit confirmation.")); return; } + const selectionIdentity = conversationIdentityController.capture(); void onConfirmFullAccessSelection() .then((confirmation) => { - if (confirmation) setSelectedPermissionPreset(preset); + if (!confirmation || selectionSequence !== permissionSelectionSequenceRef.current || !conversationIdentityController.isCurrent(selectionIdentity)) return; + if (selectedAuthorizationMode !== "always" || selectedPermissionPreset !== "full_access") { + permissionBeforeFullAccessRef.current = selectedPermissionPreset; + } + setSelectedAuthorizationMode("always"); + setSelectedPermissionPreset("full_access"); }) .catch((error: unknown) => { + if (selectionSequence !== permissionSelectionSequenceRef.current || !conversationIdentityController.isCurrent(selectionIdentity)) return; actionFeedback.report("validation", error instanceof Error ? error.message : String(error)); }); }} diff --git a/apps/web/src/features/workspace/ConversationPermissionControl.tsx b/apps/web/src/features/workspace/ConversationPermissionControl.tsx index c69e4c9f8..877ba52f0 100644 --- a/apps/web/src/features/workspace/ConversationPermissionControl.tsx +++ b/apps/web/src/features/workspace/ConversationPermissionControl.tsx @@ -1,29 +1,37 @@ import { useEffect, useState } from "react"; -import { Check, ChevronDown, Eye, Shield, ShieldCheck, SlidersHorizontal } from "lucide-react"; +import { Check, ChevronDown, Hand, Shield, ShieldAlert, ShieldCheck } from "lucide-react"; import { useI18n } from "../../i18n.js"; -import { conversationAuthorizationModeMenuOptions, conversationPermissionPresetMenuOptions } from "../../lib/session-permissions.js"; -import { Button, Menu, MenuContent, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger } from "../../shared/ui/index.js"; +import { conversationAuthorizationModeMenuOptions, permissionPresetLabel } from "../../lib/session-permissions.js"; +import { Button, Menu, MenuContent, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../../shared/ui/index.js"; import type { AuthorizationMode, PermissionPreset } from "../../types.js"; -const permissionIcons = { read_only: Eye, workspace_collaboration: Shield, full_access: ShieldCheck, custom: SlidersHorizontal }; +const authorizationIcons = { ask: Hand, smart: Shield, always: ShieldAlert }; export function ConversationPermissionControl({ - authorizationMode, permissionPreset, fullAccessConfirmed, disabled, onAuthorizationModeChange, onPermissionPresetChange, + authorizationMode, permissionPreset, fullAccessConfirmed, disabled, onAuthorizationModeChange, }: { readonly authorizationMode: AuthorizationMode; readonly permissionPreset: PermissionPreset; readonly fullAccessConfirmed: boolean; readonly disabled: boolean; readonly onAuthorizationModeChange: (mode: AuthorizationMode) => void; - readonly onPermissionPresetChange: (preset: PermissionPreset) => void; }): React.JSX.Element { const { t } = useI18n(); const [open, setOpen] = useState(false); - const permissions = conversationPermissionPresetMenuOptions(t, fullAccessConfirmed); const authorizationModes = conversationAuthorizationModeMenuOptions(t); - const selectedPermission = permissions.find((option) => option.preset === permissionPreset); - const selectedAuthorization = authorizationModes.find((option) => option.mode === authorizationMode); - const Icon = permissionIcons[permissionPreset]; + const options = authorizationModes.map((option) => option.mode === "always" ? { + ...option, + label: t("Full access"), + description: t("Full access lets Ling act without asking for routine local approvals in this conversation."), + } : option); + const fullAccess = authorizationMode === "always" && permissionPreset === "full_access"; + // An inherited always + restricted sandbox is not the Full access shortcut. + const selectedMode = authorizationMode === "always" && !fullAccess ? "custom" : authorizationMode; + const selectedAuthorization = options.find((option) => option.mode === selectedMode) + ?? authorizationModes.find((option) => option.mode === authorizationMode)!; + const confirmationLabel = fullAccess && fullAccessConfirmed + ? t("Full access · Confirmed for this conversation") : selectedAuthorization.label; + const Icon = selectedMode === "custom" ? ShieldCheck : authorizationIcons[authorizationMode]; useEffect(() => { if (disabled) setOpen(false); }, [disabled]); @@ -37,34 +45,26 @@ export function ConversationPermissionControl({ leadingIcon={} trailingIcon={} disabled={disabled} - aria-label={t("Selected permission preset {label}", { label: selectedPermission?.label ?? t("Custom") })} - title={disabled ? t("Permission is locked while the current message is running.") : `${selectedPermission?.description ?? ""} ${selectedAuthorization?.label ?? ""}`} + data-full-access={fullAccess} + aria-label={t("Selected permission preset {label}", { label: confirmationLabel })} + title={disabled ? t("Permission is locked while the current message is running.") : `${confirmationLabel}. ${selectedAuthorization.description} ${t("Sandbox settings")}: ${permissionPresetLabel(permissionPreset, t)}`} > - {selectedPermission?.label ?? t("Custom")} + {selectedAuthorization.label} - - - {permissions.map((option) => { - const OptionIcon = permissionIcons[option.preset]; - return { + + {t("Authorization mode")} + + {options.map((option) => { + const OptionIcon = authorizationIcons[option.mode]; + return { event.preventDefault(); if (disabled) return; - if (permissionPreset !== option.preset) onPermissionPresetChange(option.preset); + onAuthorizationModeChange(option.mode); setOpen(false); - }}>{option.label}{option.preset === permissionPreset ? : null}; + }}>{option.label}{option.description}{option.mode === selectedMode ? : null}; })} - - {t("Authorization mode")} - - {authorizationModes.map((option) => { - event.preventDefault(); - if (disabled) return; - onAuthorizationModeChange(option.mode); - setOpen(false); - }}>{option.label}{option.mode === authorizationMode ? : null})} - ); diff --git a/apps/web/src/features/workspace/NormalConversationComposer.tsx b/apps/web/src/features/workspace/NormalConversationComposer.tsx index 2d8677d78..d3f866e54 100644 --- a/apps/web/src/features/workspace/NormalConversationComposer.tsx +++ b/apps/web/src/features/workspace/NormalConversationComposer.tsx @@ -60,7 +60,6 @@ export interface NormalConversationComposerProps { readonly attachmentCleanupFailures?: readonly import("../../shared/components/composer-pending-assets.js").AttachmentCleanupFailure[]; readonly onRetryAttachmentCleanup?: (localId: string) => void | Promise; readonly onAuthorizationModeChange: (mode: AuthorizationMode) => void; - readonly onPermissionPresetChange: (preset: PermissionPreset) => void; readonly onModelChange: (option: ConversationHomeModelOption) => void; readonly onThinkingLevelChange: (level: ProviderThinkingMode) => void; readonly onSubmit: () => void | Promise; @@ -90,7 +89,6 @@ export function NormalConversationComposer({ onDraftChange, onModelChange, onOpenModelWorkbench, - onPermissionPresetChange, onRemoveAttachment, onRetryAttachment, attachmentCleanupFailures = [], @@ -227,7 +225,6 @@ export function NormalConversationComposer({ fullAccessConfirmed={fullAccessConfirmed} disabled={configurationLocked} onAuthorizationModeChange={onAuthorizationModeChange} - onPermissionPresetChange={onPermissionPresetChange} /> )} diff --git a/apps/web/src/features/workspace/normal-conversation-types.ts b/apps/web/src/features/workspace/normal-conversation-types.ts index 0483946ed..5fc6e3cbd 100644 --- a/apps/web/src/features/workspace/normal-conversation-types.ts +++ b/apps/web/src/features/workspace/normal-conversation-types.ts @@ -132,6 +132,7 @@ export interface ConversationHomeProps { readonly initialReasoningSummary?: ReasoningSummaryMode; readonly initialAuthorizationMode?: AuthorizationMode; readonly initialWorkspaceAccessMode?: WorkspaceAccessMode; + readonly defaultWorkspaceAccessMode?: WorkspaceAccessMode; readonly initialPermissionMode?: SessionPermissionMode; readonly initialPermissionPreset?: PermissionPreset; readonly detailContext?: ConversationHomeDetailContext; diff --git a/apps/web/src/styles/conversation-model-control.css b/apps/web/src/styles/conversation-model-control.css index cefa65fe9..1d1eac75d 100644 --- a/apps/web/src/styles/conversation-model-control.css +++ b/apps/web/src/styles/conversation-model-control.css @@ -225,12 +225,12 @@ } .conversation-permission-menu { - width: min(272px, calc(100vw - 24px)); + width: min(400px, calc(100vw - 24px)); } .conversation-permission-menu .ling-ui-menu-radio-item { display: flex; - min-height: 36px; + min-height: 64px; align-items: center; gap: var(--ling-space-4); } @@ -239,6 +239,32 @@ flex: 1; } +.conversation-permission-menu .ling-ui-menu-radio-item > svg { + flex-shrink: 0; +} + +.conversation-permission-option-copy { + display: grid; + min-width: 0; + gap: var(--ling-space-1); + white-space: normal; +} + +.conversation-permission-option-copy strong { + font-weight: 600; +} + +.conversation-permission-option-copy small { + color: var(--ling-text-muted); + font-size: var(--ling-font-xs); + line-height: var(--ling-line-md); +} + +.conversation-permission-trigger[data-full-access="true"], +.conversation-permission-menu [data-full-access="true"] { + color: var(--ling-status-warning); +} + .conversation-permission-menu-heading { display: block; padding: var(--ling-space-3) var(--ling-space-4) var(--ling-space-2); diff --git a/docs/design/conversation-composer-unification.md b/docs/design/conversation-composer-unification.md index d1060d70f..6b3ae1352 100644 --- a/docs/design/conversation-composer-unification.md +++ b/docs/design/conversation-composer-unification.md @@ -385,7 +385,12 @@ right tools: model/thinking controls when editable, read-only model status when Toolbar rules: - icon buttons are 30-32px with transparent default background, -- normal conversation places authorization choices inside its compact permission menu; +- normal conversation offers Request approval, Help me approve, and Full access + shortcuts in its compact permission menu. Independent authorization and sandbox + defaults remain in Settings. Request approval and Help me approve preserve the + selected sandbox; Full access sets both always authorization and full access only + after confirmation. Returning from that shortcut restores the prior sandbox, or + the workspace default when restoring an existing conversation; channel and private chat retain separate compact authorization triggers, - model picker is right aligned and appears only for normal conversation, - teammate private chat can show read-only effective model status, but not a model diff --git a/docs/design/conversation-session-full-access-confirmation.md b/docs/design/conversation-session-full-access-confirmation.md index 0b354f8f9..aeed93c73 100644 --- a/docs/design/conversation-session-full-access-confirmation.md +++ b/docs/design/conversation-session-full-access-confirmation.md @@ -3,19 +3,20 @@ ## Summary 完全访问采用“每个对话确认一次”,不是每条消息确认,也不是工作区永久授权。当前对话首次以 -`full_access` 发起真实运行时显示确认 Dialog;确认后,同一会话后续请求复用该确认记录并继续随请求 +`full_access` 发起真实运行时显示确认 Dialog;也可在权限菜单主动选择时提前确认。确认后,同一会话后续请求复用该确认记录并继续随请求 提交给 daemon。页面刷新、renderer 重建或客户端重开后,同一 scope 仍复用已确认记录。 ## Product Contract - 普通对话以 thread 为会话;尚未创建 thread 时以当前新对话 draft generation 为临时身份,首条消息绑定 thread 后必须继承同一次确认。 -- 普通对话中,用户在当前对话的权限菜单里主动选择 `full_access`,该明确操作本身完成本对话确认; - 不再叠加第二个确认 Dialog。由工作区默认值继承 `full_access` 时,首次真实运行仍显示确认 Dialog。 +- 普通对话中,用户在当前对话的权限菜单里主动选择 `full_access` 时先显示确认 Dialog;确认成功后才切换 + 为 `always + full_access`,取消则保留原来的两项选择。后续发送不再叠加第二个确认 Dialog。 + 由工作区默认值继承 `full_access` 时,首次真实运行仍显示确认 Dialog。 - 频道以当前 channel conversation 为会话;切换频道或频道分支后重新确认。 - AI 队友私聊以当前 teammate direct conversation 为会话;切换队友后重新确认。 -- AI 队友私聊中,用户在当前对话的权限菜单里主动选择 `full_access`,该明确操作本身完成本对话确认; - 不再叠加第二个确认 Dialog。 +- AI 队友私聊中,用户在当前对话的权限菜单里主动选择 `full_access` 时先显示确认 Dialog; + 确认成功后切换预设,后续发送不再叠加第二个确认 Dialog。 - AI 队友私聊从工作区默认值继承 `full_access` 时,不视为用户在当前对话主动确认;首次真实运行仍显示确认 Dialog。 - 工作区执行路径改变、默认权限预设改变、授权模式改变或 Approval Policy 更新后重新确认。 - 确认以不可读 scope 摘要写入 Web/desktop renderer localStorage,最多保留最近 256 个对话;不保存路径明文、密钥或权限令牌。 @@ -27,11 +28,12 @@ ## UI Contract - 未确认时显示“完全访问”。 -- 当前 scope 已确认时显示“完全访问 · 本对话已确认”。 +- 普通对话保持紧凑的“完全访问”按钮,当前 scope 已确认时通过辅助名称和 tooltip 显示“完全访问 · 本对话已确认”。 +- 频道和 AI 队友私聊在当前 scope 已确认时直接显示“完全访问 · 本对话已确认”。 - 该文案只说明当前会话 scope 已确认,不表示工作区或所有对话的永久授权。 - Dialog 保留工作区外文件、网络和工具风险说明,并明确确认只作用于当前对话。 - 取消确认不发送消息、不创建运行、不清空草稿。 -- 普通对话和 AI 队友私聊主动选择完全访问后立即显示“完全访问 · 本对话已确认”;仅由默认值继承时保持未确认文案。 +- 主动选择完全访问并完成 Dialog 确认后,立即更新对应的确认状态;仅由默认值继承时保持未确认状态。 ## Invalidation @@ -51,5 +53,5 @@ - 不改 daemon route、public API、contracts、SQLite schema 或 full-access enforcement。 - 不增加“始终允许”“记住此工作区”或跨会话复用。 - 不把频道、普通对话和队友私聊合并为同一授权 scope。 -- 不改变频道权限选择与任务运行时确认行为;主动选择即确认仅适用于普通对话和 AI 队友私聊。 +- 不改变频道权限选择与任务运行时确认行为;普通对话和 AI 队友私聊在主动选择阶段完成确认,后续运行复用该确认。 - Runtime tool approval 仅在更严格的 Agent/Skill Approval Profile 等有效策略仍要求 `ask` 时出现;外部动作及其他专用审批继续独立生效。 From 59735aa76d4e8ba94a51f82da4bf835d83d0fe99 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Fri, 23 Oct 2026 20:37:36 +0800 Subject: [PATCH 4/4] fix(ci): align approval label and source checksum exceptions --- .gitleaksignore | 14 ++++++++++++++ apps/web/e2e/ui2-shell-feedback-permission.spec.ts | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index d3324e704..556bd9cb5 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -77,3 +77,17 @@ de71ccccd55f21e909cb597347bcc6e1f81103e3:packages/daemon/test/media-native-trans 8fbb9e106d1b0e85f07a3d05abaea73b088b5382:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1251 8fbb9e106d1b0e85f07a3d05abaea73b088b5382:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1252 8fbb9e106d1b0e85f07a3d05abaea73b088b5382:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1253 + +# Exact source-file SHA-256 values in the reviewed release manifest. +# Verified against each referenced file at commit 32834956; not credentials. +32834956f98c5b379f9c572257fe4f1069813932:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1367 +32834956f98c5b379f9c572257fe4f1069813932:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1420 +32834956f98c5b379f9c572257fe4f1069813932:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1425 +32834956f98c5b379f9c572257fe4f1069813932:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1428 +32834956f98c5b379f9c572257fe4f1069813932:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1435 +32834956f98c5b379f9c572257fe4f1069813932:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1436 +32834956f98c5b379f9c572257fe4f1069813932:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1438 +32834956f98c5b379f9c572257fe4f1069813932:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1442 +32834956f98c5b379f9c572257fe4f1069813932:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:1484 +32834956f98c5b379f9c572257fe4f1069813932:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:861 +32834956f98c5b379f9c572257fe4f1069813932:native/office-local/reviewed-packaged-source-v1.json:generic-api-key:972 diff --git a/apps/web/e2e/ui2-shell-feedback-permission.spec.ts b/apps/web/e2e/ui2-shell-feedback-permission.spec.ts index c05434c05..ec5b7f5e6 100644 --- a/apps/web/e2e/ui2-shell-feedback-permission.spec.ts +++ b/apps/web/e2e/ui2-shell-feedback-permission.spec.ts @@ -109,12 +109,12 @@ test("new conversation metadata controls stay quiet and compact without a config await page.goto("/"); const home = page.getByLabel("对话首页"); - const permission = home.getByRole("button", { name: "已选择权限预设 工作区写入" }); + const permission = home.getByRole("button", { name: "已选择权限预设 帮我审批" }); const model = home.locator(".conversation-model-trigger"); const shortcut = home.getByRole("button", { name: "创建 Team" }); await expect(permission).toBeVisible(); await expect(model).toBeVisible(); - await expect(permission).toContainText("工作区写入"); + await expect(permission).toContainText("帮我审批"); await expect(model).toContainText("未选择聊天模型"); const [permissionStyle, modelStyle, shortcutFontSize] = await Promise.all([