diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index f245d23c4b..daa54c601e 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -467,6 +467,9 @@ except for error tone. Use `showIcon={false}` for text-only notices. uncontrolled open state, trigger/region accessibility wiring, focus exclusion while collapsed, reduced-motion behavior, and independent header actions. Product copy and the revealed content remain consumer-owned. +Use `headerClassName` to style the default header surface while retaining its +trigger, actions, and focus behavior. `triggerClassName` and `contentInnerClassName` +allow consumers to align the indicator and revealed content without replacing the trigger. Use `Disclosure presentation="native"` when the browser must own the existing `details`/`summary` behavior. This mode renders a real details element, a direct diff --git a/design-system/packages/ui/src/components/Disclosure/Disclosure.meta.ts b/design-system/packages/ui/src/components/Disclosure/Disclosure.meta.ts index 130120ea78..c6d2e9a1b5 100644 --- a/design-system/packages/ui/src/components/Disclosure/Disclosure.meta.ts +++ b/design-system/packages/ui/src/components/Disclosure/Disclosure.meta.ts @@ -7,6 +7,8 @@ export const disclosureMeta = { name: "Disclosure", props: [ { name: "renderHeader", type: "(triggerProps: DisclosureTriggerProps) => ReactNode" }, + { name: "headerClassName", type: "string" }, + { name: "triggerClassName", type: "string" }, { name: "contentClassName", type: "string" }, { name: "contentInnerClassName", type: "string" }, { name: "unmountOnClose", type: "boolean", defaultValue: "false" }, diff --git a/design-system/packages/ui/src/components/Disclosure/Disclosure.tsx b/design-system/packages/ui/src/components/Disclosure/Disclosure.tsx index 9ebc192457..0e7738a80d 100644 --- a/design-system/packages/ui/src/components/Disclosure/Disclosure.tsx +++ b/design-system/packages/ui/src/components/Disclosure/Disclosure.tsx @@ -23,6 +23,9 @@ interface CustomDisclosureProps actions?: ReactNode; /** Compose a header with independent controls using the supplied toggle attributes. */ renderHeader?: (triggerProps: DisclosureTriggerProps) => ReactNode; + /** Style the default header surface without replacing its trigger or actions. */ + headerClassName?: string; + triggerClassName?: string; contentClassName?: string; contentInnerClassName?: string; unmountOnClose?: boolean; @@ -53,6 +56,8 @@ const CustomDisclosure = forwardRef( function Disclosure({ actions, renderHeader, + headerClassName, + triggerClassName, contentClassName, contentInnerClassName, unmountOnClose = false, @@ -107,11 +112,11 @@ const CustomDisclosure = forwardRef( id: triggerId, onClick: toggle, type: 'button', - }) :
+ }) :
}> + + + + + ); +} diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts index 50346c49aa..b83309acf2 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts @@ -88,6 +88,7 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ 'theme', 'font', 'size', 'motion', 'animation', 'appearance pack', 'skin', 'import', 'session', 'panel', 'layout', 'overlay', + 'chat', 'content', 'width', 'resize', ], namespaces: ['settings/appearance', 'settings/application'], searchPhrases: [ @@ -100,6 +101,8 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ phrase('settings/appearance', 'sessionPanel.displayMode'), phrase('settings/appearance', 'sessionPanel.split'), phrase('settings/appearance', 'sessionPanel.overlay'), + phrase('settings/appearance', 'contentWidth.title'), + phrase('settings/appearance', 'contentWidth.label'), ], load: () => import('./pages/application/AppearanceSettingsPage'), }), diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 3b798e60c9..ea2919c20c 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -3782,6 +3782,7 @@ export const ChatInput: React.FC = ({ effectiveTargetSessionId, { isProcessing: !!derivedState?.isProcessing, + reportTitle: t('usage.title'), busyMessage: t('chatInput.usageBusy'), noWorkspaceMessage: t('chatInput.usageNoWorkspace'), failedTitle: t('chatInput.usageFailed'), diff --git a/src/web-ui/src/flow_chat/components/FlowChatWidthBoundary.scss b/src/web-ui/src/flow_chat/components/FlowChatWidthBoundary.scss new file mode 100644 index 0000000000..3e4f560eec --- /dev/null +++ b/src/web-ui/src/flow_chat/components/FlowChatWidthBoundary.scss @@ -0,0 +1,67 @@ +.flow-chat-width-boundary { + position: absolute; + inset-block: var(--openbitfun-space-12); + left: 50%; + transform: translateX(-50%); + pointer-events: none; + z-index: var(--openbitfun-layer-overlay); + + &[hidden] { display: none; } + + &__handle { + position: absolute; + inset-block: 0; + width: var(--openbitfun-space-3); + transform: translateX(-50%); + cursor: col-resize; + touch-action: none; + user-select: none; + pointer-events: auto; + outline: none; + + &--left { left: 0; } + &--right { left: 100%; } + + &::before, + &::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + background: var(--openbitfun-color-border-default); + opacity: 0; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + &::before { + top: 50%; + height: 50%; + width: var(--openbitfun-border-width-default); + transform: translate(-50%, -50%); + } + + &::after { + top: 50%; + height: var(--openbitfun-space-8); + width: var(--openbitfun-border-width-strong); + border-radius: var(--openbitfun-radius-pill); + transform: translate(-50%, -50%); + background: var(--openbitfun-color-content-secondary); + } + + &:hover::before { opacity: 0.5; } + &:hover::after, + &:focus-visible::before, + &:focus-visible::after { opacity: 1; } + + &:focus-visible::after { background: var(--openbitfun-color-focus-ring); } + } + + &[data-resizing] &__handle::before { opacity: 0.5; } + &[data-resizing] &__handle::after { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .flow-chat-width-boundary__handle::before, + .flow-chat-width-boundary__handle::after { transition: none; } +} diff --git a/src/web-ui/src/flow_chat/components/FlowChatWidthBoundary.test.tsx b/src/web-ui/src/flow_chat/components/FlowChatWidthBoundary.test.tsx new file mode 100644 index 0000000000..260c5f5dbc --- /dev/null +++ b/src/web-ui/src/flow_chat/components/FlowChatWidthBoundary.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +import React, { act, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { flowChatWidthStore, FLOW_CHAT_WIDTH_STORAGE_KEY } from '@/infrastructure/appearance/preferences/flowChatWidthStore'; +import { FlowChatWidthBoundary } from './FlowChatWidthBoundary'; + +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('FlowChat content-width boundary', () => { + let host: HTMLDivElement; + let root: Root; + let paneWidth: number; + let resized: () => void; + let resizing: ReturnType; + let writes: ReturnType; + const pane = () => host.querySelector('[data-testid="pane"]')!; + const handle = (side = 'right') => host.querySelector(`.flow-chat-width-boundary__handle--${side}`)!; + const renderedWidth = () => pane().style.getPropertyValue('--openbitfun-control-flow-chat-transcript-max-width'); + function Harness({ active }: { active: boolean }) { + const paneRef = useRef(null); + return