diff --git a/.changeset/fn-011-chat-message-layout.md b/.changeset/fn-011-chat-message-layout.md new file mode 100644 index 0000000000..19721ed4a7 --- /dev/null +++ b/.changeset/fn-011-chat-message-layout.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a project Appearance preference for bubble or full-width chat messages. +category: feature +dev: Applies the project-scoped `chatMessageLayout` choice to normal, Quick, dock, Activity, and Planner Chat surfaces. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1f3b7b8914..1e28766e37 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -692,6 +692,10 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne Chat view provides project-scoped conversations with agents. The default conversation list contains only active sessions; use **Archived conversations** to view archived sessions, restore one to the active list, or explicitly delete it. Archive is the default removal action, while delete remains a separate destructive action. +### Conversation layout + +Use **Settings → Appearance → Conversation layout** to choose the project-scoped message presentation for every dashboard chat surface. **Bubbles** is the default and keeps the bounded, left/right-aligned message bubbles; **Full width** lets each message use the available transcript width. The choice applies immediately to normal Chat, Quick Chat, and dock/overflow Chat hosts, as well as task-detail **Activity** and **Planner Chat**. Missing or invalid values safely use **Bubbles**. + ## Mailbox archive Mailbox Inbox, Outbox, and agent lists exclude archived correspondence and unread badges ignore it. Select **Archived** to review archived messages and restore them; Archive is the default removal action and Delete remains available as an explicit destructive action. diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index a25afeab3d..e23d95719b 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CONSECUTIVE_TOOL_FAILURE_RETRY_THRESHOLD, DEFAULT_CONSECUTIVE_TOOL_FAILURE_RETRY_BACKOFF_MS, DEFAULT_MAX_CONSECUTIVE_TOOL_FAILURE_RETRIES, DEFAULT_MAX_AUTO_MERGE_RETRIES, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries } from "../tasks/in-review-stall.js"; import { isExperimentalFeatureEnabled } from "../config/experimental-features.js"; -import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalOnlySettingsKey } from "../config/settings-schema.js"; +import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalOnlySettingsKey, isProjectSettingsKey } from "../config/settings-schema.js"; import { __resetLegacyCwdMainWarningForTests, normalizeMergeIntegrationWorktreeMode, @@ -246,6 +246,18 @@ describe("settings defaults invariants", () => { }); }); + describe("chatMessageLayout default", () => { + it("defaults to bubbles and keeps the setting project-scoped", () => { + expect(DEFAULT_PROJECT_SETTINGS.chatMessageLayout).toBe("bubbles"); + expect("chatMessageLayout" in DEFAULT_PROJECT_SETTINGS).toBe(true); + expect(PROJECT_SETTINGS_KEYS).toContain("chatMessageLayout"); + expect(isProjectSettingsKey("chatMessageLayout")).toBe(true); + expect("chatMessageLayout" in DEFAULT_GLOBAL_SETTINGS).toBe(false); + expect(GLOBAL_SETTINGS_KEYS).not.toContain("chatMessageLayout"); + expect(isGlobalOnlySettingsKey("chatMessageLayout")).toBe(false); + }); + }); + describe("taskDetailChatFirst default", () => { it("keeps taskDetailChatFirst explicitly false in project defaults", () => { expect(DEFAULT_PROJECT_SETTINGS.taskDetailChatFirst).toBe(false); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index a114681750..ab32288f29 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -63,6 +63,10 @@ describe("settings key parity", () => { expect(isProjectSettingsKey("maxConcurrent")).toBe(true); expect(isProjectSettingsKey("maxRecommendationsPerTask")).toBe(true); expect(isGlobalSettingsKey("maxRecommendationsPerTask")).toBe(false); + expect(isProjectSettingsKey("chatMessageLayout")).toBe(true); + expect(isGlobalSettingsKey("chatMessageLayout")).toBe(false); + expect(PROJECT_SETTINGS_KEYS).toContain("chatMessageLayout"); + expect(GLOBAL_SETTINGS_KEYS).not.toContain("chatMessageLayout"); expect(isProjectSettingsKey("recommendationMailboxNoticeEnabled")).toBe(true); expect(isGlobalSettingsKey("recommendationMailboxNoticeEnabled")).toBe(false); expect(isProjectSettingsKey("heartbeatMultiplier")).toBe(true); diff --git a/packages/core/src/config/settings-schema.ts b/packages/core/src/config/settings-schema.ts index d69b7cf3ea..4c01683325 100644 --- a/packages/core/src/config/settings-schema.ts +++ b/packages/core/src/config/settings-schema.ts @@ -536,6 +536,11 @@ export const DEFAULT_PROJECT_SETTINGS = { */ showCostBadgeOnCards: false, /* + FNXC:ChatMessageLayout 2026-08-18-20:27: + The project preference applies one reversible layout choice to every standard and task chat surface. Explicitly seed the historical bubbles presentation so upgraded projects preserve their existing UI. + */ + chatMessageLayout: "bubbles", + /* FNXC:TaskDetailActivityFirst 2026-06-30-23:59: Project task-detail defaults are Activity-first unless this opt-in is true. Keeping the default false preserves explicit deep-link ids while making omitted non-done task opens land on Activity → Live. */ diff --git a/packages/core/src/types/settings/settings-scope.ts b/packages/core/src/types/settings/settings-scope.ts index 8d884e273d..49f912aa3d 100644 --- a/packages/core/src/types/settings/settings-scope.ts +++ b/packages/core/src/types/settings/settings-scope.ts @@ -1430,6 +1430,11 @@ export interface ProjectSettings { * Default-off project setting that lets operators opt board cards into showing derived read-time task cost next to the execution-time badge. Missing/false preserves existing card density and no badge shell renders unless a task has positive token usage. */ showCostBadgeOnCards?: boolean; + /** + * FNXC:ChatMessageLayout 2026-08-18-20:27: + * One project-scoped choice controls message presentation in normal Chat and task Activity/Planner Chat. Missing or invalid persisted values resolve to the historical bubble layout. + */ + chatMessageLayout?: "bubbles" | "full-width"; /** * FNXC:TaskDetailActivityFirst 2026-06-30-23:59: * Default-off keeps task details Activity-first so omitted non-done opens land on the legacy `chat` Activity → Live surface. Operators can set true to restore Chat-first ordering/default while explicit Activity/Chat/Logs deep links remain stable. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 8095326d4c..19519e1519 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -71,6 +71,7 @@ import { FileBrowserProvider } from "./context/FileBrowserContext"; import { ShellProvider } from "./context/ShellContext"; import { RetryWarningProvider } from "./context/RetryWarningContext"; import { CostBadgeProvider } from "./context/CostBadgeContext"; +import { ChatMessageLayoutProvider } from "./context/ChatMessageLayoutContext"; import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext"; import { useShellConnection } from "./hooks/useShellConnection"; import { useStashOrphanCount } from "./hooks/useStashOrphanCount"; @@ -859,6 +860,7 @@ function AppInner() { showCostBadgeOnCards, modelPricingOverrides, taskDetailChatFirst, + chatMessageLayout, quickChatButtonMode, mobileNavPrimaryItems, quickChatCloseOnOutsideClick, @@ -875,6 +877,7 @@ function AppInner() { devServerEnabled, goalsEnabled, setQuickChatButtonModeImmediate, + setChatMessageLayoutImmediate, setMobileNavPrimaryItemsImmediate, toggleAutoMerge, togglePlanAutoApprove, @@ -1860,6 +1863,7 @@ function AppInner() { }; return ( + @@ -2259,7 +2263,7 @@ function AppInner() { onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined} taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, revertTask, retryTask, pauseTask, unpauseTask, bypassReview, resetTask, duplicateTask }} deepLink={{ handleDetailClose }} - settings={{ prAuthAvailable, autoMerge, taskDetailChatFirst, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate, setMobileNavPrimaryItemsImmediate }} + settings={{ prAuthAvailable, autoMerge, taskDetailChatFirst, chatMessageLayout, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate, setChatMessageLayoutImmediate, setMobileNavPrimaryItemsImmediate }} onSettingsClose={handleSettingsCloseWithNav} onReopenOnboarding={reopenOnboardingWithNav} onOpenApprovals={(_approvalId) => handleTaskViewChange("mailbox")} @@ -2290,6 +2294,7 @@ function AppInner() { + ); } diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index ce04613e54..0aee967c56 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -5,6 +5,7 @@ import type { UseProjectActionsResult } from "../hooks/useProjectActions"; import { mergeTaskSnapshot } from "../hooks/useTasks"; import type { ModalManager } from "../hooks/useModalManager"; import type { UseTaskHandlersResult } from "../hooks/useTaskHandlers"; +import type { ChatMessageLayout } from "../hooks/useAppSettings"; import type { Toast, ToastType } from "../hooks/useToast"; import { ModalErrorBoundary } from "./ErrorBoundary"; import { TaskDetailModal } from "./TaskDetailModal"; @@ -85,6 +86,7 @@ interface AppModalsProps { prAuthAvailable: boolean; autoMerge: boolean; taskDetailChatFirst: boolean; + chatMessageLayout: ChatMessageLayout; themeMode: ThemeMode; colorTheme: ColorTheme; dashboardFontScalePct: number; @@ -95,6 +97,7 @@ interface AppModalsProps { setDashboardFontScalePct: (scalePct: number) => void; setShadcnCustomColors: (colors: Record) => void; setQuickChatButtonModeImmediate: (mode: "floating" | "footer" | "off") => void; + setChatMessageLayoutImmediate: (layout: ChatMessageLayout) => void; setMobileNavPrimaryItemsImmediate: (items: string[]) => void; }; /** Optional override for the settings modal close handler. When provided, this is called instead of modalManager.closeSettings. */ @@ -381,6 +384,8 @@ export function AppModals({ onDashboardFontScaleChange={settings.setDashboardFontScalePct} onShadcnCustomColorsChange={settings.setShadcnCustomColors} onQuickChatButtonModeChange={settings.setQuickChatButtonModeImmediate} + chatMessageLayout={settings.chatMessageLayout} + onChatMessageLayoutChange={settings.setChatMessageLayoutImmediate} onMobileNavPrimaryItemsChange={settings.setMobileNavPrimaryItemsImmediate} onReopenOnboarding={onReopenOnboarding} onOpenApprovals={onOpenApprovals} diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index fd6099ddbb..5fb49967d1 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -1048,6 +1048,29 @@ The thread-wide Markdown/plain render toggle (`.chat-thread-header-render-toggle word-break: break-word; } +/* +FNXC:ChatMessageLayout 2026-08-18-20:27: +Full-width conversation mode is scoped to the ChatView root so normal Chat, Rooms, CLI transcripts, Quick Chat, and dock hosts share one override while the default bubble selectors remain unchanged. Stretch the message blocks, not their shared StandardChatSurface markup, so streaming, failures, tools, thinking, and attachments retain their existing semantics. +*/ +.chat-view--full-width .chat-message, +.chat-view--full-width .chat-message--user, +.chat-view--full-width .chat-message--assistant, +.chat-view--full-width .chat-message--streaming, +.chat-view--full-width .chat-message--failure { + width: 100%; + max-width: 100%; + align-self: stretch; +} + +/* Keep the selected mode explicit in narrow hosts; container/mobile rules must not reintroduce side alignment or a cap. */ +@media (max-width: 768px) { + .chat-view--full-width .chat-message { + width: 100%; + max-width: 100%; + align-self: stretch; + } +} + /* FNXC:ChatView 2026-07-22-00:00: The Latest button is centered with transform: translateX(-50%), so the global .btn:active { transform: scale(0.97) } rule must not replace that transform wholesale — doing so shifted the button half its width sideways on mousedown, out from under the cursor mid-click. Compose both transforms on :active instead (same fix as .devserver-log-viewer__new-logs-button). diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 650588aea1..528b4064cb 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -63,6 +63,7 @@ import { } from "./StandardChatSurface"; import { buildChatReportHandoff, type ChatReportHandoff } from "./chatReportHandoff"; import { CHAT_COMMANDS, matchChatCommand, filterChatCommands, getSlashTriggerMatch, type ChatCommand } from "./chat-commands"; +import { useChatMessageLayout } from "../context/ChatMessageLayoutContext"; /** * Optional task-bound context that enables the "/" command registry (e.g. @@ -563,6 +564,7 @@ interface RoomContext { export function ChatView({ projectId, addToast, floating = false, compactLayout = false, onPopOut, onMaximize, onMinimize, onClose, chatCommandContext, initialComposerDraft, initialComposerDraftNonce, onSendAsReport }: ChatViewProps) { const { t } = useTranslation("app"); + const chatMessageLayout = useChatMessageLayout(); useEffect(() => { recordResumeEvent({ view: "ChatView", @@ -3395,7 +3397,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout FNXC:Chat 2026-06-22-12:55: Chat uses the shared ViewHeader so its page chrome matches the other main-content views. The height-sensitive two-pane chat layout remains isolated in .chat-view__body beneath that header, preserving sidebar resize, thread scrolling, and mobile keyboard compensation while moving the desktop New Chat action into the canonical header actions cluster. */ -
+
) => void; /** Mirrors pending Quick Chat launcher changes into the app shell immediately. */ onQuickChatButtonModeChange?: (mode: "floating" | "footer" | "off") => void; + /** Mirrors the pending project conversation layout into mounted chat surfaces immediately. */ + chatMessageLayout?: ChatMessageLayout; + onChatMessageLayoutChange?: (layout: ChatMessageLayout) => void; /** Mirrors pending mobile quick-action changes into the app shell immediately. */ onMobileNavPrimaryItemsChange?: (items: string[]) => void; /** Optional callback when user wants to reopen the onboarding guide */ @@ -926,6 +930,8 @@ export function SettingsModal({ onDashboardFontScaleChange, onShadcnCustomColorsChange, onQuickChatButtonModeChange, + chatMessageLayout = "bubbles", + onChatMessageLayoutChange, onMobileNavPrimaryItemsChange, onReopenOnboarding, onOpenApprovals, @@ -1009,6 +1015,7 @@ export function SettingsModal({ taskPopupsBoardListOnly: true, showCostBadgeOnCards: false, taskDetailChatFirst: false, + chatMessageLayout: "bubbles", executorAllowSiblingBranchRename: false, worktreeNaming: "random", worktreeCopyFiles: [], @@ -1663,6 +1670,11 @@ export function SettingsModal({ */ taskDetailChatFirst: s.taskDetailChatFirst === true, /* + FNXC:ChatMessageLayout 2026-08-18-20:27: + Normalize legacy or malformed project values before they enter the form so the selector always has exactly its two valid choices and defaults to Bubbles. + */ + chatMessageLayout: normalizeChatMessageLayout(s.chatMessageLayout), + /* FNXC:GithubImportTracking 2026-07-01-00:00: Missing githubLinkImportedIssuesToTracking must render as unchecked and save as project-scoped false only after operator interaction; this keeps upgraded projects on legacy import behavior by default. */ @@ -4200,6 +4212,8 @@ export function SettingsModal({ onColorThemeChange={onColorThemeChange} onDashboardFontScaleChange={onDashboardFontScaleChange} onShadcnCustomColorsChange={onShadcnCustomColorsChange} + chatMessageLayout={chatMessageLayout} + onChatMessageLayoutChange={onChatMessageLayoutChange} sessionBannersHidden={sessionBannersHidden} setSessionBannersHidden={setSessionBannersHidden} /> diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css index 59722f36c2..d31c44329a 100644 --- a/packages/dashboard/app/components/TaskChatTab.css +++ b/packages/dashboard/app/components/TaskChatTab.css @@ -699,3 +699,45 @@ FN-6660 corrects the repeated mobile sizing misses from FN-6507, FN-6604, and FN min-block-size: calc(var(--space-2xl) + var(--space-lg)); } } + +/* +FNXC:ChatMessageLayout 2026-08-18-20:27: +Activity Live has an agent-header grid column and a right-aligned user group in its default transcript. Full-width mode deliberately stacks the header above one stretched transcript column, including user, status/gap, tool, and thinking blocks, while the context-free/default path keeps the existing task-chat bubbles. +*/ +.task-chat-tab--full-width .task-chat-group { + grid-template-columns: 1fr; +} + +.task-chat-tab--full-width .task-chat-group-header { + min-width: 0; +} + +.task-chat-tab--full-width .task-chat-group-bubbles, +.task-chat-tab--full-width .task-chat-user-group { + align-items: stretch; +} + +.task-chat-tab--full-width .task-chat-user-header { + align-self: flex-start; + justify-content: flex-start; + padding-inline: 0; +} + +.task-chat-tab--full-width .task-chat-entry, +.task-chat-tab--full-width .task-chat-tool-group, +.task-chat-tab--full-width .task-chat-thinking, +.task-chat-tab--full-width .task-chat-tool-entry { + width: 100%; + max-width: 100%; +} + +@media (max-width: 768px) { + .task-chat-tab--full-width .task-chat-group, + .task-chat-tab--full-width .task-chat-entry, + .task-chat-tab--full-width .task-chat-tool-group, + .task-chat-tab--full-width .task-chat-thinking, + .task-chat-tab--full-width .task-chat-tool-entry { + width: 100%; + max-width: 100%; + } +} diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 71b1ac4bc2..606e1b6fbd 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -23,6 +23,7 @@ import { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatIn import { formatAgentLogTimingLabels, markdownComponents } from "./AgentLogViewer"; import { ToolCallDetails } from "./ToolCallDetails"; import { parseRuntimeModelMarker } from "./effective-model-resolution"; +import { useChatMessageLayout } from "../context/ChatMessageLayoutContext"; import "./TaskChatTab.css"; interface TaskChatTabProps { @@ -663,6 +664,7 @@ function TaskChatUserMessage({ message }: { message: UserChatMessage }) { export function TaskChatTab({ task, columnFlags, projectId, active, addToast, onTaskUpdated, expanded = false, onToggleExpanded, effectiveModels }: TaskChatTabProps) { const { t } = useTranslation("app"); + const chatMessageLayout = useChatMessageLayout(); const { entries, loading, loadMore, hasMore, loadingMore } = useAgentLogs(task.id, active, projectId); const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); @@ -1014,7 +1016,7 @@ export function TaskChatTab({ task, columnFlags, projectId, active, addToast, on }, [canSend]); return ( -
+
{onToggleExpanded ? (