diff --git a/design-system/packages/ui/src/components/Icon/Icon.tsx b/design-system/packages/ui/src/components/Icon/Icon.tsx index 853dc58463..18d67f8fc0 100644 --- a/design-system/packages/ui/src/components/Icon/Icon.tsx +++ b/design-system/packages/ui/src/components/Icon/Icon.tsx @@ -95,6 +95,7 @@ import { import { classNames } from "../../internal/classNames"; import { Galaxy } from './galaxy'; import { LayersPlus } from './layersPlus'; +import { PlayingCardsFan } from './playingCardsFan'; import gitUrl from "./assets/git.svg"; import thinkingUrl from "./assets/thinking.svg"; import creativeUrl from "./assets/creative.svg"; @@ -176,6 +177,7 @@ export const iconNames = [ "palette", "panels-top-left", "pin", + "playing-cards-fan", "plus", "plug", "progress-25", @@ -307,6 +309,7 @@ const lineGlyphs = { "palette": Palette, "panels-top-left": PanelsTopLeft, "pin": Pin, + "playing-cards-fan": PlayingCardsFan, "plus": Plus, "plug": Plug, "progress-25": LoaderCircle, diff --git a/design-system/packages/ui/src/components/Icon/playingCardsFan.ts b/design-system/packages/ui/src/components/Icon/playingCardsFan.ts new file mode 100644 index 0000000000..85cde60628 --- /dev/null +++ b/design-system/packages/ui/src/components/Icon/playingCardsFan.ts @@ -0,0 +1,25 @@ +/** + * Lucide playing-cards-fan, absent from the currently bundled lucide-react 0.560. + * Source: https://github.com/lucide-icons/lucide/blob/1.36.0/icons/playing-cards-fan.svg + * Replace with the package export when the shared Lucide dependency is upgraded. + * + * ISC License + * Copyright (c) 2026 Lucide Icons and Contributors + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ +import { createLucideIcon } from 'lucide-react'; + +export const PlayingCardsFan = createLucideIcon('PlayingCardsFan', [ + ['path', { d: 'M12.65 7.65a2 2 0 012.629-1.046l5.51 2.374a2 2 0 011.046 2.628l-3.957 9.184a2 2 0 01-2.628 1.046l-5.51-2.374a2 2 0 01-1.046-2.628z', key: 'front-card' }], + ['path', { d: 'M18 7.777V4a2 2 0 00-2-2h-6a2 2 0 00-2 2v10a2 2 0 001.137 1.805', key: 'middle-card' }], + ['path', { d: 'm8 4.389-4.364.809a2 2 0 00-1.602 2.33l1.822 9.833a2 2 0 002.331 1.602l2.542-.47', key: 'back-card' }], +]); diff --git a/design-system/packages/ui/src/components/Menu/MenuPopover.tsx b/design-system/packages/ui/src/components/Menu/MenuPopover.tsx index b50ced3712..f34ed4b45c 100644 --- a/design-system/packages/ui/src/components/Menu/MenuPopover.tsx +++ b/design-system/packages/ui/src/components/Menu/MenuPopover.tsx @@ -36,6 +36,10 @@ export interface MenuPopoverProps extends Omit { ownerRef?: RefObject; position?: { x: number; y: number }; placement?: LayerPlacement; + /** Replace the trigger visually while keeping the layer attached to it. */ + overlapAnchor?: boolean; + /** Retain custom closing motion before releasing the layer. */ + exitDurationMs?: number; /** Stable wrappers must forward all props (and refs for root/item/separator). */ parts?: MenuPopoverParts; } @@ -57,11 +61,11 @@ function ownItems(menu: HTMLElement) { } /** Anchored/coordinate menu with nested navigation, safe pointer corridors and focus return. */ -export function MenuPopover({ items, open, onClose, anchorRef, ownerRef, position, placement = "bottom", autoFocusFirstItem = true, ...props }: MenuPopoverProps) { +export function MenuPopover({ items, open, onClose, anchorRef, ownerRef, position, placement = "bottom", overlapAnchor = false, exitDurationMs = 100, autoFocusFirstItem = true, ...props }: MenuPopoverProps) { const markerRef = useRef(null); const menuRef = useRef(null); const treeId = useId(); - const { present, state: phase } = usePresence(open, 100); + const { present, state: phase } = usePresence(open, exitDurationMs); const resolvedAnchor = anchorRef ?? markerRef; const branchRefs = anchorRef ? [anchorRef] : []; const closeAndRestoreFocus = () => { @@ -96,6 +100,7 @@ export function MenuPopover({ items, open, onClose, anchorRef, ownerRef, positio open={open} phase={phase} placement={placement} + overlapAnchor={overlapAnchor} position={position} treeId={treeId} /> @@ -118,11 +123,12 @@ interface MenuLevelProps extends Omit { anchorRef: RefObject; position?: { x: number; y: number }; placement: LayerPlacement; + overlapAnchor?: boolean; menuRef?: RefObject; parts?: MenuPopoverParts; } -function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, position, placement, menuRef: externalRef, autoFocusFirstItem, className, inlineSize, style, parts, ...props }: MenuLevelProps) { +function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, position, placement, overlapAnchor, menuRef: externalRef, autoFocusFirstItem, className, inlineSize, style, parts, ...props }: MenuLevelProps) { const MenuSurface = parts?.root ?? Menu; const Item = parts?.item ?? MenuItem; const Separator = parts?.separator ?? MenuSeparator; @@ -140,7 +146,7 @@ function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, pos const submenuId = useId(); const intent = useSubmenuIntent({ activeId, onActiveIdChange: setActiveId, parentRef: menuRef, submenuRef, enabled: open, openDelayMs: 150, closeDelayMs: 300, switchDelayMs: 300, tolerance: 50 }); // Keep geometry until presence unmounts the menu, including its exit transition. - const layout = useAnchoredLayer({ open: true, anchorRef, layerRef: menuRef, placement, point: position }); + const layout = useAnchoredLayer({ open: true, anchorRef, layerRef: menuRef, placement, overlapAnchor, point: position }); const activeEntry = items.find(item => item.id === activeId && !item.disabled && item.submenu?.length); const openSubmenu = (item: MenuEntry, trigger: HTMLElement, keyboard: boolean) => { if (item.disabled || !item.submenu?.length) return; diff --git a/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx b/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx index 3224ea4b99..84f615fc7c 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx @@ -27,7 +27,7 @@ export interface TabGroupItem { labelTransitionKey?: string | number; panelId?: string; /** Presentation and product data attributes on the native tab; selection stays owned by TabGroup. */ - tabProps?: Pick, "className" | "style" | "title" | "aria-label" | "aria-describedby"> + tabProps?: Pick, "className" | "style" | "title" | "aria-label" | "aria-describedby" | "aria-haspopup" | "aria-expanded"> & Record<`data-${string}`, string | number | boolean | undefined>; value: string; } diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx index c864dc561c..4f78c4d184 100644 --- a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -562,7 +562,8 @@ export function Tooltip({ return ( <> - + {/* Disabling an authored hint must not enable a nested overflow hint. */} + {triggerElement} {visible && ( diff --git a/design-system/packages/ui/src/internal/tooltipTriggerContext.ts b/design-system/packages/ui/src/internal/tooltipTriggerContext.ts index 4944602639..03f48fbb80 100644 --- a/design-system/packages/ui/src/internal/tooltipTriggerContext.ts +++ b/design-system/packages/ui/src/internal/tooltipTriggerContext.ts @@ -1,6 +1,6 @@ import { createContext } from "react"; -/** An explicit tooltip owns this subtree; text slots should not open another one. */ +/** An explicit tooltip owns this subtree even while disabled; text slots must not open another one. */ export const TooltipTriggerContext = createContext(false); /** A legacy title fallback must yield to authored tooltips, not claim their trigger. */ diff --git a/design-system/packages/ui/tests/icon.test.mjs b/design-system/packages/ui/tests/icon.test.mjs index 85deda0545..08dc3cdc6d 100644 --- a/design-system/packages/ui/tests/icon.test.mjs +++ b/design-system/packages/ui/tests/icon.test.mjs @@ -8,8 +8,8 @@ import { Icon, iconNames, canonicalIconNames, iconAliases } from "../dist/index. import { Network } from "lucide-react"; test("Icon exposes the complete named catalog without duplicate names", () => { - assert.equal(iconNames.length, 100); - assert.equal(canonicalIconNames.length, 97); + assert.equal(iconNames.length, 101); + assert.equal(canonicalIconNames.length, 98); assert.deepEqual(Object.keys(iconAliases).sort(), ["circle", "download"]); for (const name of ["thinking", "git", "duplicate", "chevron-left", "selected", "delete", "waitlist-message", "creative", "ultimate", "standard", "minimal", "arrow-down", "unselected"]) { assert.ok(canonicalIconNames.includes(name), name); @@ -34,7 +34,7 @@ test("Icon exposes the complete named catalog without duplicate names", () => { assert.ok(iconNames.includes("chevron-up")); assert.ok(iconNames.includes("refresh")); assert.ok(iconNames.includes("layers-plus")); - for (const name of ["target", "users", "history", "git-pull-request", "battery-full", "galaxy"]) { + for (const name of ["target", "users", "history", "git-pull-request", "battery-full", "galaxy", "playing-cards-fan"]) { assert.ok(canonicalIconNames.includes(name)); assert.match(renderToStaticMarkup(createElement(Icon, { name })), new RegExp(`lucide-${name}`)); } diff --git a/design-system/packages/ui/tests/tab-group.test.mjs b/design-system/packages/ui/tests/tab-group.test.mjs index e532307b9d..7bc8411035 100644 --- a/design-system/packages/ui/tests/tab-group.test.mjs +++ b/design-system/packages/ui/tests/tab-group.test.mjs @@ -48,6 +48,7 @@ test("tab presentation props reach the button without replacing its owned semant className: "product-tab", "data-openbitfun-product-component": "product", "data-openbitfun-product-part": "tab", title: "Full title", "aria-describedby": "help", style: { opacity: 0.8 }, + "aria-haspopup": "menu", "aria-expanded": true, // Untyped callers must not override component-owned interaction/identity. role: "button", type: "submit", tabIndex: 8, id: "wrong-id", "aria-selected": false, "aria-controls": "wrong-panel", "data-openbitfun-part": "wrong-part", @@ -58,6 +59,8 @@ test("tab presentation props reach the button without replacing its owned semant assert.match(button, /data-openbitfun-product-component="product"/); assert.match(button, /data-openbitfun-product-part="tab"/); assert.match(button, /aria-describedby="help"/); + assert.match(button, /aria-haspopup="menu"/); + assert.match(button, /aria-expanded="true"/); assert.match(button, /style="opacity:0.8"/); assert.match(button, /title="Full title"/); assert.match(button, /id="product-tab"/); diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index 2441d2ae9c..754dfa87e1 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -192,6 +192,15 @@ Narrower local guides already exist for some subtrees: ## Verification +For Agent name references, enabled-catalog admission, and prompt catalog names: + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib agentic::tools::implementations::task::tests +``` + +These fixtures cover prompt name mappings and simulated workspace policies; +they do not establish live SSH, relay, Peer, or Detached Dispatch behavior. + For smart routing, unchanged subagent model policy and resumed subagent choices: ```bash diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs index 16d53c30c7..58f438ef55 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs @@ -92,8 +92,9 @@ impl AgentExecutionTool { let mut out = String::from("\n"); for agent in agents { out.push_str(&format!( - "\n\n{}\n\n{}\n\n", + "\n{}\n\n{}\n\n{}\n\n", agent.id, + agent.name.replace('&', "&").replace('<', "<").replace('>', ">"), agent.description, agent.default_tools.join(", ") )); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs index e7543ea8e5..5c1f207f27 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs @@ -90,7 +90,7 @@ Two modes for action='spawn': The two modes are mutually exclusive: do not provide `subagent_type` when `fork_context=true`. 1. With an explicit `subagent_type` (default) - Provide `agent_id`, `subagent_type`, and `prompt`. - - Available types are listed in the section. Each type has specific capabilities and tools. + - Available types and names are listed in the section. Users reference agents by name; match their requested name to this catalog and pass the corresponding internal type. If a name is ambiguous, ask which agent they mean instead of choosing one. Refer to agents by name when talking to the user; do not ask users to provide internal types. Each type has specific capabilities and tools. - In this mode, the subagent does not share your context. Include all necessary background information in the prompt. 2. By forking the current context - Set `fork_context=true`, and provide `agent_id` and `prompt`. Do not provide `subagent_type`. @@ -162,7 +162,7 @@ impl AgentSpawnTool { pub(super) fn render_agent_spawn_description(&self) -> String { r#"Launch a new agent to work independently in the background. -Choose an `agent_type` permitted by the current system prompt. Each type has its own role and tool set. +Choose an `agent_type` permitted by the current system prompt. The catalog includes each agent's name and internal type. Users reference agents by name; match their requested name to this catalog and pass the corresponding internal type as `agent_type`. If a name matches multiple agents, ask for clarification instead of choosing one. Refer to agents by name when talking to the user; do not ask users to provide internal types. Each type has its own role and tool set. Choose a meaningful `agent_id` for this agent. It must be unique among agents launched by you and match `[a-z][a-z0-9_-]{0,31}`. diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs index 94ea47f44f..347bb98af2 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs @@ -25,6 +25,27 @@ struct PromptOrderTestAgent { id: String, } +fn named_agent(id: &str, name: &str) -> crate::agentic::agents::AgentInfo { + serde_json::from_value(json!({ + "key": id, "id": id, "name": name, "description": "A specialist agent", + "isReadonly": true, "isReview": false, "toolCount": 1, + "defaultTools": ["Read"], "promptCacheScopeKey": "test", "source": "user" + })) + .expect("legacy catalog entry should deserialize") +} + +#[test] +fn agent_name_reference_catalog_exposes_the_display_name_and_escapes_markup() { + let available = vec![named_agent("agent-internal", "Research & ")]; + let listing = AgentExecutionTool::format_agent_descriptions(&available); + assert!(listing.contains("")); + assert!(listing.contains("Research & <review>")); + assert!(listing.contains("Read")); + assert!(AgentSpawnTool + .render_agent_spawn_description() + .contains("Users reference agents by name")); +} + #[test] fn external_subagent_validation_accepts_model_emitted_casing() { let available = vec!["FileFinder".to_string(), "explore".to_string()]; diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 5b4dedaeb7..b449b2eb84 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -108,6 +108,15 @@ deliberate layout transitions or animate virtualized content. Rely on CI for full lint, build, and broad test coverage unless the local change specifically needs it. +For Agent creation, hidden internal IDs, editing, and surface-switch draft fences: + +```bash +pnpm --dir src/web-ui run test:run src/app/scenes/agents/components/CreateAgentDialog.test.tsx src/app/scenes/agents/components/suggestAgentId.test.ts src/app/scenes/agents/components/CreateAgentPage.presentation.test.ts src/app/scenes/agents/AgentsScene.test.tsx src/app/scenes/personal-overview/PersonalOverviewScene.actions.test.tsx +``` + +These DOM fixtures cover local forms and simulated Peer surface switches; +they do not establish native rendering or live remote behavior. + For navigation-divider dragging, elastic feedback, cancellation, and keyboard resizing: ```bash @@ -172,6 +181,15 @@ also run the focused state contracts: pnpm --dir src/web-ui run test:run src/app/services/sessionSceneLifecycle.test.ts src/flow_chat/services/sessionActivation.test.ts src/flow_chat/services/storeSync.test.ts src/app/stores/sceneStore.test.ts ``` +For top workspace tab recent-session disclosure, selection and reconciliation: + +```bash +pnpm --dir src/web-ui run test:run src/app/components/SceneBar/SceneBar.test.tsx src/app/stores/sceneStore.test.ts src/app/services/sessionSceneLifecycle.test.ts src/app/hooks/useSessionTabLabels.test.tsx src/shared/ui/MenuPopover.test.tsx src/shared/ui/MenuComposition.contract.test.ts +``` + +These local DOM/store fixtures cover bounded history, legacy tabs and simulated +SSH/Peer identities; they do not establish native rendering or live remote behavior. + For composer mode restoration, passive synchronization, or explicit mode selection: ```bash @@ -218,6 +236,15 @@ pnpm --dir src/web-ui run test:run src/app/components/NavPanel/components/Worksp These DOM and store fixtures do not establish native rendering or live remote behavior. +For assistant history disclosure and default-session selection: + +```bash +pnpm --dir src/web-ui run test:run src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts src/app/components/NavPanel/sections/workspaces/AssistantSessionHistory.test.tsx src/app/components/NavPanel/sections/workspaces/assistantSessionPreferences.test.ts +``` + +These local fixtures cover inline disclosure and legacy-host preference gating; +they do not establish native rendering or live remote behavior. + For application update discovery, skip persistence, notification timing, and download/install transitions, run the focused behavior tests (these do not establish visual fidelity): diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 61b25beb2f..7b23f2ceef 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -25,7 +25,10 @@ margin: 0 var(--openbitfun-space-1) 0 calc(var(--openbitfun-space-1) + 4px); &.is-flat-workspace-view { - margin: 0 var(--openbitfun-space-3); + // The avatar artwork is inset inside its box. Use the navigation's 8px + // optical inset for titles and the toggle, including the 1px list margin. + --openbitfun-nav-session-rail: calc(var(--openbitfun-space-2) - 1px); + margin: 0 1px; padding: 2px 0; } } @@ -246,8 +249,8 @@ } } - // Status and the menu share one trailing cell, including when status is idle. - // Visibility changes never alter the title's available width. + // Nested rows keep one trailing cell, including when status is idle. + // Flat lists release unused menu space below. &__inline-item-trailing { display: grid; flex: 0 0 var(--openbitfun-space-5); @@ -267,6 +270,27 @@ } } + &__inline-list.is-flat-workspace-view { + .openbitfun-nav-panel__inline-item.has-workspace-name .openbitfun-nav-panel__inline-item-trailing { + // Only an actual status occupies row space; hidden actions do not. + display: contents; + } + + .openbitfun-nav-panel__inline-item.has-workspace-name .openbitfun-nav-panel__inline-item-actions { + position: absolute; + top: 50%; + right: var(--openbitfun-space-1); + transform: translateY(-50%); + } + + // Preserve title geometry while the project name yields to row actions. + .openbitfun-nav-panel__inline-item:hover .openbitfun-nav-panel__inline-item-workspace-name, + .openbitfun-nav-panel__inline-item:focus-within .openbitfun-nav-panel__inline-item-workspace-name, + .openbitfun-nav-panel__inline-item.is-menu-open .openbitfun-nav-panel__inline-item-workspace-name { + visibility: hidden; + } + } + &__inline-item-label { flex: 1 1 0; min-width: 0; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 0c16d61cbb..8793dd23a5 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -1619,6 +1619,7 @@ const SessionsSection: React.FC = ({
({ vi.mock('../sessions/SessionsSection', () => ({ default: ({ defaultSessionAction: action, onSessionOpened }: { defaultSessionAction: { sessionId: string | null; disabled: boolean; onChange: (id: string) => void }; - onSessionOpened: () => void; + onSessionOpened?: () => void; }) => <> @@ -25,25 +25,21 @@ vi.mock('../sessions/SessionsSection', () => ({ , })); vi.mock('@openbitfun/ui', () => { - const Wrapper = ({ children }: React.PropsWithChildren) =>
{children}
; return { - Dialog: Wrapper, DialogBody: Wrapper, DialogFooter: Wrapper, DialogHeader: Wrapper, - DialogHeading: Wrapper, DialogTitle: Wrapper, DialogClose: () => null, Button: ({ children, disabled, onClick }: React.ButtonHTMLAttributes) => ( ), }; }); -import AssistantSessionHistoryDialog from './AssistantSessionHistoryDialog'; +import AssistantSessionHistory from './AssistantSessionHistory'; describe('assistant history default selection', () => { let root: Root; let container: HTMLDivElement; const workspace = { id: 'assistant', name: 'Assistant', rootPath: '/assistant', workspaceKind: 'assistant' } as WorkspaceInfo; - const onClose = vi.fn(); const render = async () => { - await act(async () => root.render()); + await act(async () => root.render()); }; beforeEach(() => { vi.clearAllMocks(); @@ -67,7 +63,6 @@ describe('assistant history default selection', () => { await act(async () => clear.click()); expect(mocks.save).toHaveBeenLastCalledWith('assistant', null); expect(container.querySelector('[data-default]')?.getAttribute('data-default')).toBe(''); - expect(onClose).not.toHaveBeenCalled(); }); it('retains the previous default if saving fails', async () => { @@ -83,7 +78,8 @@ describe('assistant history default selection', () => { await render(); expect(container.querySelector('[data-set-default]')!.disabled).toBe(true); await act(async () => container.querySelector('[data-open-session]')!.click()); - expect(onClose).toHaveBeenCalledOnce(); + expect(container.querySelector('[data-open-session]')).not.toBeNull(); + expect(container.querySelector('[data-set-default]')).not.toBeNull(); expect(mocks.save).not.toHaveBeenCalled(); }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/AssistantSessionHistoryDialog.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/AssistantSessionHistory.tsx similarity index 63% rename from src/web-ui/src/app/components/NavPanel/sections/workspaces/AssistantSessionHistoryDialog.tsx rename to src/web-ui/src/app/components/NavPanel/sections/workspaces/AssistantSessionHistory.tsx index 5095041c60..4fdad95c4d 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/AssistantSessionHistoryDialog.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/AssistantSessionHistory.tsx @@ -1,9 +1,5 @@ -import { resolveAssistantName } from '@/shared/utils/assistantName'; import { useCallback, useEffect, useState } from 'react'; -import { - Button, Dialog, DialogBody, DialogClose, DialogFooter, DialogHeader, - DialogHeading, DialogTitle, -} from '@openbitfun/ui'; +import { Button } from '@openbitfun/ui'; import { useI18n } from '@/infrastructure/i18n'; import { getActiveSurfaceScope, isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; @@ -14,21 +10,19 @@ import { notificationService } from '@/shared/notification-system'; import type { WorkspaceInfo } from '@/shared/types'; import SessionsSection from '../sessions/SessionsSection'; import { readAssistantDefaultSession, saveAssistantDefaultSession } from './assistantSessionPreferences'; -import './AssistantSessionHistoryDialog.scss'; +import './AssistantSessionHistory.scss'; interface Props { workspace: WorkspaceInfo; isActive: boolean; - onClose: () => void; } -export default function AssistantSessionHistoryDialog({ workspace, isActive, onClose }: Props) { +export default function AssistantSessionHistory({ workspace, isActive }: Props) { const { t } = useI18n('common'); const [preference, setPreference] = useState<{ supported: boolean; sessionId: string | null } | null>(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [loadFailed, setLoadFailed] = useState(false); - const name = resolveAssistantName(workspace, t('shared:agents.Claw')); const loadPreference = useCallback(async () => { const scope = getActiveSurfaceScope(); @@ -78,42 +72,28 @@ export default function AssistantSessionHistoryDialog({ workspace, isActive, onC : !preference?.supported ? t('nav.sessions.defaultUnsupported') : undefined; return ( - { if (!open) onClose(); }} size="md"> - - - {t('nav.sessions.assistantHistory', { name })} - - - - - {loadFailed && ( - - )} -
- { void changeDefault(sessionId); }, - }} - /> -
-
+
+ {loadFailed && ( + + )} + { void changeDefault(sessionId); }, + }} + /> {preference?.sessionId && ( - - - + )} -
+
); } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index eb1db0372b..5f56c038be 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -17,8 +17,8 @@ import { DialogTitle, OverflowText, } from '@openbitfun/ui'; -import React, { Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import { EyeOff, FolderOpen, FolderSearch, History, MessageCirclePlus, RotateCcw, ListChecks, ShieldCheck, Network, Server } from 'lucide-react'; +import React, { Suspense, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { ChevronUp, EyeOff, FolderOpen, FolderSearch, History, MessageCirclePlus, RotateCcw, ListChecks, ShieldCheck, Network, Server } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { RetainedMountBoundary } from '@/shared/presence'; import { InputDialog } from '@/app/components/InputDialog'; @@ -77,7 +77,7 @@ const PortForwardDialog = lazyWithRecovery(() => default: module.PortForwardDialog, })) ); -const AssistantSessionHistoryDialog = lazyWithRecovery(() => import('./AssistantSessionHistoryDialog')); +const AssistantSessionHistory = lazyWithRecovery(() => import('./AssistantSessionHistory')); const WorkspaceSessionBatchModal = lazyWithRecovery(() => import('./WorkspaceSessionBatchModal')); const ScheduledJobsModal = lazyWithRecovery(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); @@ -145,6 +145,7 @@ const WorkspaceItem: React.FC = ({ const [isResettingWorkspace, setIsResettingWorkspace] = useState(false); const [sessionsCollapsed, setSessionsCollapsed] = useState(false); const [assistantHistoryOpen, setAssistantHistoryOpen] = useState(false); + const assistantHistoryId = useId(); const openingAssistantRef = useRef(false); const sessionDisclosureIcon = sessionsCollapsed ? 'chevron-right' : 'chevron-down'; const collapseAllRequestId = useWorkspaceSessionViewStore(state => state.collapseAllRequestId); @@ -154,7 +155,10 @@ const WorkspaceItem: React.FC = ({ const [renameDialogOpen, setRenameDialogOpen] = useState(false); useEffect(() => { - if (collapseAllRequestId > 0) setSessionsCollapsed(true); + if (collapseAllRequestId > 0) { + setSessionsCollapsed(true); + setAssistantHistoryOpen(false); + } }, [collapseAllRequestId]); const [workspaceSearchEnabled, setWorkspaceSearchEnabled] = useState( () => aiExperienceConfigService.getSettings().enable_workspace_search, @@ -883,19 +887,20 @@ const WorkspaceItem: React.FC = ({ - + @@ -995,15 +1000,23 @@ const WorkspaceItem: React.FC = ({ - {assistantHistoryOpen && ( - - setAssistantHistoryOpen(false)} - /> - - )} + { .toContain('opacity: 1;'); }); - it('keeps assistant history in a dialog without inline disclosure or a resource shortcut', () => { + it('expands assistant history below its row with a collapse icon and no resource shortcut', () => { const source = readWorkspaceItemSource(); const start = source.indexOf('if (workspace.workspaceKind === WorkspaceKind.Assistant)'); const assistant = source.slice(start, source.indexOf('\n return (', start)); @@ -296,7 +296,14 @@ describe('WorkspaceListSection layout styles', () => { expect(source).toContain('useNurseryStore.getState().openAssistant(workspace.id)'); expect(source).toContain("useSceneStore.getState().openScene('assistant')"); expect(assistant).toContain('{assistantHistoryOpen && ('); - expect(assistant).toContain('onClose={() => setAssistantHistoryOpen(false)}'); + expect(assistant).toContain('data-testid="nav-assistant-session-region"'); + expect(assistant).toContain('hidden={!assistantHistoryOpen}'); + expect(assistant).toContain('aria-expanded={assistantHistoryOpen}'); + expect(assistant).toContain('aria-controls={assistantHistoryId}'); + expect(assistant).toContain('setAssistantHistoryOpen(open => !open)'); + expect(assistant).toContain('glyph={assistantHistoryOpen ? ChevronUp : History}'); + expect(assistant).not.toContain('aria-haspopup="dialog"'); + expect(assistant).not.toContain('AssistantSessionHistoryDialog'); expect(assistant).toContain('handleOpenLatestAssistantSession'); }); }); diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts b/src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts index 885814152e..6a93a6bcda 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts @@ -8,5 +8,6 @@ export const sceneBarAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'scrollNext' }, { id: 'controls' }, { id: 'dropHint' }, + { id: 'history' }, ], }; diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index 09bbcbdb05..1c9f85e255 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -19,6 +19,9 @@ flex: 0 0 auto; min-width: 0; + // Keep the tab's layout and hit target while its portalled surface expands. + &[data-session-history-open='true'] [role='tab'] { opacity: 0; } + &[data-canvas-drop-position]::after { content: ''; position: absolute; diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx index 5b13a58050..9fff98ffc4 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx @@ -53,7 +53,9 @@ vi.mock('../../hooks/useSceneManager', () => ({ })); vi.mock('../../hooks/useSessionTabLabels', () => ({ - useSessionTabLabels: () => ({ session: { title: sceneHarness.state.sessionTitle ?? '' } }), + useSessionTabLabels: (tabs: SceneTab[]) => Object.fromEntries(tabs.map(tab => [tab.id, + { title: tab.id === 'session' ? sceneHarness.state.sessionTitle ?? '' : tab.session?.sessionId ?? '' }, + ])), })); vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ @@ -91,7 +93,7 @@ describe('SceneBar overflow navigation', () => { sceneHarness.state.pendingTabId = null; sceneHarness.state.navigationMotion = 'instant'; sceneHarness.state.sessionTitle = undefined; - for (const tab of sceneHarness.state.openTabs) delete tab.session; + for (const tab of sceneHarness.state.openTabs) { delete tab.session; delete tab.sessionHistory; } sceneHarness.activateScene.mockReset(); sceneHarness.closeScene.mockReset(); clearAgentCanvasForPeerSwitch(); @@ -103,12 +105,146 @@ describe('SceneBar overflow navigation', () => { act(() => root.unmount()); container.remove(); activateSurface('local'); + vi.restoreAllMocks(); }); function renderSceneBar() { act(() => root.render()); } + function withSessionHistory() { + const tab = sceneHarness.state.openTabs[0]; + tab.session = { surfaceId: 'local', workspaceKey: 'workspace-a', sessionId: 'six' }; + tab.sessionHistory = ['six', 'five', 'four', 'three', 'two']; + return tab; + } + + const visibleHistory = () => document.querySelector('[role="menu"]:not([aria-hidden="true"])'); + + it('switches on the first click and opens the five recent sessions only when the selected tab is clicked again', () => { + withSessionHistory(); + sceneHarness.state.activeTabId = 'git'; + renderSceneBar(); + const trigger = container.querySelector('[role="tab"][data-openbitfun-value="session"]')!; + act(() => trigger.click()); + expect(sceneHarness.activateScene).toHaveBeenCalledWith('session'); + expect(visibleHistory()).toBeNull(); + sceneHarness.state.activeTabId = 'session'; + renderSceneBar(); + act(() => trigger.click()); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + expect([...visibleHistory()!.querySelectorAll('[role="menuitemradio"]')].map(item => item.textContent)) + .toEqual(['six', 'five', 'four', 'three', 'two']); + expect(visibleHistory()!.querySelector('[aria-checked="true"]')?.textContent).toBe('six'); + act(() => trigger.click()); + expect(visibleHistory()).toBeNull(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('selects a recent session through the guarded scene navigation and closes the list', () => { + const tab = withSessionHistory(); + const open = vi.spyOn(useSceneStore.getState(), 'openSessionScene').mockImplementation(() => {}); + useSceneStore.setState({ openTabs: [tab] }); + renderSceneBar(); + const trigger = container.querySelector('[role="tab"]')!; + act(() => trigger.click()); + act(() => visibleHistory()!.querySelector('[data-menu-id="four"]')!.click()); + expect(open).toHaveBeenCalledWith({ ...tab.session, sessionId: 'four' }, expect.anything()); + const guard = open.mock.calls[0][1]!.isCurrent!; + expect(guard()).toBe(true); + useSceneStore.setState({ openTabs: [] }); + expect(guard()).toBe(false); + expect(visibleHistory()).toBeNull(); + }); + + it('starts the floating surface at the capsule bounds and keeps the original tab mounted', () => { + withSessionHistory(); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.getAttribute('role') === 'tab') return new DOMRect(100, 14, 148, 28); + if (this.getAttribute('role') === 'menu') return new DOMRect(100, 14, 220, 210); + return new DOMRect(); + }); + renderSceneBar(); + const trigger = container.querySelector('[role="tab"]')!; + act(() => trigger.click()); + const menu = visibleHistory()!; + expect(menu.style.left).toBe('100px'); + expect(menu.style.top).toBe('14px'); + expect(menu.style.getPropertyValue('--_session-history-source-width')).toBe('148px'); + expect(menu.style.getPropertyValue('--_session-history-source-height')).toBe('28px'); + expect(container.querySelector('[role="tab"]')).toBe(trigger); + }); + + it('supports ArrowDown, Escape and outside dismissal, and closes history on device switches', () => { + withSessionHistory(); + renderSceneBar(); + const trigger = container.querySelector('[role="tab"]')!; + act(() => { + trigger.focus(); + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); + }); + expect(visibleHistory()).not.toBeNull(); + expect(document.activeElement?.textContent).toBe('six'); + act(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + expect(visibleHistory()).toBeNull(); + expect(document.activeElement).toBe(trigger); + act(() => trigger.click()); + act(() => document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))); + expect(visibleHistory()).toBeNull(); + act(() => trigger.click()); + expect(visibleHistory()).not.toBeNull(); + act(() => activateSurface('peer:history')); + expect(visibleHistory()).toBeNull(); + }); + + it('does not open recent sessions while activation is pending or when the close action is clicked', () => { + withSessionHistory(); + sceneHarness.state.pendingTabId = 'session'; + renderSceneBar(); + act(() => container.querySelector('[role="tab"]')!.click()); + expect(visibleHistory()).toBeNull(); + sceneHarness.state.pendingTabId = null; + renderSceneBar(); + act(() => container.querySelector('[data-scene-bar-part="closeTab"]')!.click()); + expect(sceneHarness.closeScene).toHaveBeenCalledWith('session'); + expect(visibleHistory()).toBeNull(); + }); + + it('focuses a background pane before offering its selected workspace history', () => { + withSessionHistory(); + useSceneStore.setState({ focusedPane: 'secondary' }); + renderSceneBar(); + const trigger = container.querySelector('[role="tab"]')!; + act(() => trigger.click()); + expect(sceneHarness.activateScene).toHaveBeenCalledWith('session'); + expect(visibleHistory()).toBeNull(); + act(() => useSceneStore.setState({ focusedPane: 'primary' })); + act(() => trigger.click()); + expect(visibleHistory()).not.toBeNull(); + }); + + it.each([undefined, ['six']])('keeps a workspace with only its current session collapsed (history: %s)', history => { + const tab = withSessionHistory(); + tab.sessionHistory = history; + renderSceneBar(); + const trigger = container.querySelector('[role="tab"]')!; + act(() => trigger.click()); + act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))); + expect(visibleHistory()).toBeNull(); + expect(trigger.hasAttribute('aria-haspopup')).toBe(false); + expect(trigger.hasAttribute('aria-expanded')).toBe(false); + }); + + it('collapses when reconciliation leaves only the current session in history', () => { + const tab = withSessionHistory(); + renderSceneBar(); + act(() => container.querySelector('[role="tab"]')!.click()); + expect(visibleHistory()).not.toBeNull(); + tab.sessionHistory = ['six']; + renderSceneBar(); + expect(visibleHistory()).toBeNull(); + }); + function startSessionTabDrag() { const store = useAgentCanvasStore.getState(); store.addTab({ type: 'code-editor', title: 'a.ts', data: { filePath: '/project/a.ts' }, diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx index 75b3aecb0f..0aee23460e 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx @@ -5,7 +5,7 @@ * Session tabs show only the referenced session title. */ -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useState, useEffect, useRef } from 'react'; import { Pin, CircleAlert } from 'lucide-react'; import { useSceneStore } from '../../stores/sceneStore'; import { useContextMenuStore } from '@/shared/context-menu-system/store/ContextMenuStore'; @@ -14,6 +14,7 @@ import type { MenuItem } from '@/shared/context-menu-system/types/menu.types'; import { Icon, TabGroup, type TabGroupItem } from '@openbitfun/ui'; import { useSceneTabNavigation } from './useSceneTabNavigation'; +import { SessionTabHistoryMenu } from './SessionTabHistoryMenu'; import { useSceneManager } from '../../hooks/useSceneManager'; import { useSessionTabLabels } from '../../hooks/useSessionTabLabels'; import { SessionTitleNumber } from '@/flow_chat/components/SessionTitleNumber'; @@ -26,7 +27,7 @@ import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { dropSceneTab, isSceneTabDrag, writeSceneTabDrag, endSceneTabDrag } from '../../workbench/sceneTabTransfer'; import type { WorkbenchPane } from '../../workbench/scenePaneModel'; import { observeTabDragLifecycle } from '../../workbench/tabDragLifecycle'; -import type { SceneTabId } from './types'; +import type { SceneTab, SceneTabId } from './types'; import { beginConversationTransfer, endConversationTransfer, isConversationTransfer, dropConversationInWorkbench } from '../../services/conversationDockTransfer'; import './SceneBar.scss'; @@ -37,6 +38,10 @@ function getSceneIdFromTabTarget(target: EventTarget | null): SceneTabId | undef return tab?.dataset.openbitfunValue as SceneTabId | undefined; } +function hasSessionHistory(tab: SceneTab | undefined): boolean { + return Boolean(tab?.session && tab.sessionHistory?.some(id => id !== tab.session!.sessionId)); +} + interface SceneBarProps { className?: string; pane?: WorkbenchPane; @@ -58,10 +63,18 @@ const SceneBar: React.FC = ({ closeScene, } = useSceneManager(pane); const sessionLabels = useSessionTabLabels(openTabs); + const focusedPane = useSceneStore(state => state.focusedPane); const resources = useContentResourceStore(state => state.resources); const selectedTabId = pendingTabId ?? activeTabId; const { t } = useI18n('common'); const { t: tComponents } = useI18n('components'); + const [history, setHistory] = useState<{ tabId: SceneTabId; open: boolean } | null>(null); + const historyAnchorRef = useRef(null); + const historyTab = openTabs.find(tab => tab.id === history?.tabId); + const historyOpen = Boolean(history?.open && hasSessionHistory(historyTab) && historyTab + && historyTab.id === activeTabId && focusedPane === pane && !pendingTabId); + const closeHistory = useCallback(() => setHistory(previous => previous?.open + ? { ...previous, open: false } : previous), []); const [offeredSceneTab, setOfferedSceneTab] = useState(false); const [offeredCanvasTabId, setOfferedCanvasTabId] = useState(null); const draggingCanvasTabId = useAgentCanvasStore(state => { @@ -88,8 +101,21 @@ const SceneBar: React.FC = ({ }); const handleTabValueChange = useCallback((value: string) => { + closeHistory(); activateScene(value as SceneTabId); - }, [activateScene]); + }, [activateScene, closeHistory]); + + useEffect(() => { + if (history?.open && !historyOpen) closeHistory(); + }, [history?.open, historyOpen, closeHistory]); + + const toggleHistory = (tabId: SceneTabId, anchor: HTMLElement) => { + if (tabId !== activeTabId || pendingTabId) return; + if (focusedPane !== pane) { activateScene(tabId); return; } + if (!hasSessionHistory(openTabs.find(tab => tab.id === tabId))) return; + historyAnchorRef.current = anchor; + setHistory(previous => ({ tabId, open: previous?.tabId !== tabId || !previous.open })); + }; useEffect(() => { // The source has written its transfer format and committed its drag state @@ -106,7 +132,7 @@ const SceneBar: React.FC = ({ setCanvasDrop(null); }; const stopDragListener = observeTabDragLifecycle(offerDrop, clearDrop); - const stopSurfaceListener = onSurfaceActivated(clearDrop); + const stopSurfaceListener = onSurfaceActivated(() => { clearDrop(); setHistory(null); }); return () => { stopDragListener(); stopSurfaceListener(); @@ -152,6 +178,7 @@ const SceneBar: React.FC = ({ }, [closeScene, tabDefs]); const handleContextMenu = (event: React.MouseEvent, id: SceneTabId) => { + closeHistory(); event.preventDefault(); event.stopPropagation(); const tab = openTabs.find(candidate => candidate.id === id); @@ -205,6 +232,10 @@ const SceneBar: React.FC = ({ {tab.contentId && resources[tab.contentId]?.fileMissing && } : undefined, labelTransitionKey: tab.session?.sessionId, + tabProps: hasSessionHistory(tab) ? { + 'aria-haspopup': 'menu', + 'aria-expanded': historyOpen && history?.tabId === tab.id, + } : undefined, // Keep the close hit target stationary between pointer down and up; // shrinking it can retarget the click at the button edge (issue #2210). endAction: closable ? ( @@ -294,9 +325,22 @@ const SceneBar: React.FC = ({ const title = resource?.target.kind === 'file' ? [resource.scope.surfaceId, resource.scope.remoteConnectionId, resource.target.path].filter(Boolean).join(' · ') : undefined; return
{ + const trigger = (event.target as Element).closest('[role="tab"]'); + if (trigger) toggleHistory(tabId, trigger); + }} + onKeyDown={event => { + if (event.key !== 'ArrowDown' || !hasSessionHistory(tab) || tabId !== activeTabId || pendingTabId) return; + const trigger = (event.target as Element).closest('[role="tab"]'); + if (!trigger) return; + event.preventDefault(); + event.stopPropagation(); + if (!historyOpen) toggleHistory(tabId, trigger); + }} onContextMenu={event => handleContextMenu(event, tabId)} - onDragStart={event => { writeSceneTabDrag(event.dataTransfer, tabId); if (tab?.session) beginConversationTransfer(event.dataTransfer, tab.session, tabId); }} + onDragStart={event => { closeHistory(); writeSceneTabDrag(event.dataTransfer, tabId); if (tab?.session) beginConversationTransfer(event.dataTransfer, tab.session, tabId); }} onDragEnd={() => { endConversationTransfer(); endSceneTabDrag(); }} >{node}
; }} @@ -327,6 +371,12 @@ const SceneBar: React.FC = ({ )} + {historyTab?.session && } {showDropHint && canAcceptTab && (
diff --git a/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.scss b/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.scss new file mode 100644 index 0000000000..7469bf225e --- /dev/null +++ b/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.scss @@ -0,0 +1,114 @@ +.openbitfun-session-tab-history { + --_session-history-source-width: 0px; + --_session-history-source-height: var(--openbitfun-control-tab-group-item-height-sm); + --_session-history-source-left: 0px; + --_session-history-source-top: 0px; + + // Match the session header SearchField panel, including its glass recipe. + inline-size: max(220px, var(--_session-history-source-width)); + padding: 0; + border: 0; + border-radius: var(--openbitfun-radius-lg); + outline: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + outline-offset: calc(-0.5 * var(--openbitfun-border-width-default)); + color: var(--openbitfun-color-content-primary); + background: var(--openbitfun-color-surface-raised); + box-shadow: var(--openbitfun-shadow-overlay); + opacity: 1; + translate: none; + transition: none; + + --_session-history-capsule: inset( + var(--_session-history-source-top) + calc(100% - var(--_session-history-source-left) - var(--_session-history-source-width)) + calc(100% - var(--_session-history-source-top) - var(--_session-history-source-height)) + var(--_session-history-source-left) + round calc(var(--_session-history-source-height) / 2) + ); + + &[data-state='entering'] { + clip-path: var(--_session-history-capsule); + } + + &[data-state='entered'] { + animation: session-tab-history-expand var(--openbitfun-motion-duration-content-swap) var(--openbitfun-motion-easing-standard) both; + } + + &[data-state='exiting'] { + pointer-events: none; + animation: session-tab-history-collapse var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-exit) both; + } + + &__header { + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--openbitfun-control-tab-group-item-gap); + inline-size: 100%; + block-size: var(--_session-history-source-height); + padding: 0 var(--openbitfun-control-tab-group-item-padding-inline-sm); + border: 0; + border-radius: inherit; + color: inherit; + background: transparent; + font-family: var(--openbitfun-type-label-md-font-family); + font-size: var(--openbitfun-type-label-md-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: var(--openbitfun-type-label-md-line-height); + letter-spacing: var(--openbitfun-type-label-md-letter-spacing); + text-align: start; + cursor: pointer; + } + + &__rows { + padding: var(--openbitfun-space-1); + border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + } + + @supports (backdrop-filter: none) or (-webkit-backdrop-filter: none) { + background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 80%, transparent); + -webkit-backdrop-filter: var(--openbitfun-effect-blur-medium); + backdrop-filter: var(--openbitfun-effect-blur-medium); + } + + :where([data-contrast='high']) & { + background: var(--openbitfun-color-surface-raised); + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + + @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { + background: var(--openbitfun-color-surface-raised); + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + + @media (prefers-reduced-motion: reduce) { + &[data-state] { animation: none; clip-path: none; } + } + + @media (forced-colors: active) { + outline-color: ButtonText; + background: Canvas; + box-shadow: none; + -webkit-backdrop-filter: none; + backdrop-filter: none; + } +} + +@keyframes session-tab-history-expand { + 0% { clip-path: var(--_session-history-capsule); } + 45% { + clip-path: inset(0 0 calc(100% - var(--_session-history-source-height)) 0 round calc(var(--_session-history-source-height) / 2)); + } + 99% { clip-path: inset(0 round var(--openbitfun-radius-lg)); } + 100% { clip-path: none; } +} + +@keyframes session-tab-history-collapse { + 0% { clip-path: inset(0 round var(--openbitfun-radius-lg)); } + 55% { + clip-path: inset(0 0 calc(100% - var(--_session-history-source-height)) 0 round calc(var(--_session-history-source-height) / 2)); + } + 100% { clip-path: var(--_session-history-capsule); } +} diff --git a/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.tsx b/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.tsx new file mode 100644 index 0000000000..bf9d34fe2d --- /dev/null +++ b/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.tsx @@ -0,0 +1,103 @@ +import { createContext, forwardRef, useContext, useLayoutEffect, useMemo, useRef, type RefObject } from 'react'; +import { Menu, MenuList, MenuPopover, OverflowText, type MenuEntry } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { SessionTitleNumber } from '@/flow_chat/components/SessionTitleNumber'; +import { useSessionTabLabels } from '../../hooks/useSessionTabLabels'; +import { useSceneStore } from '../../stores/sceneStore'; +import type { SceneTab } from './types'; +import './SessionTabHistoryMenu.scss'; + +const HistorySurfaceContext = createContext<{ + anchorRef: RefObject; + title: string; + number?: string; + onClose: () => void; +} | null>(null); + +/** Keep one menu surface at the capsule's origin; reveal its size without scaling text. */ +const HistorySurface: typeof Menu = forwardRef(function HistorySurface(props, forwardedRef) { + const context = useContext(HistorySurfaceContext)!; + const rootRef = useRef(null); + useLayoutEffect(() => { + const anchor = context.anchorRef.current; + const root = rootRef.current; + if (!anchor || !root) return; + const rect = anchor.getBoundingClientRect(); + const left = Number(props.style?.left ?? rect.left); + const top = Number(props.style?.top ?? rect.top); + root.style.setProperty('--_session-history-source-width', `${rect.width}px`); + root.style.setProperty('--_session-history-source-height', `${rect.height}px`); + root.style.setProperty('--_session-history-source-left', `${Math.max(0, rect.left - left)}px`); + root.style.setProperty('--_session-history-source-top', `${Math.max(0, rect.top - top)}px`); + }, [context.anchorRef, props.style?.left, props.style?.top]); + + return
{ + rootRef.current = node; + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }} + className={`${props.className ?? ''} openbitfun-session-tab-history`} + > + + {props.children} +
; +}); + +const historyParts = { root: HistorySurface }; + +interface SessionTabHistoryMenuProps { + tab: SceneTab; + anchorRef: RefObject; + open: boolean; + onClose: () => void; +} + +export function SessionTabHistoryMenu({ tab, anchorRef, open, onClose }: SessionTabHistoryMenuProps) { + const { t } = useI18n('common'); + const tabs = useMemo(() => tab.session + ? (tab.sessionHistory ?? [tab.session.sessionId]).slice(0, 5).map(sessionId => ({ + id: `session:${sessionId}`, lastUsed: 0, session: { ...tab.session!, sessionId }, + })) : [], [tab.session, tab.sessionHistory]); + const labels = useSessionTabLabels(tabs); + const scope = getActiveSurfaceScope(); + const items: MenuEntry[] = tabs.flatMap(entry => { + const label = labels[entry.id]; + const target = entry.session!; + if (!label || target.surfaceId !== scope.surfaceId) return []; + return [{ + id: target.sessionId, + label: label.title, + shortcut: label.number ? : undefined, + role: 'menuitemradio', + checked: target.sessionId === tab.session?.sessionId, + onSelect: () => { + const isCurrent = () => scope.isCurrent() + && useSceneStore.getState().openTabs.some(candidate => candidate.id === tab.id + && candidate.session + && candidate.session === tab.session + && (candidate.sessionHistory ?? [candidate.session.sessionId]).includes(target.sessionId)); + useSceneStore.getState().openSessionScene(target, { isCurrent }); + }, + }]; + }); + const currentLabel = labels[`session:${tab.session?.sessionId}`]; + + return ; +} diff --git a/src/web-ui/src/app/components/SceneBar/types.ts b/src/web-ui/src/app/components/SceneBar/types.ts index 5242df5c26..20ad96edd5 100644 --- a/src/web-ui/src/app/components/SceneBar/types.ts +++ b/src/web-ui/src/app/components/SceneBar/types.ts @@ -77,6 +77,8 @@ export interface SceneTab { /** Legacy tabs without a location open in the primary pane. */ pane?: 'primary' | 'secondary'; session?: SessionSceneTarget; + /** Recently opened sessions in this workspace, newest first (including current). */ + sessionHistory?: string[]; /** Reference to content state; the tab never owns an editor buffer. */ contentId?: string; /** User ordering preference; independent of closability. */ diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index b4f46536b1..427349fb99 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -149,11 +149,6 @@ function subagentTooltipFields( ): AgentCapabilityTooltipField[] { const source = subagent.subagentSource ?? subagent.source; return [ - { - label: t('agentsOverview.capabilityTooltip.subagentId'), - value: subagent.id, - monospace: true, - }, { label: t('agentsOverview.capabilityTooltip.source'), value: subagentSourceLabel(source, t), diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.test.tsx b/src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.test.tsx index 6d860ab6f6..d01973a539 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.test.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.test.tsx @@ -60,6 +60,10 @@ const promptGroup = () => dialog().querySelector('[id$="-custom-age const promptEditor = () => (promptGroup().querySelector('.ProseMirror') as HTMLElement & { editor: Editor }).editor; const button = (label: string) => [...dialog().querySelectorAll('button')].find(control => control.textContent === label)!; const click = async (label: string) => { await act(async () => button(label).click()); }; +const editTools = async () => { + await act(async () => dialog().querySelector(`button[aria-label="${copy('customizeTools')}"]`)!.click()); +}; +const toolButton = (name: string) => dialog().querySelector(`button[aria-label^="${name}"][aria-pressed]`)!; const submit = async () => { await act(async () => dialog().querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))); }; @@ -82,7 +86,6 @@ const render = async () => { await act(async () => root.render()); }; const completeBasics = async () => { - fill('id', 'research-helper'); fill('name', 'Research helper'); fill('description', 'Research a topic'); await click(copy('wizard.next')); @@ -123,13 +126,13 @@ describe('guided agent creation', () => { expect(dialog().querySelector('textarea')).toBeNull(); await submit(); expect(field('name').getAttribute('aria-invalid')).toBe('true'); - expect(field('id').value).toMatch(/^agent-[a-f0-9]{8}$/); + expect(field('id')).toBeNull(); + expect(dialog().textContent).not.toContain(copy('identityDetails')); expect(field('description').getAttribute('aria-invalid')).toBe('true'); expect(document.activeElement).toBe(field('name')); - fill('id', '9-invalid'); fill('description', 'Research a topic'); await submit(); - expect(field('id').getAttribute('aria-invalid')).toBe('true'); + expect(field('name').getAttribute('aria-invalid')).toBe('true'); expect(mocks.create).not.toHaveBeenCalled(); await completeBasics(); @@ -143,24 +146,27 @@ describe('guided agent creation', () => { fill('prompt', 'Return a summary with sources.'); await submit(); expect(mocks.create).not.toHaveBeenCalled(); + expect(dialog().querySelector('[data-testid="custom-agent-tool-groups"]')).toBeNull(); + await editTools(); expect(dialog().querySelector('[data-testid="custom-agent-tool-groups"]')).not.toBeNull(); expect(dialog().querySelector('.tool-group-picker__sections')).not.toBeNull(); - expect(dialog().querySelector('input[aria-label^="mcp__github__list_issues"]')).toBeNull(); - const write = dialog().querySelector('input[aria-label^="Write"]')!; - expect(write.checked).toBe(true); + expect(toolButton('mcp__github__list_issues')).toBeNull(); + const write = toolButton('Write'); + expect(write.getAttribute('aria-pressed')).toBe('true'); act(() => write.click()); + await click('agentsOverview.save'); await click(copy('wizard.back')); expect(promptEditor().getText()).toBe('Return a summary with sources.'); await click(copy('wizard.back')); - expect(field('id').value).toBe('research-helper'); + expect(field('id')).toBeNull(); expect(field('name').value).toBe('Research helper'); expect(field('description').value).toBe('Research a topic'); await submit(); await submit(); - expect(dialog().querySelector('input[aria-label^="Write"]')!.checked).toBe(false); + expect([...dialog().querySelectorAll('.tool-group-summary__item')].map(node => node.textContent)).toEqual(['Read', 'WebFetch']); await submit(); expect(mocks.create).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ - id: 'research-helper', name: 'Research helper', kind: 'mode', level: 'user', + id: expect.stringMatching(/^agent-[a-f0-9]{8}$/), name: 'Research helper', kind: 'mode', level: 'user', description: 'Research a topic', prompt: 'Return a summary with sources.', tools: ['Read', 'WebFetch'], userContextPolicy: ['workspace_context', 'workspace_instructions'], })); @@ -168,31 +174,186 @@ describe('guided agent creation', () => { expect(onClose).toHaveBeenCalledOnce(); }); - it('opens and suggests an ID in a WebView without randomUUID', async () => { + it('edits capsules in the selected-tool area and saves or discards the pending selection', async () => { + mocks.tools.mockResolvedValue([...tools, { name: 'AskUserQuestion', description: 'Ask a question', is_readonly: true }]); + await render(); + await reachTools(); + const toolArea = dialog().querySelector('.th-create-panel__tool-summary')!; + const summary = () => [...toolArea.querySelectorAll('.tool-group-summary__item')].map(node => node.textContent); + expect(summary()).toEqual(['Read', 'WebFetch', 'Write']); + expect([...dialog().querySelectorAll('summary')].map(node => node.textContent)).not.toContain(copy('customizeTools')); + + await editTools(); + expect(toolArea.querySelector('[data-testid="custom-agent-tool-groups"]')).not.toBeNull(); + expect(toolArea.querySelector('.tool-group-summary')).toBeNull(); + expect(toolButton('AskUserQuestion').getAttribute('aria-pressed')).toBe('false'); + expect(button(copy('submit')).disabled).toBe(true); + act(() => toolButton('Write').click()); + await submit(); + expect(mocks.create).not.toHaveBeenCalled(); + await click('agentsOverview.cancel'); + expect(summary()).toEqual(['Read', 'WebFetch', 'Write']); + + await editTools(); + act(() => toolButton('Write').click()); + const all = dialog().querySelector('input[aria-label="agentsOverview.selectAll"]')!; + expect(all.closest('.th-create-panel__tool-edit-actions')?.contains(button('agentsOverview.save'))).toBe(true); + expect(toolArea.querySelector('[data-openbitfun-component="toolbar"]')).toBeNull(); + expect(all.indeterminate).toBe(true); + act(() => all.click()); + expect(all.checked).toBe(true); + expect(all.indeterminate).toBe(false); + expect([...toolArea.querySelectorAll('[aria-pressed]')].every(node => node.getAttribute('aria-pressed') === 'true')).toBe(true); + act(() => all.click()); + expect(all.checked).toBe(false); + expect(all.indeterminate).toBe(false); + expect([...toolArea.querySelectorAll('[aria-pressed]')].every(node => node.getAttribute('aria-pressed') === 'false')).toBe(true); + act(() => all.click()); + expect(toolButton('mcp__github__list_issues')).toBeNull(); + await click('agentsOverview.save'); + expect(summary()).toEqual(['AskUserQuestion', 'Read', 'WebFetch', 'Write']); + expect(button(copy('submit')).disabled).toBe(false); + await submit(); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ + tools: ['Read', 'Write', 'WebFetch', 'AskUserQuestion'], + })); + }); + + it('retains pending tool edits when navigating back through the wizard', async () => { + await render(); + await reachTools(); + await editTools(); + act(() => toolButton('Write').click()); + await click(copy('wizard.back')); + expect(promptEditor().getText()).toBe('Return a summary with sources.'); + expect(button(copy('wizard.next')).disabled).toBe(false); + await submit(); + expect(toolButton('Write').getAttribute('aria-pressed')).toBe('false'); + expect(button(copy('submit')).disabled).toBe(true); + await click('agentsOverview.save'); + await submit(); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ tools: ['Read', 'WebFetch'] })); + }); + + it.each(['local', 'peer-device'])('removes writable tools and limits select-all in readonly mode on %s', async (surface) => { + activateSurface(surface); + await render(); + await reachTools(); + expect(dialog().querySelector(`input[aria-label="${copy('readonly')}"]`)).toBeNull(); + await editTools(); + const readonly = dialog().querySelector(`input[aria-label="${copy('readonly')}"]`)!; + const actions = readonly.closest('.th-create-panel__tool-edit-actions')!; + expect(actions.contains(button('agentsOverview.save'))).toBe(true); + expect(actions.querySelector('input[aria-label="agentsOverview.selectAll"]')).not.toBeNull(); + expect(dialog().querySelector('.th-create-page__section-heading--tools')?.textContent).toBe(copy('tools')); + expect(toolButton('Write').getAttribute('aria-pressed')).toBe('true'); + act(() => readonly.click()); + expect(toolButton('Write')).toBeNull(); + const all = actions.querySelector('input[aria-label="agentsOverview.selectAll"]')!; + act(() => all.click()); + act(() => all.click()); + expect(toolButton('Write')).toBeNull(); + expect(toolButton('Read').getAttribute('aria-pressed')).toBe('true'); + expect(toolButton('WebFetch').getAttribute('aria-pressed')).toBe('true'); + await click('agentsOverview.save'); + expect([...dialog().querySelectorAll('.tool-group-summary__item')].map(node => node.textContent)).toEqual(['Read', 'WebFetch']); + await submit(); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ readonly: true, tools: ['Read', 'WebFetch'] })); + }); + + it('cancels readonly changes with the tool draft and does not restore writable tools when disabled', async () => { + await render(); + await reachTools(); + await editTools(); + const readonlyInput = () => dialog().querySelector(`input[aria-label="${copy('readonly')}"]`)!; + act(() => readonlyInput().click()); + expect(toolButton('Write')).toBeNull(); + await click('agentsOverview.cancel'); + await editTools(); + expect(readonlyInput().checked).toBe(false); + expect(toolButton('Write').getAttribute('aria-pressed')).toBe('true'); + act(() => readonlyInput().click()); + await click('agentsOverview.save'); + await editTools(); + expect(readonlyInput().checked).toBe(true); + act(() => readonlyInput().click()); + expect(toolButton('Write').getAttribute('aria-pressed')).toBe('false'); + await click('agentsOverview.save'); + await submit(); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ readonly: false, tools: ['Read', 'WebFetch'] })); + }); + + it('prevents an empty readonly selection from falling back to writable host defaults', async () => { + await render(); + await reachTools(); + await editTools(); + act(() => toolButton('Read').click()); + act(() => toolButton('WebFetch').click()); + act(() => dialog().querySelector(`input[aria-label="${copy('readonly')}"]`)!.click()); + expect(toolButton('Write')).toBeNull(); + expect(dialog().querySelector('[role="alert"]')?.textContent).toBe(copy('readonlyToolsRequired')); + await click('agentsOverview.save'); + expect(button(copy('submit')).disabled).toBe(true); + await submit(); + expect(mocks.create).not.toHaveBeenCalled(); + await editTools(); + act(() => toolButton('Read').click()); + await click('agentsOverview.save'); + await submit(); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ readonly: true, tools: ['Read'] })); + }); + + it('keeps the editor accessible when no tools satisfy readonly mode', async () => { + mocks.tools.mockResolvedValue([tools[1]]); + await render(); + await reachTools(); + await editTools(); + const readonlyInput = () => dialog().querySelector(`input[aria-label="${copy('readonly')}"]`)!; + act(() => readonlyInput().click()); + expect(toolButton('Write')).toBeNull(); + expect(dialog().querySelector('input[aria-label="agentsOverview.selectAll"]')!.disabled).toBe(true); + await click('agentsOverview.save'); + expect(button(copy('submit')).disabled).toBe(true); + await editTools(); + act(() => readonlyInput().click()); + expect(toolButton('Write')).not.toBeNull(); + await click('agentsOverview.cancel'); + }); + + it('creates an agent with a Chinese name and hidden ID in a WebView without randomUUID', async () => { vi.stubGlobal('crypto', {}); try { await render(); fill('name', '中文智能体'); - expect(field('id').value).toMatch(/^agent-[a-z0-9]+-[a-z0-9]*$/); + expect(field('id')).toBeNull(); expect(document.activeElement).toBe(field('name')); + fill('description', 'Research a topic'); + await submit(); + await act(async () => { await vi.dynamicImportSettled(); }); + await click(copy('usePromptTemplate')); + await submit(); + await submit(); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ + id: expect.stringMatching(/^agent-[a-z0-9]+-[a-z0-9]*$/), name: '中文智能体', + })); } finally { vi.unstubAllGlobals(); } }); - it('keeps advanced permissions collapsed in the full page and creates from the task template', async () => { + it('keeps readonly controls in the tool editor and creates from the task template', async () => { await act(async () => root.render(
)); await act(async () => { await vi.dynamicImportSettled(); }); expect(field('name').placeholder).toBe(copy('createNamePlaceholder')); - const permissions = [...dialog().querySelectorAll('summary')].find(summary => summary.textContent === copy('permissionsStandard'))!; - expect(permissions.closest('details')?.open).toBe(false); + expect(dialog().querySelector(`input[aria-label="${copy('readonly')}"]`)).toBeNull(); + expect(dialog().textContent).not.toContain(copy('permissionsStandard')); fill('name', 'Research helper'); fill('description', 'Research a topic'); - expect(field('id').value).toBe('research-helper'); + expect(field('id')).toBeNull(); await click(copy('usePromptTemplate')); await submit(); expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ - id: 'research-helper', name: 'Research helper', description: 'Research a topic', + id: expect.stringMatching(/^agent-[a-f0-9]{8}$/), name: 'Research helper', description: 'Research a topic', prompt: copy('promptTemplate'), readonly: false, })); }); @@ -206,21 +367,19 @@ describe('guided agent creation', () => { await act(async () => root.render(
)); await act(async () => { await vi.dynamicImportSettled(); }); fill('name', 'New display name'); - expect(field('id').disabled).toBe(true); - expect(field('id').value).toBe('legacy-id'); + expect(field('id')).toBeNull(); + expect(dialog().textContent).not.toContain('legacy-id'); await submit(); expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'legacy-id', name: 'New display name' })); expect(mocks.create).not.toHaveBeenCalled(); }); - it('starts with a name, generates a portable ID and preserves a manual override', async () => { + it('uses the name for user references and generates an independent internal ID', async () => { await render(); expect(document.activeElement).toBe(field('name')); fill('name', 'Research helper'); - expect(field('id').value).toBe('research-helper'); - fill('id', 'my-stable-id'); fill('name', 'Renamed helper'); - expect(field('id').value).toBe('my-stable-id'); + expect(field('id')).toBeNull(); fill('description', 'Research a topic'); await submit(); await act(async () => { await vi.dynamicImportSettled(); }); @@ -228,7 +387,12 @@ describe('guided agent creation', () => { expect(promptEditor().getText()).toContain(copy('promptTemplate')); expect(button(copy('usePromptTemplate'))).toBeUndefined(); await submit(); - expect(dialog().querySelector('[data-testid="custom-agent-tool-groups"]')?.closest('details')?.open).toBe(false); + expect(dialog().querySelector('[data-testid="custom-agent-tool-groups"]')).toBeNull(); + expect(dialog().querySelector('.tool-group-summary')).not.toBeNull(); + await submit(); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ + id: expect.stringMatching(/^agent-[a-f0-9]{8}$/), name: 'Renamed helper', + })); }); it.each(['text', 'source-html', 'markdown-editor'])('renders pasted Markdown from %s and keeps its structure across steps', async (format) => { @@ -273,13 +437,17 @@ describe('guided agent creation', () => { await reachTools(); const review = dialog().querySelector(`input[aria-label="${copy('review')}"]`)!; act(() => review.click()); + await editTools(); const readonly = dialog().querySelector(`input[aria-label="${copy('readonly')}"]`)!; expect(readonly.checked).toBe(true); expect(readonly.disabled).toBe(true); - expect(dialog().querySelector('input[aria-label^="Write"]')).toBeNull(); + expect(toolButton('Write')).toBeNull(); + const all = dialog().querySelector('input[aria-label="agentsOverview.selectAll"]')!; + act(() => all.click()); + await click('agentsOverview.save'); await submit(); expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ - kind: 'subagent', level: 'project', workspaceId: 'workspace-1', review: true, readonly: true, tools: ['Read'], + kind: 'subagent', level: 'project', workspaceId: 'workspace-1', review: true, readonly: true, tools: ['Read', 'WebFetch'], })); }); @@ -294,7 +462,7 @@ describe('guided agent creation', () => { await click(copy('wizard.retry')); expect(button(copy('submit')).disabled).toBe(false); await submit(); - expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ id: 'research-helper', prompt: 'Return a summary with sources.' })); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Research helper', prompt: 'Return a summary with sources.' })); }); it('blocks creation while tools load and preserves a failed submission for retry', async () => { @@ -314,6 +482,7 @@ describe('guided agent creation', () => { expect(button(copy('submit')).disabled).toBe(false); await submit(); expect(mocks.create).toHaveBeenCalledTimes(2); + expect(mocks.create.mock.calls[1][0].id).toBe(mocks.create.mock.calls[0][0].id); expect(onCreated).toHaveBeenCalledOnce(); }); @@ -327,9 +496,11 @@ describe('guided agent creation', () => { await click(copy('contextProjectLayout')); fill('prompt', 'Review project changes.'); await submit(); + await act(async () => resolveTools(tools)); + await editTools(); const readonly = dialog().querySelector(`input[aria-label="${copy('readonly')}"]`)!; act(() => readonly.click()); - await act(async () => resolveTools(tools)); + await click('agentsOverview.save'); await submit(); expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ kind: 'subagent', level: 'project', workspaceId: 'workspace-1', readonly: false, @@ -348,7 +519,7 @@ describe('guided agent creation', () => { expect(onCreated).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); expect(mocks.success).not.toHaveBeenCalled(); - expect(field('id').value).toMatch(/^agent-[a-f0-9]{8}$/); + expect(field('id')).toBeNull(); expect(field('name').value).toBe(''); }); @@ -357,12 +528,12 @@ describe('guided agent creation', () => { await completeBasics(); mocks.workspace = { id: 'workspace-2' }; await render(); - expect(field('id').value).toMatch(/^agent-[a-f0-9]{8}$/); + expect(field('id')).toBeNull(); expect(field('name').value).toBe(''); expect(dialog().querySelector('textarea')).toBeNull(); await completeBasics(); await act(async () => { activateSurface('peer-device'); }); - expect(field('id').value).toMatch(/^agent-[a-f0-9]{8}$/); + expect(field('id')).toBeNull(); expect(field('name').value).toBe(''); expect(dialog().querySelector('textarea')).toBeNull(); expect(mocks.create).not.toHaveBeenCalled(); diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss index 6933ba991d..2f87fae1d6 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss @@ -198,25 +198,21 @@ } .th-create-panel__field-head { - display: flex; - align-items: flex-start; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; gap: var(--openbitfun-space-3); } .th-create-panel__tool-edit-actions { display: flex; flex-shrink: 0; + flex-wrap: wrap; + align-items: center; justify-content: flex-end; gap: var(--openbitfun-space-2); } -.th-create-panel__tool-editor { - height: min(360px, 40vh); - min-height: 0; - scrollbar-gutter: stable; -} - .th-create-panel__tool-summary { box-sizing: border-box; min-height: 96px; @@ -385,12 +381,4 @@ grid-template-columns: minmax(0, 1fr); } - .th-create-panel__field-head { - align-items: stretch; - flex-direction: column; - } - - .th-create-panel__tool-edit-actions { - justify-content: flex-start; - } } diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx index cddb14c4c7..4bbfebc0f5 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx @@ -1,4 +1,4 @@ -import { Button, DialogBody, DialogClose, Icon, Input, PageHeader, ScrollArea, SetupWizard, Switch, Tooltip, type IconSource } from '@openbitfun/ui'; +import { Button, Checkbox, DialogBody, DialogClose, Icon, IconButton, Input, PageHeader, ScrollArea, SetupWizard, Switch, Tooltip, type IconSource } from '@openbitfun/ui'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FileText, @@ -19,19 +19,19 @@ import { isUserSelectableToolName } from '@/shared/utils/toolVisibility'; import { useAgentsStore } from '../agentsStore'; import { isAgentProfileConfigurableToolName } from '../agentToolVisibility'; import { - filterToolsForReviewMode, - normalizeReviewModeState, + filterToolsForReadonlyMode, + normalizeToolPermissionState, type SubagentEditorToolInfo, } from './subagentEditorUtils'; -import { createSuggestedAgentId, suggestAgentId } from './suggestAgentId'; +import { createSuggestedAgentId } from './suggestAgentId'; import { ToolGroupPicker, ToolGroupSummary } from './ToolGroupPicker'; +import { setToolGroupSelection } from './toolGroups'; import { useUserToolGroups } from './useUserToolGroups'; import '../AgentsView.scss'; import './CreateAgentPage.scss'; const MEditor = React.lazy(() => import('@/tools/editor/meditor/components/MEditor').then((module) => ({ default: module.MEditor }))); -const ID_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const DEFAULT_MODE_POLICY: UserContextSection[] = [ 'workspace_context', 'workspace_instructions', @@ -84,12 +84,12 @@ function defaultPolicyForKind(kind: CustomAgentKind): UserContextSection[] { function defaultSelectedTools( tools: SubagentEditorToolInfo[], kind: CustomAgentKind, - review: boolean, + readonly: boolean, ): Set { const defaultTools = kind === 'mode' ? DEFAULT_CUSTOM_MODE_TOOLS : DEFAULT_CUSTOM_SUBAGENT_TOOLS; const selectableToolNames = new Set( - filterToolsForReviewMode(tools, kind === 'subagent' && review).map((tool) => tool.name), + filterToolsForReadonlyMode(tools, readonly).map((tool) => tool.name), ); return new Set( @@ -116,6 +116,7 @@ const CreateAgentPage: React.FC = ({ standalone = false, t const fieldId = useCallback((field: string) => `${formInstanceId}-custom-agent-${field}`, [formInstanceId]); const formRef = useRef(null); const promptControlRef = useRef(null); + const toolEditButtonRef = useRef(null); const [step, setStep] = useState(0); const steps = [ { @@ -160,10 +161,7 @@ const CreateAgentPage: React.FC = ({ standalone = false, t const [kind, setKind] = useState('mode'); const [level, setLevel] = useState('user'); - const [generatedId] = useState(createSuggestedAgentId); - const [agentId, setAgentId] = useState(generatedId); - const agentIdCustomized = useRef(false); - const [agentIdError, setAgentIdError] = useState(null); + const [agentId, setAgentId] = useState(createSuggestedAgentId); const [name, setName] = useState(''); const [nameError, setNameError] = useState(false); const [description, setDescription] = useState(''); @@ -179,6 +177,8 @@ const CreateAgentPage: React.FC = ({ standalone = false, t const [selectedTools, setSelectedTools] = useState>(new Set()); const [toolsEditing, setToolsEditing] = useState(false); const [pendingTools, setPendingTools] = useState | null>(null); + const [pendingReadonly, setPendingReadonly] = useState(null); + const toolSelectionInitialized = useRef(false); const [userContextPolicy, setUserContextPolicy] = useState>( () => new Set(defaultPolicyForKind('mode')), ); @@ -237,22 +237,19 @@ const CreateAgentPage: React.FC = ({ standalone = false, t setReview(false); setUserContextPolicy(new Set(defaultPolicyForKind(kind))); setSelectedTools(new Set()); + toolSelectionInitialized.current = false; setToolsEditing(false); setPendingTools(null); + setPendingReadonly(null); }, [isEdit, kind]); useEffect(() => { - if (isEdit || toolInfos.length === 0) { + if (isEdit || toolInfos.length === 0 || toolSelectionInitialized.current) { return; } - - setSelectedTools((prev) => { - if (prev.size > 0) { - return prev; - } - return defaultSelectedTools(toolInfos, kind, review); - }); - }, [isEdit, kind, review, toolInfos]); + toolSelectionInitialized.current = true; + setSelectedTools(defaultSelectedTools(toolInfos, kind, defaultReadonlyForKind(kind))); + }, [isEdit, kind, toolInfos]); useEffect(() => { if (!isEdit || !editingAgentId) { @@ -285,8 +282,8 @@ const CreateAgentPage: React.FC = ({ standalone = false, t setSelectedTools(new Set(detail.tools ?? [])); setToolsEditing(false); setPendingTools(null); + setPendingReadonly(null); setUserContextPolicy(new Set(detail.userContextPolicy)); - setAgentIdError(null); } catch (error) { if (cancelled) { return; @@ -304,19 +301,6 @@ const CreateAgentPage: React.FC = ({ standalone = false, t }; }, [editingAgentId, isEdit, workspaceId]); - const validateAgentId = useCallback( - (value: string) => { - if (!value.trim()) { - return t('agentsOverview.form.idRequired'); - } - if (!ID_REGEX.test(value.trim())) { - return t('agentsOverview.form.idFormat'); - } - return null; - }, - [t], - ); - const toggleContextSection = useCallback((section: UserContextSection) => { setUserContextPolicy((prev) => { const next = new Set(prev); @@ -332,7 +316,7 @@ const CreateAgentPage: React.FC = ({ standalone = false, t const handleReviewChange = useCallback( (nextReview: boolean) => { setReview(nextReview); - const next = normalizeReviewModeState({ + const next = normalizeToolPermissionState({ review: nextReview, readonly, selectedTools, @@ -340,26 +324,36 @@ const CreateAgentPage: React.FC = ({ standalone = false, t }); setReadonly(next.readonly); setSelectedTools(next.selectedTools); - setPendingTools((current) => current ? new Set(next.selectedTools) : null); + setPendingTools((current) => current ? normalizeToolPermissionState({ + review: nextReview, + readonly: pendingReadonly ?? readonly, + selectedTools: current, + availableTools: toolInfos, + }).selectedTools : null); + if (nextReview && toolsEditing) setPendingReadonly(true); }, - [readonly, selectedTools, toolInfos], + [pendingReadonly, readonly, selectedTools, toolInfos, toolsEditing], ); const handleReadonlyChange = useCallback( (nextReadonly: boolean) => { - if (review) { - setReadonly(true); - return; - } - setReadonly(nextReadonly); + const next = normalizeToolPermissionState({ + review, + readonly: nextReadonly, + selectedTools: pendingTools ?? selectedTools, + availableTools: toolInfos, + }); + setPendingReadonly(next.readonly); + setPendingTools(next.selectedTools); }, - [review], + [pendingTools, review, selectedTools, toolInfos], ); + const toolReadonly = pendingReadonly ?? readonly; const selectableTools = useMemo( - () => filterToolsForReviewMode(toolInfos, kind === 'subagent' && review) + () => filterToolsForReadonlyMode(toolInfos, toolReadonly || (kind === 'subagent' && review)) .filter((tool) => isAgentProfileConfigurableToolName(tool.name)), - [kind, review, toolInfos], + [kind, review, toolInfos, toolReadonly], ); const selectableGroupTools = useMemo(() => selectableTools.map((tool) => ({ name: tool.name, @@ -368,6 +362,10 @@ const CreateAgentPage: React.FC = ({ standalone = false, t needs_permissions: tool.needsPermissions, dynamic_info: tool.dynamicInfo, })), [selectableTools]); + const toolSelectionDraft = pendingTools ?? selectedTools; + const selectedSelectableToolCount = selectableTools.filter((tool) => toolSelectionDraft.has(tool.name)).length; + const allSelectableToolsSelected = selectableTools.length > 0 && selectedSelectableToolCount === selectableTools.length; + const readonlyToolsMissing = toolReadonly && selectedSelectableToolCount === 0; const managementGroupTools = useMemo( () => toolInfos .filter((tool) => isAgentProfileConfigurableToolName(tool.name)) @@ -403,12 +401,10 @@ const CreateAgentPage: React.FC = ({ standalone = false, t const handleNext = () => { if (step === 0) { - const idError = validateAgentId(agentId); - setAgentIdError(idError); setNameError(!name.trim()); setDescriptionError(!description.trim()); - if (!name.trim() || idError || !description.trim()) { - focusField(!name.trim() ? 'name' : idError ? 'id' : 'description'); + if (!name.trim() || !description.trim()) { + focusField(!name.trim() ? 'name' : 'description'); return; } } @@ -423,12 +419,7 @@ const CreateAgentPage: React.FC = ({ standalone = false, t }; const handleSubmit = useCallback(async () => { - const nextAgentIdError = validateAgentId(agentId); - setAgentIdError(nextAgentIdError); - if (nextAgentIdError) { - return; - } - if (!isEdit && !name.trim()) { + if (!name.trim()) { setNameError(true); focusField('name'); return; @@ -452,22 +443,32 @@ const CreateAgentPage: React.FC = ({ standalone = false, t if (isEdit && !editingAgentId) { return; } + const submittedTools = normalizeToolPermissionState({ + review: kind === 'subagent' && review, + readonly, + selectedTools, + availableTools: toolInfos, + }).selectedTools; + // Empty selections use host defaults, which may include writable tools. + if (readonly && submittedTools.size === 0) { + notification.error(t('agentsOverview.form.readonlyToolsRequired')); + return; + } const scope = getActiveSurfaceScope(); setSubmitting(true); try { const normalizedId = agentId.trim(); - const effectiveName = name.trim() || normalizedId; const payload = { kind, level: kind === 'subagent' ? level : 'user', id: normalizedId, - name: effectiveName, + name: name.trim(), description: description.trim(), prompt: prompt.trim(), readonly, review: kind === 'subagent' ? review : false, - tools: selectedTools.size > 0 ? Array.from(selectedTools) : undefined, + tools: submittedTools.size > 0 ? Array.from(submittedTools) : undefined, userContextPolicy: Array.from(userContextPolicy), workspaceId: kind === 'subagent' && level === 'project' ? workspaceId : undefined, } as const; @@ -521,9 +522,9 @@ const CreateAgentPage: React.FC = ({ standalone = false, t readonly, review, selectedTools, + toolInfos, t, userContextPolicy, - validateAgentId, workspaceId, ]); @@ -580,33 +581,21 @@ const CreateAgentPage: React.FC = ({ standalone = false, t ); } - const permissionControls = ( -
+ const permissionControls = kind === 'subagent' ? ( +
+ {t('agentsOverview.form.review')}
- {t('agentsOverview.form.readonly')} + {t('agentsOverview.form.review')} handleReadonlyChange(event.target.checked)} - aria-label={t('agentsOverview.form.readonly')} + checked={review} + onChange={(event) => handleReviewChange(event.target.checked)} + aria-label={t('agentsOverview.form.review')} />
- {kind === 'subagent' ? ( -
- - {t('agentsOverview.form.review')} - - handleReviewChange(event.target.checked)} - aria-label={t('agentsOverview.form.review')} - /> -
- ) : null} -
- ); + + ) : null; const basicInformation = (
@@ -624,7 +613,6 @@ const CreateAgentPage: React.FC = ({ standalone = false, t const nextName = event.target.value; setName(nextName); setNameError(false); - if (!isEdit && !agentIdCustomized.current) setAgentId(suggestAgentId(nextName, generatedId)); }} invalid={nameError} aria-describedby={nameError ? fieldId('name-error') : undefined} @@ -695,35 +683,6 @@ const CreateAgentPage: React.FC = ({ standalone = false, t
-
- {t('agentsOverview.form.identityDetails')} -

{t('agentsOverview.form.idHelp')}

-
- - { - agentIdCustomized.current = true; - setAgentId(event.target.value); - setAgentIdError(validateAgentId(event.target.value)); - }} - onBlur={() => setAgentIdError(validateAgentId(agentId))} - placeholder={t('agentsOverview.form.idPlaceholder')} - invalid={!!agentIdError} - aria-describedby={agentIdError ? fieldId('id-error') : undefined} - disabled={isEdit} - size="sm" - /> - {agentIdError ? ( - {agentIdError} - ) : null} -
- -
- {kind === 'subagent' ? (
{t('agentsOverview.form.level')} @@ -765,7 +724,7 @@ const CreateAgentPage: React.FC = ({ standalone = false, t ) : null}
); - const toolSelection = selectableTools.length > 0 ? ( + const toolSelection = toolInfos.some((tool) => isAgentProfileConfigurableToolName(tool.name)) ? (
= ({ standalone = false, t data-openbitfun-part="sectionHeading" >

{t('agentsOverview.form.tools')}

- - {kind === 'subagent' && review - ? t('agentsOverview.form.reviewToolsHint') - : t('agentsOverview.form.toolsHint', { - optionalLabel: t('agentsOverview.form.toolsOptional'), - })} -
- {standalone ? null : toolsEditing ? ( + {toolsEditing ? (
+
+ {t('agentsOverview.form.readonly')} + handleReadonlyChange(event.target.checked)} + aria-label={t('agentsOverview.form.readonly')} + /> +
+ 0 && !allSelectableToolsSelected} + onCheckedChange={(checked) => setPendingTools(new Set(setToolGroupSelection( + Array.from(toolSelectionDraft), selectableTools.map((tool) => tool.name), checked, + )))} + disabled={submitting || selectableTools.length === 0} + />
) : ( - + + { + setPendingTools(new Set(selectedTools)); + setPendingReadonly(readonly); + setToolsEditing(true); + }} + disabled={submitting} + icon={} + /> + )}
- {standalone ? ( - <> - -
- {t('agentsOverview.form.customizeTools')} - setSelectedTools(new Set(toolNames))} - onSaveUserGroups={saveUserToolGroups} - disabled={submitting} - testId="custom-agent-tool-groups" - /> -
- - ) : toolsEditing ? ( - + + {toolsEditing ? ( setPendingTools(new Set(toolNames))} onSaveUserGroups={saveUserToolGroups} disabled={submitting} testId="custom-agent-tool-groups" /> - - ) : ( - + ) : ( - + )} + + {readonlyToolsMissing && ( +

{t('agentsOverview.form.readonlyToolsRequired')}

)}
) : null; @@ -994,7 +961,7 @@ const CreateAgentPage: React.FC = ({ standalone = false, t form={formId} variant="primary" size="sm" - disabled={submitting || toolsEditing || (standalone && step === 2 && (toolsLoading || toolsLoadError))} + disabled={submitting || ((toolsEditing || readonlyToolsMissing) && (!standalone || step === 2)) || (standalone && step === 2 && (toolsLoading || toolsLoadError))} loading={submitting} > {standalone && step < 2 ? t('agentsOverview.form.wizard.next') : submitLabel} @@ -1011,7 +978,7 @@ const CreateAgentPage: React.FC = ({ standalone = false, t data-openbitfun-part="form" onSubmit={(event) => { event.preventDefault(); - if (submitting || toolsEditing) return; + if (submitting || (toolsEditing && (!standalone || step === 2))) return; if (standalone && step < 2) { handleNext(); return; @@ -1026,10 +993,7 @@ const CreateAgentPage: React.FC = ({ standalone = false, t {(!standalone || step === 0) && basicInformation} {(!standalone || step === 2) && ( <> -
- {t(readonly ? 'agentsOverview.form.permissionsReadonly' : 'agentsOverview.form.permissionsStandard')} - {permissionControls} -
+ {permissionControls} {standalone && toolsLoading ? (

{t('agentsOverview.form.wizard.loadingTools')}

) : standalone && toolsLoadError ? ( @@ -1042,7 +1006,7 @@ const CreateAgentPage: React.FC = ({ standalone = false, t ) : ( <> {toolSelection} - {standalone && selectableTools.length === 0 && ( + {standalone && !toolInfos.some((tool) => isAgentProfileConfigurableToolName(tool.name)) && (

{t('agentsOverview.form.wizard.noTools')}

)} diff --git a/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.scss b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.scss index 6815dd66d5..8cef0cc7fb 100644 --- a/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.scss +++ b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.scss @@ -2,6 +2,15 @@ @include capability.capability-group-layout('tool'); +.tool-group-picker--flat .tool-group-picker__token-grid { + display: flex; + flex-wrap: wrap; +} + +.tool-group-picker__pill { + border-radius: var(--openbitfun-radius-pill); +} + .tool-group-manager { display: flex; min-width: 0; diff --git a/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.test.tsx b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.test.tsx index a4deeceb43..0eed3d30c3 100644 --- a/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.test.tsx +++ b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.test.tsx @@ -54,13 +54,41 @@ describe('flat tool presentation', () => { )); expect(container.querySelectorAll('h4')).toHaveLength(0); expect(container.textContent).not.toContain('agentsOverview.toolGroups.manageGroups'); - const controls = [...container.querySelectorAll('input[type="checkbox"]')]; + const controls = [...container.querySelectorAll('button[aria-pressed]')]; expect(controls).toHaveLength(3); const write = controls.find(control => control.getAttribute('aria-label')?.startsWith('Write'))!; act(() => write.click()); expect(onSelectionChange).toHaveBeenCalledWith(['Read', 'unavailable_existing_tool', 'Write']); }); + it('keeps selected capsules first while preserving unavailable selections', () => { + let selection = ['Read', 'unavailable_existing_tool']; + const onSelectionChange = (names: string[]) => { + selection = names; + renderPicker(); + }; + const renderPicker = () => root.render( + , + ); + const capsules = () => [...container.querySelectorAll('button[aria-pressed]')]; + const names = () => capsules().map(control => control.textContent); + act(renderPicker); + expect(names()).toEqual(['Read', 'ExecCommand', 'Write']); + act(() => capsules().find(control => control.textContent === 'Write')!.click()); + expect(names()).toEqual(['Read', 'Write', 'ExecCommand']); + expect(capsules().find(control => control.textContent === 'Write')!.getAttribute('aria-pressed')).toBe('true'); + act(() => capsules().find(control => control.textContent === 'Read')!.click()); + expect(names()).toEqual(['Write', 'ExecCommand', 'Read']); + expect(selection).toEqual(['unavailable_existing_tool', 'Write']); + }); + it('retains grouped presentation for existing consumers by default', () => { act(() => root.render( , diff --git a/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.tsx b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.tsx index bcd3d729c7..fcdb721fe8 100644 --- a/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.tsx +++ b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.tsx @@ -456,7 +456,14 @@ export const ToolGroupPicker: React.FC = ({ const groups = useMemo(() => resolveToolGroups(tools, userGroups, t), [t, tools, userGroups]); const selectedCount = new Set(selectedToolNames).size; const sections = useMemo>(() => { - if (!grouped) return [['', flattenToolGroups(groups)]]; + if (!grouped) { + const selected = new Set(selectedToolNames); + const flatGroups = flattenToolGroups(groups); + for (const group of flatGroups) { + group.tools.sort((left, right) => Number(selected.has(right.name)) - Number(selected.has(left.name))); + } + return [['', flatGroups]]; + } const sectionsByLabel = new Map(); for (const group of groups) { const label = groupSectionLabel(group, t); @@ -465,11 +472,11 @@ export const ToolGroupPicker: React.FC = ({ sectionsByLabel.set(label, entries); } return [...sectionsByLabel.entries()]; - }, [grouped, groups, t]); + }, [grouped, groups, selectedToolNames, t]); return ( -
- + {grouped && = ({ {t('agentsOverview.selectionSaveHint')} )} - trailing={grouped && ( + trailing={( )} - /> + />}
{sections.map(([sectionLabel, sectionGroups]) => ( = ({ titleMonospace placement="top" > - onSelectionChange( - setToolGroupSelection(selectedToolNames, [tool.name], checked), - )} - disabled={disabled} - inputAriaLabel={capabilityTooltipAriaLabel(tool.name, tool.description, tooltipFields)} - /> + {grouped ? ( + onSelectionChange( + setToolGroupSelection(selectedToolNames, [tool.name], checked), + )} + disabled={disabled} + inputAriaLabel={capabilityTooltipAriaLabel(tool.name, tool.description, tooltipFields)} + /> + ) : ( + + )} ); })} diff --git a/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.test.ts b/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.test.ts index 475f5af409..3a817db5c8 100644 --- a/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.test.ts +++ b/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { evaluateReviewSubagentToolReadiness, - filterToolsForReviewMode, - normalizeReviewModeState, + filterToolsForReadonlyMode, + normalizeToolPermissionState, type SubagentEditorToolInfo, } from './subagentEditorUtils'; @@ -18,14 +18,14 @@ const tools: SubagentEditorToolInfo[] = [ describe('subagentEditorUtils', () => { it('shows only readonly tools for review subagents', () => { - expect(filterToolsForReviewMode(tools, true).map((tool) => tool.name)).toEqual([ + expect(filterToolsForReadonlyMode(tools, true).map((tool) => tool.name)).toEqual([ 'GetFileDiff', 'Read', 'Grep', 'Glob', 'LS', ]); - expect(filterToolsForReviewMode(tools, false).map((tool) => tool.name)).toEqual([ + expect(filterToolsForReadonlyMode(tools, false).map((tool) => tool.name)).toEqual([ 'GetFileDiff', 'Read', 'Grep', @@ -37,7 +37,7 @@ describe('subagentEditorUtils', () => { }); it('forces review subagents to readonly and removes writable selected tools', () => { - const next = normalizeReviewModeState({ + const next = normalizeToolPermissionState({ review: true, readonly: false, selectedTools: new Set(['Read', 'Write', 'ExecCommand']), @@ -49,6 +49,30 @@ describe('subagentEditorUtils', () => { expect(next.removedToolNames).toEqual(['Write', 'ExecCommand']); }); + it('removes writable and unknown tools when readonly is enabled without review', () => { + const next = normalizeToolPermissionState({ + review: false, + readonly: true, + selectedTools: new Set(['Read', 'Write', 'ExecCommand', 'unavailable-tool']), + availableTools: tools, + }); + expect(next.readonly).toBe(true); + expect([...next.selectedTools]).toEqual(['Read']); + expect(next.removedToolNames).toEqual(['Write', 'ExecCommand', 'unavailable-tool']); + }); + + it('preserves the current selection when readonly is disabled', () => { + const next = normalizeToolPermissionState({ + review: false, + readonly: false, + selectedTools: new Set(['Read']), + availableTools: tools, + }); + expect(next.readonly).toBe(false); + expect([...next.selectedTools]).toEqual(['Read']); + expect(next.removedToolNames).toEqual([]); + }); + it('marks review subagent tooling invalid when the minimum diff or read tool is missing', () => { expect(evaluateReviewSubagentToolReadiness(new Set(['Read']))).toMatchObject({ readiness: 'invalid', diff --git a/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.ts b/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.ts index 4530f47e62..516e4ea65f 100644 --- a/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.ts +++ b/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.ts @@ -17,30 +17,30 @@ export { type ReviewSubagentToolReadinessResult, } from '@/shared/services/reviewSubagentCapabilities'; -export function filterToolsForReviewMode( +export function filterToolsForReadonlyMode( tools: SubagentEditorToolInfo[], - review: boolean, + readonly: boolean, ): SubagentEditorToolInfo[] { - return review ? tools.filter((tool) => tool.isReadonly) : tools; + return readonly ? tools.filter((tool) => tool.isReadonly) : tools; } -export interface NormalizeReviewModeStateInput { +export interface NormalizeToolPermissionStateInput { review: boolean; readonly: boolean; selectedTools: Set; availableTools: SubagentEditorToolInfo[]; } -export interface NormalizeReviewModeStateResult { +export interface NormalizeToolPermissionStateResult { readonly: boolean; selectedTools: Set; removedToolNames: string[]; } -export function normalizeReviewModeState( - input: NormalizeReviewModeStateInput, -): NormalizeReviewModeStateResult { - if (!input.review) { +export function normalizeToolPermissionState( + input: NormalizeToolPermissionStateInput, +): NormalizeToolPermissionStateResult { + if (!input.review && !input.readonly) { return { readonly: input.readonly, selectedTools: new Set(input.selectedTools), diff --git a/src/web-ui/src/app/scenes/agents/components/suggestAgentId.test.ts b/src/web-ui/src/app/scenes/agents/components/suggestAgentId.test.ts index 69f49fb4b4..9966f908ca 100644 --- a/src/web-ui/src/app/scenes/agents/components/suggestAgentId.test.ts +++ b/src/web-ui/src/app/scenes/agents/components/suggestAgentId.test.ts @@ -1,16 +1,8 @@ import { afterEach, expect, it, vi } from 'vitest'; -import { createSuggestedAgentId, suggestAgentId } from './suggestAgentId'; +import { createSuggestedAgentId } from './suggestAgentId'; afterEach(() => vi.unstubAllGlobals()); -it.each([ - ['Research Helper', 'research-helper'], ['123 review', 'agent-123-review'], - ['Résumé', 'resume'], ['中文智能体', 'agent-fallback'], ['../../', 'agent-fallback'], -])('suggests a portable ID for %s', (name, expected) => { - expect(suggestAgentId(name, 'agent-fallback')).toBe(expected); -}); - it.each([undefined, {}])('generates valid fallback IDs without randomUUID (%s)', crypto => { vi.stubGlobal('crypto', crypto); const id = createSuggestedAgentId(); expect(id).toMatch(/^agent-[a-z0-9]+-[a-z0-9]*$/); - expect(suggestAgentId('中文智能体', id)).toBe(id); }); diff --git a/src/web-ui/src/app/scenes/agents/components/suggestAgentId.ts b/src/web-ui/src/app/scenes/agents/components/suggestAgentId.ts index 84b2631068..53e63b1d29 100644 --- a/src/web-ui/src/app/scenes/agents/components/suggestAgentId.ts +++ b/src/web-ui/src/app/scenes/agents/components/suggestAgentId.ts @@ -5,10 +5,3 @@ export function createSuggestedAgentId(): string { : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; return `agent-${suffix}`; } - -/** IDs are portable filenames; display names remain unrestricted and localized. */ -export function suggestAgentId(name: string, fallback: string): string { - const slug = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '') - .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); - return slug ? (/^[a-z]/.test(slug) ? slug : `agent-${slug}`) : fallback; -} diff --git a/src/web-ui/src/app/scenes/personal-overview/PersonalOverviewScene.actions.test.tsx b/src/web-ui/src/app/scenes/personal-overview/PersonalOverviewScene.actions.test.tsx index 691175948c..c9934648d5 100644 --- a/src/web-ui/src/app/scenes/personal-overview/PersonalOverviewScene.actions.test.tsx +++ b/src/web-ui/src/app/scenes/personal-overview/PersonalOverviewScene.actions.test.tsx @@ -187,7 +187,6 @@ describe('overview catalog actions', () => { expect(useAgentsStore.getState()).toBe(agentsState); expect(mocks.openScene).not.toHaveBeenCalled(); await fillAgentField('name', 'New agent'); - await fillAgentField('id', 'new-agent'); await fillAgentField('description', 'A new agent'); await clickDialogButton('agentsOverview.form.wizard.next'); await fillAgentField('prompt', 'Help with the task.'); @@ -198,7 +197,7 @@ describe('overview catalog actions', () => { await clickDialogButton('agentsOverview.form.wizard.next'); await clickDialogButton('agentsOverview.form.submit'); expect(mocks.createAgent).toHaveBeenCalledWith(expect.objectContaining({ - id: 'new-agent', kind: 'mode', description: 'A new agent', prompt: 'Help with the task.', + id: expect.stringMatching(/^agent-[a-f0-9]{8}$/), kind: 'mode', description: 'A new agent', prompt: 'Help with the task.', userContextPolicy: ['workspace_instructions', 'project_layout'], })); expect(mocks.updateAgent).not.toHaveBeenCalled(); @@ -222,12 +221,12 @@ describe('overview catalog actions', () => { let finish!: () => void; mocks.createAgent.mockReturnValue(new Promise(resolve => { finish = resolve; })); await act(async () => { root.render(); }); - for (const [field, value] of [['name', 'Page agent'], ['id', 'page-agent'], ['description', 'Page agent'], ['prompt', 'Original prompt']]) { + for (const [field, value] of [['name', 'Page agent'], ['description', 'Page agent'], ['prompt', 'Original prompt']]) { await fillAgentField(field, value, container); } const submit = container.querySelector('.th-create-page__footer button[type="submit"]')!; await act(async () => { submit.click(); }); - expect(mocks.createAgent).toHaveBeenCalledWith(expect.objectContaining({ id: 'page-agent', prompt: 'Original prompt' })); + expect(mocks.createAgent).toHaveBeenCalledWith(expect.objectContaining({ id: expect.stringMatching(/^agent-[a-f0-9]{8}$/), name: 'Page agent', prompt: 'Original prompt' })); expect(submit.textContent).toBe('agentsOverview.form.submit'); expect(submit.getAttribute('aria-busy')).toBe('true'); await act(async () => { finish(); }); @@ -240,7 +239,6 @@ describe('overview catalog actions', () => { await render(); await click('personal-overview-create-agent'); await fillAgentField('name', 'Old agent'); - await fillAgentField('id', 'old-agent'); await fillAgentField('description', 'Created on the original device'); await clickDialogButton('agentsOverview.form.wizard.next'); await fillAgentField('prompt', 'Original task'); @@ -250,7 +248,7 @@ describe('overview catalog actions', () => { await act(async () => { activateSurface('peer-host'); }); await act(async () => { finish(); }); expect(document.querySelector('[role="dialog"]')).not.toBeNull(); - expect(document.querySelector('[role="dialog"] [id$="custom-agent-id"]')?.value).toMatch(/^agent-[a-f0-9]{8}$/); + expect(document.querySelector('[role="dialog"] [id$="custom-agent-id"]')).toBeNull(); expect(document.querySelector('[role="dialog"] [id$="custom-agent-name"]')?.value).toBe(''); expect(mocks.refresh).not.toHaveBeenCalled(); expect(mocks.success).not.toHaveBeenCalled(); diff --git a/src/web-ui/src/app/scenes/session/AuxPane.scss b/src/web-ui/src/app/scenes/session/AuxPane.scss index 8dfae3e061..0fd95343c9 100644 --- a/src/web-ui/src/app/scenes/session/AuxPane.scss +++ b/src/web-ui/src/app/scenes/session/AuxPane.scss @@ -41,6 +41,15 @@ padding: 0; } + &__empty-content { + max-inline-size: 100%; + + & > [data-openbitfun-part="title"] { + min-inline-size: 0; + max-inline-size: 100%; + } + } + &:focus-within { outline: none; } @@ -69,4 +78,3 @@ justify-content: center; gap: var(--openbitfun-space-2); } -.openbitfun-aux-pane__empty-path { overflow-wrap: anywhere; } diff --git a/src/web-ui/src/app/scenes/session/AuxPaneEmptyState.test.tsx b/src/web-ui/src/app/scenes/session/AuxPaneEmptyState.test.tsx index 9ec177a92c..3931640ede 100644 --- a/src/web-ui/src/app/scenes/session/AuxPaneEmptyState.test.tsx +++ b/src/web-ui/src/app/scenes/session/AuxPaneEmptyState.test.tsx @@ -7,20 +7,20 @@ import { activateSurface, resetDeviceSurfaceForTest } from '@/infrastructure/pee import { useNavSceneStore } from '@/app/stores/navSceneStore'; import { AuxPaneEmptyState } from './AuxPaneEmptyState'; const mock = vi.hoisted(() => ({ - workspace: { id: 'remote-project', rootPath: '/srv/project', workspaceKind: 'remote', connectionId: 'ssh-1', connectionName: 'Build host' } as { id: string; rootPath: string; workspaceKind: string; connectionId?: string; connectionName?: string } | null, + workspace: { id: 'remote-project', name: 'Project Atlas', rootPath: '/srv/project', workspaceKind: 'remote', connectionId: 'ssh-1', connectionName: 'Build host' } as { id: string; name: string; rootPath: string; workspaceKind: string; connectionId?: string; connectionName?: string } | null, canvas: { closedTabs: [] as ClosedTabRecord[], scopeKey: 'session-a', reopenClosedTab: vi.fn() }, - expand: vi.fn(), hide: vi.fn(), + expand: vi.fn(), })); -vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ workspace: mock.workspace }) })); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ workspace: mock.workspace, workspaceName: mock.workspace?.name ?? '' }) })); vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string, options?: { title?: string }) => options?.title ? `${key}:${options.title}` : key }) })); vi.mock('@/app/components/panels/content-canvas', () => ({ useCanvasStore: (selector: (state: typeof mock.canvas) => unknown) => selector(mock.canvas) })); -vi.mock('./sessionPanelLayout', () => ({ expandSessionAuxPane: mock.expand, hideSessionAuxPane: mock.hide })); +vi.mock('./sessionPanelLayout', () => ({ expandSessionAuxPane: mock.expand })); let host: HTMLDivElement, root: Root; const render = async () => { await act(async () => root.render()); }; const button = (name: string) => Array.from(host.querySelectorAll('button')).find(button => button.textContent === name)!; beforeEach(() => { vi.clearAllMocks(); resetDeviceSurfaceForTest(); - mock.workspace = { id: 'remote-project', rootPath: '/srv/project', workspaceKind: 'remote', connectionId: 'ssh-1', connectionName: 'Build host' }; + mock.workspace = { id: 'remote-project', name: 'Project Atlas', rootPath: '/srv/project', workspaceKind: 'remote', connectionId: 'ssh-1', connectionName: 'Build host' }; mock.canvas.closedTabs = []; mock.canvas.scopeKey = 'session-a'; host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); @@ -29,14 +29,16 @@ it('routes file browsing and terminal creation to the explicit workspace on the activateSurface('peer-host'); const requested = vi.fn(); window.addEventListener('terminal-create-requested', requested); await render(); - expect(host.textContent).toContain('Build host · /srv/project'); + expect(host.querySelector('[data-openbitfun-part="title"]')?.textContent).toBe('Project Atlas'); + expect(host.querySelector('[data-openbitfun-name="playing-cards-fan"]')).not.toBeNull(); + expect(host.textContent).not.toContain('Build host'); + expect(host.textContent).not.toContain('/srv/project'); await act(async () => button('canvas.empty.browseFiles').click()); expect(useNavSceneStore.getState().resourceWorkspace).toEqual({ surfaceId: 'peer-host', workspaceId: 'remote-project' }); await act(async () => button('canvas.empty.newTerminal').click()); expect(requested.mock.calls[0][0].detail).toEqual({ surfaceId: 'peer-host', resourceScope: { surfaceId: 'peer-host', workspaceId: 'remote-project', workspacePath: '/srv/project', remoteConnectionId: 'ssh-1' } }); window.removeEventListener('terminal-create-requested', requested); - await act(async () => button('canvas.hidePanel').click()); - expect(mock.hide).toHaveBeenCalledOnce(); + expect(button('canvas.hidePanel')).toBeUndefined(); }); it.each(['missing', 'disconnected', 'other-workspace'] as const)('gates actions for a %s workspace without a local fallback', async state => { if (state === 'missing') mock.workspace = null; @@ -46,6 +48,7 @@ it.each(['missing', 'disconnected', 'other-workspace'] as const)('gates actions expect(button('canvas.empty.browseFiles').disabled).toBe(true); expect(button('canvas.empty.newTerminal').disabled).toBe(true); expect(host.textContent).toContain(state === 'disconnected' ? 'canvas.empty.workspaceUnavailable' : 'canvas.empty.selectWorkspace'); + expect(host.querySelector('[data-openbitfun-part="title"]')?.textContent).toBe(state === 'disconnected' ? 'Project Atlas' : 'canvas.empty.title'); }); it('reopens only content owned by this session, workspace and surface', async () => { mock.canvas.closedTabs = [{ tab: { title: 'Notes', content: { type: 'markdown-viewer', title: 'Notes', metadata: { diff --git a/src/web-ui/src/app/scenes/session/AuxPaneEmptyState.tsx b/src/web-ui/src/app/scenes/session/AuxPaneEmptyState.tsx index f367043645..de907c7a0a 100644 --- a/src/web-ui/src/app/scenes/session/AuxPaneEmptyState.tsx +++ b/src/web-ui/src/app/scenes/session/AuxPaneEmptyState.tsx @@ -1,21 +1,19 @@ import { useSyncExternalStore } from 'react'; -import { Button, Empty, Icon } from '@openbitfun/ui'; +import { Button, Empty, Icon, OverflowText } from '@openbitfun/ui'; import { useI18n } from '@/infrastructure/i18n'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; -import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; import { useNavSceneStore } from '@/app/stores/navSceneStore'; import { useWorkspaceResourceState } from '@/app/scenes/workspace-resources/workspaceResourceState'; import { useCanvasStore } from '@/app/components/panels/content-canvas'; import { isCanvasTabVisibleForSession } from '@/app/components/panels/content-canvas/types'; import type { ContentResourceScope } from '@/shared/types/contentResource'; -import { expandSessionAuxPane, hideSessionAuxPane } from './sessionPanelLayout'; +import { expandSessionAuxPane } from './sessionPanelLayout'; export function AuxPaneEmptyState({ workspacePath, sessionId }: { workspacePath?: string; sessionId: string | null }) { const { t } = useI18n('components'); - const { workspace } = useCurrentWorkspace(); + const { workspace, workspaceName } = useCurrentWorkspace(); const scope = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope, getActiveSurfaceScope); - const peer = usePeerDeviceModeOptional(); const target = workspace && (!workspacePath || workspace.rootPath === workspacePath) ? workspace : null; const canOpen = Boolean(target && (target.workspaceKind !== 'remote' || target.connectionId)); const lastClosed = useCanvasStore(state => state.closedTabs[0]); @@ -25,7 +23,6 @@ export function AuxPaneEmptyState({ workspacePath, sessionId }: { workspacePath? const canReopen = Boolean(lastClosed && canvasScopeKey === (sessionId || '__none__') && isCanvasTabVisibleForSession(lastClosed.tab, sessionId) && lastScope?.surfaceId === scope.surfaceId && lastScope.workspaceId === target?.id); - const hostLabel = target?.connectionName || (peer?.peerMode.active ? peer.peerMode.deviceName : t('canvas.empty.currentHost')); const browseFiles = () => { if (!canOpen || !target || !scope.isCurrent()) return; useWorkspaceResourceState.getState().updateLayout(scope.key('workspace-resources', target.id), { filesCollapsed: false, fileView: 'tree' }); @@ -39,12 +36,9 @@ export function AuxPaneEmptyState({ workspacePath, sessionId }: { workspacePath? } })); }; return
- } title={t('canvas.empty.title')} - description={<> -

{t('canvas.empty.description')}

- {target &&

{hostLabel} · {target.rootPath}

} - {!canOpen &&

{t(target ? 'canvas.empty.workspaceUnavailable' : 'canvas.empty.selectWorkspace')}

} - } + } + title={{(target && workspaceName) || t('canvas.empty.title')}} + description={canOpen ? undefined : t(target ? 'canvas.empty.workspaceUnavailable' : 'canvas.empty.selectWorkspace')} actions={
@@ -53,7 +47,6 @@ export function AuxPaneEmptyState({ workspacePath, sessionId }: { workspacePath? reopenClosedTab(); expandSessionAuxPane(); }}>{t('canvas.empty.reopen', { title: lastClosed!.tab.title })}} -
} />
; } diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.tsx index 0eb78b3981..27b9e6243c 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/DefaultHarnessSection.tsx @@ -216,7 +216,6 @@ export function DefaultHarnessSection(): React.ReactElement { return ( {content} diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx index 7b88a0912f..4e10962e49 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx @@ -386,7 +386,6 @@ const ExecutionSettingsPage: React.FC = () => { { { {/* ── Tool execution behavior ────────────────────────────── */} { { expect(advancedEnd).toBeGreaterThan(advancedStart); expect(source).not.toContain("import '@/infrastructure/config/components/MemoriesConfig.scss';"); expect(source).not.toContain(' { { { { @@ -3896,9 +3895,6 @@ const ModelSettingsPage: React.FC = () => { {isProviderScopedEditing && ( @@ -4656,7 +4652,6 @@ const ModelSettingsPage: React.FC = () => { @@ -4890,7 +4885,6 @@ const ModelSettingsPage: React.FC = () => {
{ {proxyLoadState === 'ready' ? { className="openbitfun-model-settings__models-section" bodySurface={false} title={tDefault('sections.providers')} - description={t('providersDescription')} extra={( { ) : <> { if (!desktopRuntime) { return ( - - {null} + +

{t('desktopOnly.description')}

); } @@ -74,7 +74,8 @@ const ReviewCapacitySection: React.FC = () => { if (!team) { return ( - + +

{loadError ?? t('messages.loadFailed')}

)} > + {!config.enabled && ( + + {null} + + )} {remoteWorkspace ? ( {null} @@ -466,7 +470,6 @@ const HooksSettingsSection: React.FC = () => { { {/* ── Built-in actions ──────────────────────────────────────────── */} - +
{builtinActions.map(action => ( { {/* ── Custom actions ────────────────────────────────────────────── */} { if (loading) { return ( - + ); @@ -413,7 +413,7 @@ const WebSearchSettingsPage: React.FC = () => { if (loadFailed) { return ( - + { @@ -452,7 +451,6 @@ const WebSearchSettingsPage: React.FC = () => { /> { {selectedProvider === 'openbitfun_search_http' ? ( { ) : null} {credentialRequired ? ( - +
{credentialConfigured ? t('status.configured') : t('status.missing')} @@ -585,7 +582,6 @@ const WebSearchSettingsPage: React.FC = () => { {selectedProvider === 'openbitfun_search_http' ? ( { expect(useSceneStore.getState().openTabs.map(tab => tab.session?.sessionId)).toEqual(['a', 'b']); }); + it.each(['local', 'peer:history'])('reconciles recent SSH workspace sessions on the %s surface', surfaceId => { + stop?.(); + activateSurface(surfaceId); + select([], null); + useSceneStore.getState().resetForPeerSwitch(); + stop = startSessionSceneLifecycle(); + const sessions = ['one', 'two', 'three'].map(id => session(id, { + workspaceId: 'ssh-project', workspacePath: '/srv/project', remoteConnectionId: 'ssh-host', + })); + select(sessions, 'one'); + useSceneStore.getState().openScene('session'); + flowChatStore.switchSession('two'); + flowChatStore.switchSession('three'); + const tabId = useSceneStore.getState().activeTabId; + expect(useSceneStore.getState().openTabs[0].sessionHistory).toEqual(['three', 'two', 'one']); + expect(useSceneStore.getState().openTabs[0].session?.surfaceId).toBe(surfaceId); + flowChatStore.removeSession('two'); + expect(useSceneStore.getState().openTabs[0].sessionHistory).toEqual(['three', 'one']); + expect(useSceneStore.getState().activeTabId).toBe(tabId); + expect(flowChatStore.getActiveSession()?.sessionId).toBe('three'); + }); + it.each(['canvas', 'page'] as const)('keeps a moved conversation synchronized while the other pane has %s focus', async focus => { const first = session('a', { workspacePath: '/a' }); const second = session('b', { workspacePath: '/b' }); diff --git a/src/web-ui/src/app/services/sessionSceneLifecycle.ts b/src/web-ui/src/app/services/sessionSceneLifecycle.ts index acfb1050d5..beeb41a5ac 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.ts @@ -92,18 +92,20 @@ export function startSessionSceneLifecycle(): () => void { const source = flowChatStore.getState(); const targets = new Map(); for (const tab of useSceneStore.getState().openTabs) { - const session = tab.session?.surfaceId === surfaceId - ? source.sessions.get(tab.session.sessionId) : undefined; - // Retire only explicitly closed workspace tabs. Cached sessions remain - // recoverable, and an offline remote workspace is not a close event. - if (session && closedWorkspace && resolveSessionSceneWorkspace(session, [closedWorkspace])) continue; - if (session) targets.set(session.sessionId, scenes.pendingTabId && tab.session - // Keep the transaction's identity until it commits. Metadata may - // acquire a workspace id while activation is awaiting the host. - ? tab.session - : resolveSessionSceneTarget( - session, workspaceManager.getState().openedWorkspaces.values(), surfaceId, - )); + if (!tab.session || tab.session.surfaceId !== surfaceId) continue; + for (const sessionId of new Set([tab.session.sessionId, ...tab.sessionHistory ?? []])) { + const session = source.sessions.get(sessionId); + // Retire only explicitly closed workspace tabs. Cached sessions remain + // recoverable, and an offline remote workspace is not a close event. + if (session && closedWorkspace && resolveSessionSceneWorkspace(session, [closedWorkspace])) continue; + if (session) targets.set(sessionId, scenes.pendingTabId + // Keep the transaction's identity until it commits. Metadata may + // acquire a workspace id while activation is awaiting the host. + ? { ...tab.session, sessionId } + : resolveSessionSceneTarget( + session, workspaceManager.getState().openedWorkspaces.values(), surfaceId, + )); + } } useSceneStore.getState().reconcileSessionScenes(targets); } finally { diff --git a/src/web-ui/src/app/stores/sceneStore.test.ts b/src/web-ui/src/app/stores/sceneStore.test.ts index be68c67d20..17494db725 100644 --- a/src/web-ui/src/app/stores/sceneStore.test.ts +++ b/src/web-ui/src/app/stores/sceneStore.test.ts @@ -32,6 +32,45 @@ describe('sceneStore transition snapshots', () => { expect(state.navCursor).toBe(-1); }); + it('keeps the five most recently opened sessions per workspace, moving repeats to the front', () => { + const scenes = useSceneStore.getState(); + for (let index = 1; index <= 7; index++) { + scenes.openSessionScene({ ...sessionTarget, sessionId: String(index) }); + } + scenes.openSessionScene({ ...sessionTarget, sessionId: '4' }); + expect(useSceneStore.getState().openTabs).toHaveLength(1); + expect(useSceneStore.getState().openTabs[0].sessionHistory).toEqual(['4', '7', '6', '5', '3']); + scenes.openSessionScene({ ...sessionTarget, workspaceKey: 'other', sessionId: 'elsewhere' }); + expect(useSceneStore.getState().openTabs.map(tab => tab.sessionHistory)) + .toEqual([['4', '7', '6', '5', '3'], ['elsewhere']]); + }); + + it('accepts legacy tabs without history and records background session changes without stealing focus', () => { + const scenes = useSceneStore.getState(); + scenes.openSessionScene(sessionTarget); + useSceneStore.setState(state => ({ openTabs: state.openTabs.map(({ sessionHistory: _history, ...tab }) => tab) })); + scenes.openScene('git'); + scenes.updateSessionScene({ ...sessionTarget, sessionId: 'next' }); + expect(useSceneStore.getState().activeTabId).toBe('git'); + expect(useSceneStore.getState().openTabs[0].sessionHistory).toEqual(['next', 'current']); + scenes.resetForPeerSwitch(); + expect(useSceneStore.getState().openTabs).toEqual([]); + }); + + it('does not add a failed session activation to the recent list', async () => { + useSceneStore.getState().openSessionScene(sessionTarget); + const stop = registerSessionSceneNavigation({ + current: () => sessionTarget, + isActive: target => target.sessionId === sessionTarget.sessionId, + activate: async () => false, + }); + try { + useSceneStore.getState().openSessionScene({ ...sessionTarget, sessionId: 'failed' }); + await vi.waitFor(() => expect(useSceneStore.getState().pendingTabId).toBeNull()); + expect(useSceneStore.getState().openTabs[0].sessionHistory).toEqual(['current']); + } finally { stop(); } + }); + it('only activates an existing session reference without creating or replacing its workspace tab', () => { const onActivated = vi.fn(); useSceneStore.getState().activateSessionScene(sessionTarget, { onActivated }); diff --git a/src/web-ui/src/app/stores/sceneStore.ts b/src/web-ui/src/app/stores/sceneStore.ts index 07ad97e974..0a790f6b50 100644 --- a/src/web-ui/src/app/stores/sceneStore.ts +++ b/src/web-ui/src/app/stores/sceneStore.ts @@ -87,6 +87,12 @@ function buildSceneTab(id: SceneTabId, now: number): SceneTab { return { id, lastUsed: now }; } +function rememberSession(tab: SceneTab, session: SessionSceneTarget): SceneTab { + const previous = tab.sessionHistory ?? (tab.session ? [tab.session.sessionId] : []); + return { ...tab, session, sessionHistory: [session.sessionId, + ...previous.filter(id => id !== session.sessionId)].slice(0, 5) }; +} + /** Shell adapter: the tab owner never loads history or changes runtime state. */ export interface SessionSceneNavigation { current: () => SessionSceneTarget | null; @@ -273,7 +279,7 @@ export const useSceneStore = create((set, get) => ({ const id = getSessionSceneTabId(target); const state = get(); if (!state.openTabs.some(tab => tab.id === id && tab.session?.sessionId !== target.sessionId)) return; - set({ openTabs: state.openTabs.map(tab => tab.id === id ? { ...tab, session: target } : tab) }); + set({ openTabs: state.openTabs.map(tab => tab.id === id ? rememberSession(tab, target) : tab) }); }, reconcileSessionScenes: (targets) => { @@ -289,12 +295,18 @@ export const useSceneStore = create((set, get) => ({ const target = tab.session && targets.get(tab.session.sessionId); if (!target) continue; const id = getSessionSceneTabId(target); + const history = tab.sessionHistory?.filter(sessionId => { + const entry = targets.get(sessionId); + return entry?.surfaceId === target.surfaceId && entry.workspaceKey === target.workspaceKey; + }); + const reconciled = id === tab.id && target.workspaceKey === tab.session?.workspaceKey + && history?.length === tab.sessionHistory?.length + ? tab : { ...tab, id, session: target, sessionHistory: history }; remapped.set(tab.id, id); const existing = tabs.get(id); if (!existing || tab.id === state.activeTabId || (existing.session?.sessionId !== activeSessionId && tab.lastUsed > existing.lastUsed)) { - tabs.set(id, id === tab.id && target.workspaceKey === tab.session?.workspaceKey - ? tab : { ...tab, id, session: target }); + tabs.set(id, reconciled); } } const nextTabs = [...tabs.values()]; @@ -528,7 +540,8 @@ function openSceneTarget(id: SceneTabId, session?: SessionSceneTarget, options?: const presentation = selectedSessionTab(state); const created = { ...buildSceneTab(id, Date.now()), ...(session && presentation ? { pane: tabPane(presentation) } : {}) }; - const tab = { ...(existing ?? created), ...(session ? { session } : {}), lastUsed: Date.now() }; + const source = existing ?? created; + const tab = { ...(session ? rememberSession(source, session) : source), lastUsed: Date.now() }; const tabs = existing ? state.openTabs.map(candidate => candidate.id === id ? tab : candidate) : [...state.openTabs, tab]; let panes = selectSceneInPane({ ...state, openTabs: orderPinnedTabsFirst(tabs) }, id); if (options?.revealCanvas) panes = selectCanvasInPane(panes, false); diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index 8b7f764237..f7b39e41a3 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -558,7 +558,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( return ( <> -
+ ) : showModelConfiguration || (activePanel === 'pools' && poolsEmpty) ? ( + + + ) : activePanel === 'pool' ? (
@@ -2637,7 +2656,6 @@ export const ModelSelector: React.FC = ({ ) : searchedModels.map(model => { const isSelected = !currentModelMode && (isNativeModelSelection ? concreteModelId : currentModelId) === model.id; - const providerMatched = modelSearchTerms.some(term => model.providerName.toLowerCase().includes(term)); return ( = ({ disabled={disabled || loading || reasoningLoading || externalSelection?.disabled} onClick={() => handleSelectModelFromMenu(model.id)} > - {model.modelName}{providerMatched && {model.providerName}} + {model.modelName} {model.providerName} ); @@ -2838,24 +2856,6 @@ export const ModelSelector: React.FC = ({ {acpFastModeItem}
- ) : activePanel === 'pools' && poolsEmpty ? ( - - - ) : isSelectionPanel ? (
= ({ metadata={isSelected ? : null} onClick={() => handleSelectModelFromMenu(model.id)} > - {model.modelName} + {model.modelName} {model.providerName} ); diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx index b2b0d78284..0caf38172a 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorProviderLevels.test.tsx @@ -367,6 +367,8 @@ describe('ModelSelector provider levels', () => { expect(modelOption('disabled-model')).toBeNull(); expect(modelOption('image-model')).toBeNull(); expect(modelOption('acme-fast')?.getAttribute('aria-checked')).toBe('true'); + expect(modelOption('acme-fast')?.querySelector('[data-openbitfun-part="label"]')?.textContent).toBe('acme-fast-native Acme'); + expect(modelOption('umbra-main')?.querySelector('.openbitfun-model-selector__option-provider')?.textContent?.trim()).toBe('Umbra'); expect(modelOption('acme-fast')?.querySelector('[data-openbitfun-part="metadata"]')).toBeNull(); await searchFor(' ACME deep '); @@ -377,7 +379,7 @@ describe('ModelSelector provider levels', () => { expect(modelOption('acme-fast')).toBeNull(); expect(modelOption('umbra-main')).toBeNull(); await searchFor('deep'); - expect(modelOption('acme-deep')?.textContent).toBe('acme-deep-native'); + expect(modelOption('acme-deep')?.textContent).toBe('acme-deep-native Acme'); expect(modelOption('acme-deep')?.querySelector('[data-openbitfun-part="metadata"]')).toBeNull(); await searchFor('Umbra'); expect(modelOption('umbra-main')).not.toBeNull(); @@ -1802,6 +1804,7 @@ describe('ModelSelector provider levels', () => { expect(modelOption('acme-fast')).not.toBeNull(); expect(modelOption('acme-deep')).not.toBeNull(); expect(modelOption('umbra-main')).toBeNull(); + expect(modelOption('acme-fast')?.querySelector('[data-openbitfun-part="label"]')?.textContent).toBe('acme-fast-native Acme'); expect(sharedPanelItems()).not.toBeNull(); expect(sharedPanelItems()?.contains(modelOption('acme-fast'))).toBe(true); expect(sharedPanelItems()?.contains(modelOption('acme-deep'))).toBe(true); @@ -1833,7 +1836,8 @@ describe('ModelSelector provider levels', () => { expect(modelOption('umbra-main')).not.toBeNull(); }); - it('keeps the selector visible and actionable when no model is configured', async () => { + it.each(['local', 'peer-empty-models'])('offers only model configuration without fallback context on %s when no model is configured', async surface => { + activateSurface(surface); const onAvailabilityChange = vi.fn(); vi.mocked(configManager.getConfigs).mockResolvedValue({ 'ai.models': [], @@ -1844,6 +1848,8 @@ describe('ModelSelector provider levels', () => { root.render( , ); @@ -1854,19 +1860,37 @@ describe('ModelSelector provider levels', () => { '[data-testid="chat-model-selector-btn"]', ); expect(trigger).not.toBeNull(); - expect(trigger?.textContent).toContain('modelSelector.status.unconfigured'); + expect(trigger?.textContent).toContain('modelSelector.noModelSelected'); + expect(trigger?.dataset.tooltip).toBe('modelSelector.configure'); expect(onAvailabilityChange).toHaveBeenLastCalledWith({ status: 'unconfigured', canSend: false, }); - await openMenu(); - expect(document.body.querySelector( - '[data-testid="chat-model-selector-status"][data-model-status="unconfigured"]', - )).not.toBeNull(); - expect(document.body.querySelector( - '[data-testid="chat-model-selector-open-settings"]', - )).not.toBeNull(); + vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] }); + await act(async () => { + trigger!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + vi.advanceTimersToNextFrame(); + }); + await act(async () => { vi.advanceTimersToNextFrame(); }); + const menu = document.body.querySelector('[data-testid="chat-model-selector-menu"]')!; + const configure = menu.querySelector('[data-testid="chat-model-selector-open-settings"]')!; + expect(menu.getAttribute('data-mode-empty')).toBe('true'); + expect(configure.closest('.openbitfun-model-selector__mode-empty')).not.toBeNull(); + expect(menu.querySelectorAll('button')).toHaveLength(1); + expect(menu.textContent).toBe('modelSelector.configure'); + expect(document.activeElement).toBe(configure); + expect(menu.querySelector('[data-testid="chat-model-selector-settings-context"]')).toBeNull(); + expect(menu.querySelector('[data-testid="chat-model-selector-status"]')).toBeNull(); + + const openSettings = vi.spyOn(quickActions, 'openSettings').mockImplementation(() => undefined); + try { + await act(async () => configure.click()); + expect(openSettings).toHaveBeenCalledExactlyOnceWith({ pageId: 'ai.models' }); + expect(trigger?.getAttribute('aria-expanded')).toBe('false'); + } finally { + openSettings.mockRestore(); + } }); it('blocks sending with a missing pinned ID while keeping replacement models selectable', async () => { diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx index cc64038fcc..0557eb8b3c 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx @@ -546,7 +546,7 @@ describe('AcpAgentsConfig', () => { const registrySection = registryHeading?.closest('section'); expect(registrySection?.textContent).toContain('actions.refresh'); expect(registrySection?.textContent).toContain('presets.opencode.description'); - expect(registrySection?.textContent).toContain('registry.description'); + expect(registrySection?.textContent).not.toContain('registry.description'); expect(registrySection?.textContent).not.toContain('Native ACP coding agent'); }); diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx index 38f840ca30..33a82b8eb2 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -1349,7 +1349,7 @@ const AcpAgentsConfig = forwardRef( data-openbitfun-component="acp-agents-config" data-openbitfun-part="root" > - {presentation === 'page' ? : null} + {presentation === 'page' ? : null} {loading ? ( @@ -1374,7 +1374,6 @@ const AcpAgentsConfig = forwardRef( > {presentation === 'page' ? : null} @@ -1438,7 +1437,6 @@ const AcpAgentsConfig = forwardRef( {activeView === 'json' && (