diff --git a/src/web-ui/src/app/hooks/useApp.ts b/src/web-ui/src/app/hooks/useApp.ts index da983bb6ce..1570ae4b7d 100644 --- a/src/web-ui/src/app/hooks/useApp.ts +++ b/src/web-ui/src/app/hooks/useApp.ts @@ -3,7 +3,7 @@ * Provides unified app state management and actions. */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useSyncExternalStore } from 'react'; import { UseAppReturn, AppState, @@ -56,11 +56,19 @@ export const useApp = (): UseAppReturn => { const nextChatCollapsed = !state.layout.chatCollapsed; appManager.updateLayout({ chatCollapsed: nextChatCollapsed, + // Collapsing the chat pane exits full-width tiled chat so the layout + // resets to its centered column state. + chatFullWidth: nextChatCollapsed ? false : state.layout.chatFullWidth, // Keep behavior aligned with editor-mode layout: // when chat is hidden, ensure the right panel is visible to occupy center space. rightPanelCollapsed: nextChatCollapsed ? false : state.layout.rightPanelCollapsed }); - }, [state.layout.chatCollapsed, state.layout.rightPanelCollapsed]); + }, [state.layout.chatCollapsed, state.layout.rightPanelCollapsed, state.layout.chatFullWidth]); + + const toggleChatFullWidth = useCallback(() => { + const next = !state.layout.chatFullWidth; + appManager.updateLayout({ chatFullWidth: next }); + }, [state.layout.chatFullWidth]); const switchLeftPanelTab = useCallback((tab: PanelType) => { appManager.updateLayout({ @@ -222,6 +230,7 @@ export const useApp = (): UseAppReturn => { toggleRightPanel, toggleBottomTerminalPanel, toggleChatPanel, + toggleChatFullWidth, switchLeftPanelTab, updateLeftPanelWidth, updateCenterPanelWidth, @@ -290,3 +299,25 @@ export const useTabs = () => { selectTab }; }; + +// Fine-grained read-only subscription to the chat full-width flag. Uses +// `useSyncExternalStore` so only components observing this flag re-render when +// it flips, instead of re-rendering on any app state change. +export const useChatFullWidth = (): boolean => { + return useSyncExternalStore( + (callback) => appManager.addEventListener(callback), + () => appManager.getState().layout.chatFullWidth, + () => false, // SSR / non-browser snapshot + ); +}; + +// Fine-grained action to toggle the chat full-width flag without subscribing to +// the whole app store. Reads the current value straight from the manager rather +// than a subscribed snapshot, so components that only need this action re-render +// only when they observe the flag via `useChatFullWidth`, never on arbitrary app +// state changes. +export const useToggleChatFullWidth = (): (() => void) => { + return useCallback(() => { + appManager.updateLayout({ chatFullWidth: !appManager.getState().layout.chatFullWidth }); + }, []); +}; diff --git a/src/web-ui/src/app/scenes/session/ChatPane.scss b/src/web-ui/src/app/scenes/session/ChatPane.scss index d1fcb83697..73c6de6c82 100644 --- a/src/web-ui/src/app/scenes/session/ChatPane.scss +++ b/src/web-ui/src/app/scenes/session/ChatPane.scss @@ -25,3 +25,11 @@ width: 100%; height: 100%; } + +.bitfun-chat-pane__content--chat-full-width { + /* Full-width tiled chat: stretch the reading column edge to edge instead of + * capping at the content max-width token (900px). */ + .virtual-item-wrapper { + max-width: none; + } +} diff --git a/src/web-ui/src/app/scenes/session/ChatPane.tsx b/src/web-ui/src/app/scenes/session/ChatPane.tsx index 7e92dd16ea..b39f319ae6 100644 --- a/src/web-ui/src/app/scenes/session/ChatPane.tsx +++ b/src/web-ui/src/app/scenes/session/ChatPane.tsx @@ -14,6 +14,7 @@ import type { LineRange } from '@/component-library'; import path from 'path-browserify'; import { createLogger } from '@/shared/utils/logger'; import { hasNonFileUriScheme } from '@/shared/utils/pathUtils'; +import { useChatFullWidth } from '@/app/hooks/useApp'; import './ChatPane.scss'; @@ -50,6 +51,7 @@ const ChatPaneInner: React.FC = ({ chatInputRegistration, }) => { const addTab = useCanvasStore(state => state.addTab); + const chatFullWidth = useChatFullWidth(); const deferredTaskDetailTimersRef = useRef([]); const deferredTaskDetailIdleCallbacksRef = useRef([]); @@ -151,7 +153,7 @@ const ChatPaneInner: React.FC = ({ return (
= ({ isSceneActive={isSceneActive} onSendMessage={(_message: string) => {}} registration={chatInputRegistration} + className={chatFullWidth ? 'bitfun-chat-input--chat-full-width' : ''} /> )}
diff --git a/src/web-ui/src/app/types/index.ts b/src/web-ui/src/app/types/index.ts index 4ee1c3d875..98b9e81eaa 100644 --- a/src/web-ui/src/app/types/index.ts +++ b/src/web-ui/src/app/types/index.ts @@ -41,6 +41,7 @@ export interface LayoutState { centerPanelWidth: number; centerPanelCollapsed: boolean; chatCollapsed: boolean; + chatFullWidth: boolean; rightPanelWidth: number; // Fixed right panel width rightPanelCollapsed: boolean; bottomTerminalPanelHeight: number; @@ -191,6 +192,7 @@ export interface UseAppReturn { toggleRightPanel: () => void; toggleBottomTerminalPanel: () => void; toggleChatPanel: () => void; + toggleChatFullWidth: () => void; switchLeftPanelTab: (tab: PanelType) => void; updateLeftPanelWidth: (width: number, options?: { persist?: boolean }) => void; updateCenterPanelWidth: (width: number) => void; @@ -231,6 +233,7 @@ export const DEFAULT_LAYOUT_STATE: LayoutState = { : 960, centerPanelCollapsed: false, chatCollapsed: false, + chatFullWidth: false, rightPanelWidth: typeof window !== 'undefined' ? Math.max(540, Math.min(800, Math.floor(window.innerWidth * 0.35))) // Right 35%, min 540px (for config-tabs), max 800px : 540, diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index da988190c2..ad3f6723be 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -14,7 +14,7 @@ width: 100%; height: auto; min-height: 0; - max-width: 900px; + max-width: var(--bf-appearance-token-flowchat-content-max-width); z-index: $z-overlay; display: flex; flex-direction: column; @@ -27,6 +27,20 @@ padding-bottom: 32px; } + /* Full-width tiled chat: the composer stretches edge to edge with the chat + * pane (still horizontally centered) instead of capping at 900px. */ + &:has(.bitfun-chat-input--chat-full-width) { + max-width: none; + padding-left: $size-gap-4; + padding-right: $size-gap-4; + } + + /* Full-width tiled chat: inner rows (the file modifications bar) stretch + * with the composer instead of capping at 900px. */ + &:has(.bitfun-chat-input--chat-full-width) .session-file-modifications-bar { + max-width: none; + } + &.bitfun-context-drop-zone--can-accept { .bitfun-chat-input__box { border-color: var(--bf-appearance-token-border-medium); @@ -602,7 +616,7 @@ .session-file-modifications-bar { width: 100%; - max-width: 900px; + max-width: var(--bf-appearance-token-flowchat-content-max-width); } & > * { diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss index de59e368a1..f9b0c03935 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss @@ -144,7 +144,7 @@ &__runtime-status .runtime-status-slot__content { width: 100%; - max-width: 900px; + max-width: var(--bf-appearance-token-flowchat-content-max-width); margin: 0 auto; padding: 0 var(--bf-appearance-token-flowchat-content-inline-pad); box-sizing: border-box; diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss index 5ee5f77c5f..85e39bf85f 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss +++ b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss @@ -6,7 +6,7 @@ /* Same column box as .model-round-item so the collapse header (`>`) and the * round's body text share one leading edge. */ box-sizing: border-box; - width: min(100%, 900px); + width: min(100%, var(--bf-appearance-token-flowchat-content-max-width)); margin: 0 auto; padding: 0 var(--bf-appearance-token-flowchat-content-inline-pad); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx index 2529071f8d..e0e7856be0 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx @@ -57,6 +57,15 @@ vi.mock('@/shared/utils/tabUtils', () => ({ createReviewPlatformTab: vi.fn(), })); +const { mockToggleChatFullWidth } = vi.hoisted(() => ({ + mockToggleChatFullWidth: vi.fn(), +})); + +vi.mock('@/app/hooks/useApp', () => ({ + useToggleChatFullWidth: () => mockToggleChatFullWidth, + useChatFullWidth: () => false, +})); + vi.mock('./SessionFilesBadge', () => ({ SessionFilesBadge: () =>
, })); @@ -145,6 +154,22 @@ describe('FlowChatHeader', () => { expect(container.querySelector('[data-testid="flowchat-header-turn-next"]')).toBeNull(); }); + it('renders a full-width toggle that flips the chat layout', () => { + mockToggleChatFullWidth.mockClear(); + + act(() => { + root.render(); + }); + + const toggle = container.querySelector('[data-testid="session-fullwidth-toggle"]'); + expect(toggle).not.toBeNull(); + + act(() => { + toggle?.click(); + }); + expect(mockToggleChatFullWidth).toHaveBeenCalledTimes(1); + }); + it('places the Agent tree entry immediately before background commands', () => { act(() => { root.render(); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index 3cd97ede3c..2647da2984 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { ChevronDown, ChevronUp, GitPullRequest, Keyboard, MoreHorizontal, Search, Square, SquareTerminal, Terminal, X } from 'lucide-react'; +import { ChevronDown, ChevronUp, GitPullRequest, Keyboard, Maximize2, Minimize2, MoreHorizontal, Search, Square, SquareTerminal, Terminal, X } from 'lucide-react'; import { Tooltip, IconButton, Input } from '@/component-library'; import { useTranslation } from 'react-i18next'; import { SessionFilesBadge } from './SessionFilesBadge'; @@ -16,6 +16,7 @@ import { getAppearanceOverlayHost } from '@/infrastructure/appearance'; import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { createReviewPlatformTab } from '@/shared/utils/tabUtils'; +import { useChatFullWidth, useToggleChatFullWidth } from '@/app/hooks/useApp'; import './FlowChatHeader.scss'; export interface FlowChatHeaderCommandSummary { @@ -102,6 +103,8 @@ export const FlowChatHeader: React.FC = ({ onStopAllBackgroundCommands, }) => { const { t } = useTranslation('flow-chat'); + const toggleChatFullWidth = useToggleChatFullWidth(); + const isChatFullWidth = useChatFullWidth(); const { currentWorkspace } = useWorkspaceContext(); const [isBackgroundCommandPanelOpen, setIsBackgroundCommandPanelOpen] = useState(false); const [isBackgroundCommandSectionMenuOpen, setIsBackgroundCommandSectionMenuOpen] = useState(false); @@ -769,6 +772,18 @@ export const FlowChatHeader: React.FC = ({ )} + + toggleChatFullWidth()} + tooltip={t(isChatFullWidth ? 'layout.fullWidth.exit' : 'layout.fullWidth.enter')} + aria-label={t(isChatFullWidth ? 'layout.fullWidth.exit' : 'layout.fullWidth.enter')} + data-testid="session-fullwidth-toggle" + > + {isChatFullWidth ? : } +
); diff --git a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.scss b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.scss index 2b6e524aa0..3299b9b5b4 100644 --- a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.scss +++ b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.scss @@ -37,7 +37,7 @@ &--footer &__content { width: 100%; - max-width: 900px; + max-width: var(--bf-appearance-token-flowchat-content-max-width); margin: 0 auto; padding: 0 var(--bf-appearance-token-flowchat-content-inline-pad); box-sizing: border-box; diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss index 560680ee97..3e06410d93 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss @@ -10,7 +10,7 @@ * groups, user messages, notices). Matches ChatInput's 900px so headers, * `>` collapse rows, and bubbles all sit on the same leading edge. */ - max-width: 900px; + max-width: var(--bf-appearance-token-flowchat-content-max-width); margin: 0 auto; box-sizing: border-box; min-height: 1px; diff --git a/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts b/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts index 595af79dcc..8a711f7f33 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts @@ -291,6 +291,7 @@ function createCssTokens(palette: AppearancePalette): Record { '--bf-appearance-token-flowchat-control-pad-x': '0.75rem', '--bf-appearance-token-flowchat-content-inline-pad': '3rem', '--bf-appearance-token-flowchat-content-inline-pad-mobile': '1.5rem', + '--bf-appearance-token-flowchat-content-max-width': '900px', '--bf-appearance-token-flowchat-card-gap': '0.42rem', '--bf-appearance-token-flowchat-card-radius': effects.radius.base, '--bf-appearance-token-flowchat-card-pad-y': '0.625rem', diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index e6acda567a..eded60b85e 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -75,6 +75,10 @@ "comfortable": "Comfortable", "expanded": "Expanded" }, + "fullWidth": { + "enter": "Tile chat full width", + "exit": "Exit full-width tiled chat" + }, "resizer": { "leftAriaLabel": "Resize left panel", "centerAriaLabel": "Resize panel", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 62ca45116d..01dbaaf0f6 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -75,6 +75,10 @@ "comfortable": "舒适", "expanded": "展开" }, + "fullWidth": { + "enter": "全宽平铺对话", + "exit": "退出全宽平铺" + }, "resizer": { "leftAriaLabel": "调整左侧面板大小", "centerAriaLabel": "调整面板大小", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index e53ce85ca1..26f6bd4e32 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -75,6 +75,10 @@ "comfortable": "舒適", "expanded": "展開" }, + "fullWidth": { + "enter": "全寬平鋪對話", + "exit": "退出全寬平鋪" + }, "resizer": { "leftAriaLabel": "調整左側面板大小", "centerAriaLabel": "調整面板大小",