FN-011: add project chat message width preference

Add a project-scoped conversation layout preference and apply full-width rendering consistently across dashboard chat surfaces.

- Add bubbles/full-width project settings with localized Appearance controls.
- Apply the preference to Chat, Activity, Planner Chat, Quick Chat, and docked hosts with responsive safeguards.
- Add regression coverage, documentation, and the published-package changeset.

Files changed:
 .changeset/fn-011-chat-message-layout.md           |  7 ++++
 docs/dashboard-guide.md                            |  4 ++
 .../core/src/__tests__/settings-defaults.test.ts   | 14 ++++++-
 .../core/src/__tests__/settings-parity.test.ts     |  4 ++
 packages/core/src/config/settings-schema.ts        |  5 +++
 packages/core/src/types/settings/settings-scope.ts |  5 +++
 packages/dashboard/app/App.tsx                     |  7 +++-
 packages/dashboard/app/components/AppModals.tsx    |  5 +++
 packages/dashboard/app/components/ChatView.css     | 23 +++++++++++
 packages/dashboard/app/components/ChatView.tsx     |  4 +-
 .../dashboard/app/components/SettingsModal.tsx     | 14 +++++++
 packages/dashboard/app/components/TaskChatTab.css  | 42 +++++++++++++++++++++
 packages/dashboard/app/components/TaskChatTab.tsx  |  4 +-
 .../app/components/TaskPlannerChatTab.css          | 22 +++++++++++
 .../app/components/TaskPlannerChatTab.tsx          |  4 +-
 .../components/__tests__/ChatView.core.test.tsx    | 34 +++++++++++++++++
 .../__tests__/SettingsModal.general.test.tsx       | 42 +++++++++++++++++++++
 .../app/components/__tests__/TaskChatTab.test.tsx  | 31 +++++++++++++++
 .../__tests__/TaskPlannerChatTab.test.tsx          | 32 ++++++++++++++++
 .../settings/sections/AppearanceSection.tsx        | 24 +++++++++++-
 .../sections/__tests__/AppearanceSection.test.tsx  | 22 ++++++++++-
 .../app/context/ChatMessageLayoutContext.tsx       | 16 ++++++++
 .../app/hooks/__tests__/useAppSettings.test.ts     | 44 ++++++++++++++++++++++
 packages/dashboard/app/hooks/useAppSettings.ts     | 29 ++++++++++++++
 .../app/task-modal-touch-resize-e2e-fixture.tsx    |  2 +-
 packages/i18n/locales/en/app.json                  |  6 ++-
 packages/i18n/locales/es/app.json                  |  6 ++-
 packages/i18n/locales/fr/app.json                  |  6 ++-
 packages/i18n/locales/ko/app.json                  |  6 ++-
 packages/i18n/locales/pt-BR/app.json               |  6 ++-
 packages/i18n/locales/zh-CN/app.json               |  6 ++-
 packages/i18n/locales/zh-TW/app.json               |  6 ++-
 packages/i18n/src/resources.d.ts                   |  4 ++
 33 files changed, 471 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-011
Fusion-Task-Lineage: d858d00f-e05b-4dc2-8ddb-cfc5b9973871
Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-18 21:48:51 +00:00
parent d295202768
commit 3ae22c23d1
33 changed files with 471 additions and 15 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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);

View File

@@ -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);

View File

@@ -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.
*/

View File

@@ -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.

View File

@@ -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 (
<ConfirmDialogProvider skipConfirmations={skipConfirmationDialogs}>
<ChatMessageLayoutProvider value={chatMessageLayout}>
<ModalDismissPreferenceProvider enabled={dismissModalsOnOutsideClick}>
<QuickAddSubmitOnEnterProvider enabled={quickAddSubmitOnEnter}>
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
@@ -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() {
</NavigationHistoryProvider>
</QuickAddSubmitOnEnterProvider>
</ModalDismissPreferenceProvider>
</ChatMessageLayoutProvider>
</ConfirmDialogProvider>
);
}

View File

@@ -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<string, string>) => 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}

View File

@@ -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).

View File

@@ -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.
*/
<div ref={chatViewRef} className={`chat-view${floating ? " chat-view--floating" : ""}${isChatMobile ? " chat-view--narrow" : ""}${showMobileDirectThreadHeaderControls ? " chat-view--mobile-direct-thread" : ""}`}>
<div ref={chatViewRef} className={`chat-view${floating ? " chat-view--floating" : ""}${isChatMobile ? " chat-view--narrow" : ""}${showMobileDirectThreadHeaderControls ? " chat-view--mobile-direct-thread" : ""}${chatMessageLayout === "full-width" ? " chat-view--full-width" : ""}`}>
<ViewHeader
icon={MessageSquare}
title={t("chat.title", "Chat")}

View File

@@ -23,6 +23,7 @@ import {
type DashboardShortcutAction,
} from "../utils/keyboardShortcuts";
import type { DashboardKeyboardShortcutMap } from "../utils/keyboardShortcuts";
import { normalizeChatMessageLayout, type ChatMessageLayout } from "../hooks/useAppSettings";
import { SettingsHelpTip } from "./settings/SettingsHelpTip";
import type { SectionSaveHandler } from "./settings/sections/context";
import { AppearanceSection } from "./settings/sections/AppearanceSection";
@@ -661,6 +662,9 @@ interface SettingsModalProps {
onShadcnCustomColorsChange?: (colors: Record<string, string>) => 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}
/>

View File

@@ -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%;
}
}

View File

@@ -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 (
<div className="task-chat-tab" data-testid="task-chat-tab">
<div className={`task-chat-tab${chatMessageLayout === "full-width" ? " task-chat-tab--full-width" : ""}`} data-testid="task-chat-tab">
{onToggleExpanded ? (
<button
type="button"

View File

@@ -7,6 +7,20 @@
min-height: 0;
}
/*
FNXC:ChatMessageLayout 2026-08-18-20:27:
Planner Chat reuses StandardChatSurface message markup, so its root modifier is the only task-specific hook needed to remove the shared horizontal bubble split while preserving the planner transcript, controls, and empty/loading states.
*/
.task-planner-chat--full-width .chat-message,
.task-planner-chat--full-width .chat-message--user,
.task-planner-chat--full-width .chat-message--assistant,
.task-planner-chat--full-width .chat-message--streaming,
.task-planner-chat--full-width .chat-message--failure {
width: 100%;
max-width: 100%;
align-self: stretch;
}
/*
FNXC:TaskDetailPlannerChat 2026-07-02-00:28:
Planner Chat should not render a redundant in-panel title/header. The transcript starts at the top of the chat area, the expand button floats over the view, and the planning model/provider icon appears only as a small empty-state affordance.
@@ -312,3 +326,11 @@ height rather than relying on the global button minimum.
margin-inline: 0;
}
}
@media (max-width: 768px) {
.task-planner-chat--full-width .chat-message {
width: 100%;
max-width: 100%;
align-self: stretch;
}
}

View File

@@ -14,6 +14,7 @@ import { ChatQuestionResponse } from "./ChatQuestionResponse";
import { ProviderIcon } from "./ProviderIcon";
import { StandardChatActionButton, StandardChatMessageItem, StandardStreamingMessage, formatModelTag } from "./StandardChatSurface";
import { CHAT_COMMANDS, filterChatCommands, getSlashTriggerMatch, matchChatCommand, type ChatCommand } from "./chat-commands";
import { useChatMessageLayout } from "../context/ChatMessageLayoutContext";
import "./TaskPlannerChatTab.css";
interface TaskPlannerChatTabProps {
@@ -311,6 +312,7 @@ function buildPlannerQuestionRenderStates(messages: readonly ChatMessage[]): Map
export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expanded = false, onExpandedChange, planningModel, addToast, onTaskUpdated }: TaskPlannerChatTabProps) {
const { t } = useTranslation("app");
const chatMessageLayout = useChatMessageLayout();
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
@@ -1017,7 +1019,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
The planner Chat tab owns an in-view expand/collapse button so mobile users can reclaim vertical room while keeping close/back/task identity controls reachable. This state is independent from Activity Live expansion because Activity still represents operational steering/history, not planner-model conversation.
*/
return (
<section className="task-planner-chat" aria-label={t("taskDetail.plannerChat.label", "Planner chat")} data-testid="task-planner-chat-panel">
<section className={`task-planner-chat${chatMessageLayout === "full-width" ? " task-planner-chat--full-width" : ""}`} aria-label={t("taskDetail.plannerChat.label", "Planner chat")} data-testid="task-planner-chat-panel">
{onExpandedChange && (
<button
type="button"

View File

@@ -14,6 +14,7 @@ import type { DiscoveredSkill } from "@fusion/dashboard";
import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat";
import { loadAllAppCss } from "../../test/cssFixture";
import { FileBrowserProvider } from "../../context/FileBrowserContext";
import { ChatMessageLayoutProvider } from "../../context/ChatMessageLayoutContext";
import { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache";
import {
renderWithAct,
@@ -1214,6 +1215,39 @@ describe("ChatView", () => {
);
});
it("applies full-width layout to populated and streaming ChatView messages while bubbles stay default", async () => {
setupMockChat({
activeSession: activeSessionFixture,
messages: [
{ id: "layout-user", sessionId: "session-001", role: "user", content: "Question", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "layout-assistant", sessionId: "session-001", role: "assistant", content: "Answer", createdAt: "2026-04-08T00:00:01.000Z" },
],
isStreaming: true,
streamingText: "Live answer",
});
await renderWithAct(
<ChatMessageLayoutProvider value="full-width">
<ChatView projectId="proj-123" addToast={vi.fn()} />
</ChatMessageLayoutProvider>,
);
expect(screen.getByTestId("chat-message-layout-user")).toHaveClass("chat-message--user");
expect(screen.getByTestId("chat-message-layout-assistant")).toHaveClass("chat-message--assistant");
expect(document.querySelector(".chat-view")).toHaveClass("chat-view--full-width");
expect(document.querySelector(".chat-message--streaming")).toHaveClass("chat-message--streaming");
const css = loadAllAppCss();
expect(css).toContain(".chat-view--full-width .chat-message");
expect(css).toContain("max-width: 100%");
});
it("keeps the default bubbles modifier absent", async () => {
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.querySelector(".chat-view")).not.toHaveClass("chat-view--full-width");
});
it("shows streaming copy action for provider chats", async () => {
setupMockChat({
activeSession: {

View File

@@ -221,6 +221,48 @@ vi.mock("../FileBrowser", () => ({
}));
describe("SettingsModal", () => {
it("renders and persists the project-scoped conversation layout without a global write", async () => {
const onChatMessageLayoutChange = vi.fn();
mockFetchSettingsByScope.mockResolvedValueOnce({
global: defaultSettings,
project: {
...defaultSettings,
allowAbsoluteFileBrowserPaths: false,
directMergeCommitStrategy: "auto",
maxAutoMergeRetries: 3,
executorToolFailureRetryCount: 2,
executorToolFailureRetryBackoffMs: 2000,
executorToolFailureThreshold: 1,
executorModelEscalationEnabled: false,
executorEscalationProvider: undefined,
executorEscalationModelId: undefined,
executorEscalationNodeId: undefined,
chatMessageLayout: "bubbles",
gitlabAuthTokenType: "personal",
mergeAdvanceAutoSync: "stash-and-ff",
pushRemote: undefined,
showCostBadgeOnCards: false,
taskDetailChatFirst: false,
worktreeInitCommand: undefined,
worktreesDir: undefined,
worktrunk: { enabled: false, binaryPath: undefined, onFailure: "fail" },
},
} as never);
renderModal({ initialSection: "appearance", onChatMessageLayoutChange });
await waitForSettingsModalReady();
const selector = screen.getByLabelText("Conversation layout") as HTMLSelectElement;
expect(selector.value).toBe("bubbles");
vi.useFakeTimers();
fireEvent.change(selector, { target: { value: "full-width" } });
expect(onChatMessageLayoutChange).toHaveBeenCalledWith("full-width");
await flushSettingsAutoSave();
vi.useRealTimers();
expect(mockUpdateSettings).toHaveBeenCalledWith({ chatMessageLayout: "full-width" }, undefined);
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
});
it("renders recommendation mailbox notices enabled by default and persists disabling it", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();

View File

@@ -5,6 +5,7 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { AgentLogEntry, Task } from "@fusion/core";
import { TaskChatTab } from "../TaskChatTab";
import { ChatMessageLayoutProvider } from "../../context/ChatMessageLayoutContext";
import { isCliSessionLive, type CliSessionSummaryRecord } from "../TaskDetailModal";
import { useAgentLogs } from "../../hooks/useAgentLogs";
import { addSteeringComment, refineTask } from "../../api";
@@ -2976,6 +2977,36 @@ describe("TaskChatTab", () => {
expect(mobileGroupRule).toContain("grid-template-columns: 1fr");
});
it("applies full-width layout to Activity agent and user blocks while preserving the default modifier", () => {
mockLogs([
makeEntry({ agent: "executor", text: "streamed agent output", type: "text" }),
]);
const task = makeTask({
steeringComments: [makeSteeringComment({ id: "layout-user", text: "user steering" })],
});
render(
<ChatMessageLayoutProvider value="full-width">
<TaskChatTab task={task} active addToast={vi.fn()} />
</ChatMessageLayoutProvider>,
);
expect(screen.getByTestId("task-chat-tab")).toHaveClass("task-chat-tab--full-width");
expect(screen.getByTestId("task-chat-entry-user")).toBeInTheDocument();
expect(screen.getByTestId("task-chat-entry-text")).toBeInTheDocument();
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
expect(css).toContain(".task-chat-tab--full-width .task-chat-group");
expect(css).toContain(".task-chat-tab--full-width .task-chat-entry,");
expect(css).toContain("max-width: 100%");
});
it("keeps Activity bubbles when no full-width context is provided", () => {
mockLogs([makeEntry({ agent: "executor", text: "default output" })]);
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
expect(screen.getByTestId("task-chat-tab")).not.toHaveClass("task-chat-tab--full-width");
});
it("stacks agent headers only inside the List View split-detail chat host", () => {
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
const desktopGroupRule = getCssRuleBlock(css, ".task-chat-group");

View File

@@ -5,6 +5,7 @@ import React from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
import { ChatMessageLayoutProvider } from "../../context/ChatMessageLayoutContext";
const taskPlannerChatCss = readFileSync(resolve(__dirname, "../TaskPlannerChatTab.css"), "utf8");
const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop");
@@ -651,6 +652,37 @@ describe("TaskPlannerChatTab", () => {
expect(screen.queryByRole("button", { name: /Summarize recent activity/ })).not.toBeInTheDocument();
});
it("applies full-width layout to persisted and streaming Planner Chat messages", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
{ id: "layout-planner-user", sessionId: "chat-planner", role: "user", content: "Planner question", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" },
{ id: "layout-planner-assistant", sessionId: "chat-planner", role: "assistant", content: "Planner answer", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:02:00.000Z" },
],
});
render(
<ChatMessageLayoutProvider value="full-width">
<TaskPlannerChatTab
task={makeTask("FN-7310")}
active
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
addToast={vi.fn()}
/>
</ChatMessageLayoutProvider>,
);
expect(await screen.findByTestId("task-planner-chat-panel")).toHaveClass("task-planner-chat--full-width");
expect(screen.getByTestId("chat-message-layout-planner-user")).toBeInTheDocument();
expect(screen.getByTestId("chat-message-layout-planner-assistant")).toBeInTheDocument();
expect(taskPlannerChatCss).toContain(".task-planner-chat--full-width .chat-message");
expect(taskPlannerChatCss).toContain("max-width: 100%");
});
it("keeps Planner Chat bubbles when no full-width context is provided", async () => {
renderPlannerChat();
expect(await screen.findByTestId("task-planner-chat-panel")).not.toHaveClass("task-planner-chat--full-width");
});
it("keeps an unsnapped planner transcript in place during streamed growth", async () => {
const user = userEvent.setup();
const metrics = mockPlannerTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240 });

View File

@@ -3,7 +3,9 @@ import type { ThemeMode, ColorTheme } from "@fusion/core";
import { ThemeSelector } from "../../ThemeSelector";
import { LanguageSelector } from "../../LanguageSelector";
import { SettingsToggleRow } from "../SettingsToggleRow";
import { SettingsSelectRow } from "../SettingsSelectRow";
import type { SectionBaseProps } from "./context";
import { normalizeChatMessageLayout, type ChatMessageLayout } from "../../../hooks/useAppSettings";
export interface AppearanceSectionProps extends SectionBaseProps {
themeMode: ThemeMode;
colorTheme: ColorTheme;
@@ -14,6 +16,8 @@ export interface AppearanceSectionProps extends SectionBaseProps {
onColorThemeChange?: (theme: ColorTheme) => void;
onDashboardFontScaleChange?: (scalePct: number) => void;
onShadcnCustomColorsChange?: (colors: Record<string, string>) => void;
chatMessageLayout?: ChatMessageLayout;
onChatMessageLayoutChange?: (layout: ChatMessageLayout) => void;
sessionBannersHidden: boolean;
setSessionBannersHidden: (hidden: boolean) => void;
}
@@ -25,7 +29,7 @@ Rows render through the shared settings primitives rather than hand-rolled `form
FNXC:SettingsScope 2026-07-15-17:35:
Scope badges are per-row because this section genuinely mixes authority levels: theme, color, and font scale are global (DEFAULT_GLOBAL_SETTINGS), while every task-presentation toggle below is project-scoped (DEFAULT_PROJECT_SETTINGS). The nav labels the whole section "global", which is true only of the theme controls, so the badges are what tell an operator which of these travels between projects.
*/
export function AppearanceSection({ form, setForm, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors = {}, resolvedThemeMode, onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange, sessionBannersHidden, setSessionBannersHidden, }: AppearanceSectionProps) {
export function AppearanceSection({ form, setForm, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors = {}, resolvedThemeMode, onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange, chatMessageLayout = "bubbles", onChatMessageLayoutChange, sessionBannersHidden, setSessionBannersHidden, }: AppearanceSectionProps) {
const { t } = useTranslation("app");
return (<>
<h4 className="settings-section-heading">{t("settings.appearance.title", "Appearance")}</h4>
@@ -43,6 +47,24 @@ export function AppearanceSection({ form, setForm, themeMode, colorTheme, dashbo
onShadcnCustomColorsChange?.(colors);
}}/>
<LanguageSelector />
<SettingsSelectRow
descriptor={{
key: "chatMessageLayout",
label: t("settings.appearance.chatMessageLayout", "Conversation layout"),
help: t("settings.appearance.chatMessageLayoutHelp", "Choose Bubbles or Full width for normal Chat, Quick Chat, dock Chat, task Activity, and Planner Chat. Project-scoped; default: Bubbles."),
scope: "project",
options: [
{ value: "bubbles", label: t("settings.appearance.chatMessageLayoutBubbles", "Bubbles") },
{ value: "full-width", label: t("settings.appearance.chatMessageLayoutFullWidth", "Full width") },
],
}}
value={normalizeChatMessageLayout(form.chatMessageLayout ?? chatMessageLayout)}
onChange={(value) => {
const nextLayout = normalizeChatMessageLayout(value);
setForm((f) => ({ ...f, chatMessageLayout: nextLayout }));
onChatMessageLayoutChange?.(nextLayout);
}}
/>
<SettingsToggleRow
descriptor={{
key: "openTasksInRightSidebar",

View File

@@ -12,7 +12,7 @@ vi.mock("../../LanguageSelector", () => ({
LanguageSelector: () => <div data-testid="language-selector" />,
}));
function renderAppearanceSection(formOverrides: Partial<Settings> = {}) {
function renderAppearanceSection(formOverrides: Partial<Settings> = {}, onChatMessageLayoutChange = vi.fn()) {
let form: SettingsFormState = {
maxConcurrent: 2,
maxWorktrees: 4,
@@ -24,6 +24,7 @@ function renderAppearanceSection(formOverrides: Partial<Settings> = {}) {
taskPopupsBoardListOnly: true,
showCostBadgeOnCards: false,
taskDetailChatFirst: false,
chatMessageLayout: "bubbles",
...formOverrides,
} as SettingsFormState;
const setForm = vi.fn((updater: SettingsFormState | ((previous: SettingsFormState) => SettingsFormState)) => {
@@ -37,6 +38,8 @@ function renderAppearanceSection(formOverrides: Partial<Settings> = {}) {
themeMode="dark"
colorTheme="ocean"
dashboardFontScalePct={100}
chatMessageLayout={form.chatMessageLayout}
onChatMessageLayoutChange={onChatMessageLayoutChange}
sessionBannersHidden={false}
setSessionBannersHidden={vi.fn()}
/>,
@@ -46,6 +49,23 @@ function renderAppearanceSection(formOverrides: Partial<Settings> = {}) {
}
describe("AppearanceSection", () => {
it("renders the two-option conversation layout selector and updates full width", () => {
const onChatMessageLayoutChange = vi.fn();
const { setForm, getForm } = renderAppearanceSection({}, onChatMessageLayoutChange);
const selector = screen.getByLabelText("Conversation layout") as HTMLSelectElement;
expect(selector.value).toBe("bubbles");
expect(screen.getAllByRole("option")).toHaveLength(2);
fireEvent.change(selector, { target: { value: "full-width" } });
expect(onChatMessageLayoutChange).toHaveBeenCalledWith("full-width");
expect(setForm).toHaveBeenCalledTimes(1);
expect(getForm().chatMessageLayout).toBe("full-width");
});
it("selects a persisted full-width conversation layout", () => {
renderAppearanceSection({ chatMessageLayout: "full-width" });
expect((screen.getByLabelText("Conversation layout") as HTMLSelectElement).value).toBe("full-width");
});
it("renders and updates the open-tasks-in-right-sidebar checkbox", () => {
const { setForm, getForm } = renderAppearanceSection();

View File

@@ -0,0 +1,16 @@
import { createContext, useContext, type ReactNode } from "react";
import type { ChatMessageLayout } from "../hooks/useAppSettings";
/**
* FNXC:ChatMessageLayout 2026-08-18-20:27:
* One App-level context keeps the project-scoped choice consistent across normal Chat, Quick Chat, dock hosts, task Activity, and Planner Chat without duplicating layout props through each host.
*/
const ChatMessageLayoutContext = createContext<ChatMessageLayout>("bubbles");
export function ChatMessageLayoutProvider({ value, children }: { value: ChatMessageLayout; children: ReactNode }) {
return <ChatMessageLayoutContext.Provider value={value}>{children}</ChatMessageLayoutContext.Provider>;
}
export function useChatMessageLayout(): ChatMessageLayout {
return useContext(ChatMessageLayoutContext);
}

View File

@@ -46,6 +46,50 @@ describe("useAppSettings", () => {
mockUpdateSettings.mockResolvedValue({} as never);
});
it.each([
["full-width", "full-width"],
[undefined, "bubbles"],
["legacy-value", "bubbles"],
] as const)("normalizes persisted chat message layout %s to %s", async (storedLayout, expectedLayout) => {
mockFetchSettings.mockResolvedValueOnce({ chatMessageLayout: storedLayout } as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => expect(result.current.settingsLoaded).toBe(true));
expect(result.current.chatMessageLayout).toBe(expectedLayout);
});
it("resets chat message layout to bubbles while a new project hydrates", async () => {
mockFetchSettings
.mockResolvedValueOnce({ chatMessageLayout: "full-width" } as never)
.mockResolvedValueOnce({ chatMessageLayout: undefined } as never);
const { result, rerender } = renderHook(({ projectId }) => useAppSettings(projectId), { initialProps: { projectId: "project-a" } });
await waitFor(() => expect(result.current.chatMessageLayout).toBe("full-width"));
rerender({ projectId: "project-b" });
expect(result.current.chatMessageLayout).toBe("bubbles");
await waitFor(() => expect(result.current.settingsLoaded).toBe(true));
expect(result.current.chatMessageLayout).toBe("bubbles");
});
it("does not apply a previous project's late layout response", async () => {
let resolvePreviousProjectSettings!: (value: never) => void;
mockFetchSettings
.mockImplementationOnce(() => new Promise((resolve) => { resolvePreviousProjectSettings = resolve as (value: never) => void; }))
.mockResolvedValueOnce({ chatMessageLayout: "bubbles" } as never);
const { result, rerender } = renderHook(({ projectId }) => useAppSettings(projectId), { initialProps: { projectId: "project-a" } });
rerender({ projectId: "project-b" });
await waitFor(() => expect(result.current.settingsLoaded).toBe(true));
expect(result.current.chatMessageLayout).toBe("bubbles");
await act(async () => {
resolvePreviousProjectSettings({ chatMessageLayout: "full-width" } as never);
});
expect(result.current.chatMessageLayout).toBe("bubbles");
});
it("defaults omitted task popup scoping to enabled during hydration", async () => {
const { result } = renderHook(() => useAppSettings("proj_123"));

View File

@@ -7,8 +7,17 @@ import { setAutoReloadEnabled } from "../versionCheck";
import { DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS, resolveDashboardKeyboardShortcuts, type DashboardKeyboardShortcutMap } from "../utils/keyboardShortcuts";
export type QuickChatButtonMode = "floating" | "footer" | "off";
export type ChatMessageLayout = "bubbles" | "full-width";
export type PlanApprovalMode = NonNullable<ProjectSettings["planApprovalMode"]>;
/**
* FNXC:ChatMessageLayout 2026-08-18-20:27:
* Settings can come from older project files or external writers, so the app shell accepts only the two persisted layout values and fails closed to the historical bubble presentation.
*/
export function normalizeChatMessageLayout(value: unknown): ChatMessageLayout {
return value === "full-width" ? "full-width" : "bubbles";
}
/**
* Settings state and actions consumed by the dashboard App shell.
*/
@@ -34,6 +43,7 @@ export interface UseAppSettingsResult {
showCostBadgeOnCards: boolean;
modelPricingOverrides?: ModelPricingOverrides;
taskDetailChatFirst: boolean;
chatMessageLayout: ChatMessageLayout;
quickChatButtonMode: QuickChatButtonMode;
mobileNavPrimaryItems: string[];
quickChatCloseOnOutsideClick: boolean;
@@ -57,6 +67,7 @@ export interface UseAppSettingsResult {
toggleEnginePause: () => Promise<void>;
toggleShowQuickChatFAB: () => Promise<void>;
setQuickChatButtonModeImmediate: (mode: QuickChatButtonMode) => void;
setChatMessageLayoutImmediate: (layout: ChatMessageLayout) => void;
setMobileNavPrimaryItemsImmediate: (items: string[]) => void;
toggleAutoReloadOnVersionChange: () => Promise<void>;
/** Re-fetches settings from the backend to pick up changes made externally (e.g., by SettingsModal). */
@@ -95,6 +106,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [showCostBadgeOnCards, setShowCostBadgeOnCards] = useState(false);
const [modelPricingOverrides, setModelPricingOverrides] = useState<ModelPricingOverrides | undefined>(undefined);
const [taskDetailChatFirst, setTaskDetailChatFirst] = useState(false);
const [chatMessageLayout, setChatMessageLayout] = useState<ChatMessageLayout>("bubbles");
const [quickChatButtonMode, setQuickChatButtonMode] = useState<QuickChatButtonMode>("off");
const [mobileNavPrimaryItems, setMobileNavPrimaryItems] = useState<string[]>(() => resolveMobileNavPrimaryItems().primaryItems);
const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true);
@@ -114,6 +126,9 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [autoReloadOnVersionChange, setAutoReloadOnVersionChangeState] = useState(true);
const autoMergeRef = useRef(autoMerge);
const planApprovalModeRef = useRef<PlanApprovalMode>(planApprovalMode);
const settingsProjectIdRef = useRef(projectId);
// FNXC:ChatMessageLayout 2026-08-18-21:02: Ignore a prior project's late settings response so its project-scoped conversation layout cannot overwrite the active project's bubbles/full-width choice.
settingsProjectIdRef.current = projectId;
/**
* Fetches config and settings from the backend and updates local state.
@@ -125,6 +140,8 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
fetchSettings(projectId),
]);
if (settingsProjectIdRef.current !== projectId) return;
if (configResult.status === "fulfilled") {
setMaxConcurrent(configResult.value.maxConcurrent);
setRootDir(configResult.value.rootDir);
@@ -189,6 +206,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
App-level task-detail hosts need the project setting so Activity-first is the missing/false default and Chat-first is restored only by explicit opt-in.
*/
setTaskDetailChatFirst(settings.taskDetailChatFirst === true);
setChatMessageLayout(normalizeChatMessageLayout(settings.chatMessageLayout));
setExperimentalFeatures(settings.experimentalFeatures ?? {});
const features = settings.experimentalFeatures ?? {};
/*
@@ -219,6 +237,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setShowCostBadgeOnCards(false);
setModelPricingOverrides(undefined);
setTaskDetailChatFirst(false);
setChatMessageLayout("bubbles");
setQuickChatCloseOnOutsideClick(true);
setDashboardKeyboardShortcuts(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS);
setDismissModalsOnOutsideClick(false);
@@ -317,6 +336,14 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setShowQuickChatFAB(mode === "floating");
}, []);
const setChatMessageLayoutImmediate = useCallback((layout: ChatMessageLayout) => {
/*
FNXC:ChatMessageLayout 2026-08-18-20:27:
The Appearance draft must update already-mounted ChatView and task-chat surfaces immediately; SettingsModal remains the sole persistence owner.
*/
setChatMessageLayout(normalizeChatMessageLayout(layout));
}, []);
/*
FNXC:Navigation 2026-07-17-00:00:
The settings draft previews mobile quick-action order and membership in the app shell before Save;
@@ -361,6 +388,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
showCostBadgeOnCards,
modelPricingOverrides,
taskDetailChatFirst,
chatMessageLayout,
quickChatButtonMode,
mobileNavPrimaryItems,
quickChatCloseOnOutsideClick,
@@ -384,6 +412,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
toggleEnginePause,
toggleShowQuickChatFAB,
setQuickChatButtonModeImmediate,
setChatMessageLayoutImmediate,
setMobileNavPrimaryItemsImmediate,
toggleAutoReloadOnVersionChange,
refresh,

View File

@@ -151,7 +151,7 @@ function TaskDetailTitleModalHarness() {
openNewTaskWithDescription: noop,
openWorkflowEditor: noop,
};
return <div data-testid="title-host-modal"><NavigationHistoryProvider value={{ pushNav: noop, replaceCurrent: noop, removeNav: noop }}><AppModals projectId="fixture" tasks={[fixtureTask]} projects={[]} currentProject={null} addToast={noop} toasts={[]} removeToast={noop} modalManager={modalManager as never} projectActions={{} as never} taskHandlers={{} as never} taskOperations={{ moveTask: asyncTask, deleteTask: asyncTask, mergeTask: asyncMerge, archiveTask: asyncTask, retryTask: asyncTask, pauseTask: asyncTask, unpauseTask: asyncTask, resetTask: asyncTask, duplicateTask: asyncTask }} deepLink={{ handleDetailClose: noop }} settings={{ prAuthAvailable: false, autoMerge: true, taskDetailChatFirst: false, themeMode: "system", colorTheme: "default", dashboardFontScalePct: 100, shadcnCustomColors: {}, resolvedThemeMode: "light", setThemeMode: noop, setColorTheme: noop, setDashboardFontScalePct: noop, setShadcnCustomColors: noop, setQuickChatButtonModeImmediate: noop, setMobileNavPrimaryItemsImmediate: noop }} /></NavigationHistoryProvider></div>;
return <div data-testid="title-host-modal"><NavigationHistoryProvider value={{ pushNav: noop, replaceCurrent: noop, removeNav: noop }}><AppModals projectId="fixture" tasks={[fixtureTask]} projects={[]} currentProject={null} addToast={noop} toasts={[]} removeToast={noop} modalManager={modalManager as never} projectActions={{} as never} taskHandlers={{} as never} taskOperations={{ moveTask: asyncTask, deleteTask: asyncTask, mergeTask: asyncMerge, archiveTask: asyncTask, retryTask: asyncTask, pauseTask: asyncTask, unpauseTask: asyncTask, resetTask: asyncTask, duplicateTask: asyncTask }} deepLink={{ handleDetailClose: noop }} settings={{ prAuthAvailable: false, autoMerge: true, taskDetailChatFirst: false, chatMessageLayout: "bubbles", themeMode: "system", colorTheme: "default", dashboardFontScalePct: 100, shadcnCustomColors: {}, resolvedThemeMode: "light", setThemeMode: noop, setColorTheme: noop, setDashboardFontScalePct: noop, setShadcnCustomColors: noop, setQuickChatButtonModeImmediate: noop, setChatMessageLayoutImmediate: noop, setMobileNavPrimaryItemsImmediate: noop }} /></NavigationHistoryProvider></div>;
}
function TaskDetailTitleMainPanelHarness() {

View File

@@ -5841,7 +5841,11 @@
"taskPopupsBoardListOnlyHelp": "When enabled, each open task-detail popup appears only on the view where it was opened. Switching views hides it without closing; returning restores it in the same position. Default: enabled.",
"taskDetailChatFirstHelp": "Off by default: task details list Activity first and omitted non-done opens land on Activity. Turn on to restore Chat-first order/default; explicit Chat links still work either way.",
"showCostBadgeOnCards": "Show cost badges on task cards",
"showCostBadgeOnCardsHelp": "Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge."
"showCostBadgeOnCardsHelp": "Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge.",
"chatMessageLayout": "Conversation layout",
"chatMessageLayoutBubbles": "Bubbles",
"chatMessageLayoutFullWidth": "Full width",
"chatMessageLayoutHelp": "Choose Bubbles or Full width for normal Chat, Quick Chat, dock Chat, task Activity, and Planner Chat. Project-scoped; default: Bubbles."
},
"auth": {
"apiKeyCleared": "API key cleared",

View File

@@ -5831,7 +5831,11 @@
"taskPopupsBoardListOnlyHelp": "",
"taskDetailChatFirstHelp": "",
"showCostBadgeOnCards": "",
"showCostBadgeOnCardsHelp": ""
"showCostBadgeOnCardsHelp": "",
"chatMessageLayout": "",
"chatMessageLayoutBubbles": "",
"chatMessageLayoutFullWidth": "",
"chatMessageLayoutHelp": ""
},
"auth": {
"apiKeyCleared": "Clave API borrada",

View File

@@ -5831,7 +5831,11 @@
"taskPopupsBoardListOnlyHelp": "",
"taskDetailChatFirstHelp": "",
"showCostBadgeOnCards": "",
"showCostBadgeOnCardsHelp": ""
"showCostBadgeOnCardsHelp": "",
"chatMessageLayout": "",
"chatMessageLayoutBubbles": "",
"chatMessageLayoutFullWidth": "",
"chatMessageLayoutHelp": ""
},
"auth": {
"apiKeyCleared": "Clé API effacée",

View File

@@ -5831,7 +5831,11 @@
"taskPopupsBoardListOnlyHelp": "",
"taskDetailChatFirstHelp": "",
"showCostBadgeOnCards": "",
"showCostBadgeOnCardsHelp": ""
"showCostBadgeOnCardsHelp": "",
"chatMessageLayout": "",
"chatMessageLayoutBubbles": "",
"chatMessageLayoutFullWidth": "",
"chatMessageLayoutHelp": ""
},
"auth": {
"apiKeyCleared": "API 키가 지워졌습니다",

View File

@@ -7110,7 +7110,11 @@
"taskPopupsBoardListOnlyHelp": "Quando ativado, cada popup de detalhes de tarefa aberto aparece somente na visualização em que foi aberto. Trocar de visualização o oculta sem fechá-lo; voltar o restaura na mesma posição. Padrão: ativado.",
"taskDetailChatFirstHelp": "Desativado por padrão: os detalhes da tarefa listam Atividade primeiro, e aberturas de tarefas não concluídas sem aba especificada caem em Atividade. Ative para restaurar a ordem/padrão com Chat primeiro; links diretos para o Chat continuam funcionando de qualquer forma.",
"showCostBadgeOnCards": "Mostrar selos de custo nos cartões de tarefa",
"showCostBadgeOnCardsHelp": "Padrão: desativado. Quando ativado, os cartões do quadro mostram o custo do modelo derivado ao lado do tempo de execução; quando o preço não está disponível, isso é indicado — e tarefas sem uso de tokens não mostram selo."
"showCostBadgeOnCardsHelp": "Padrão: desativado. Quando ativado, os cartões do quadro mostram o custo do modelo derivado ao lado do tempo de execução; quando o preço não está disponível, isso é indicado — e tarefas sem uso de tokens não mostram selo.",
"chatMessageLayout": "",
"chatMessageLayoutBubbles": "",
"chatMessageLayoutFullWidth": "",
"chatMessageLayoutHelp": ""
},
"search": {
"allSections": "Mostrando todas as seções de configurações",

View File

@@ -5831,7 +5831,11 @@
"taskPopupsBoardListOnlyHelp": "",
"taskDetailChatFirstHelp": "",
"showCostBadgeOnCards": "",
"showCostBadgeOnCardsHelp": ""
"showCostBadgeOnCardsHelp": "",
"chatMessageLayout": "",
"chatMessageLayoutBubbles": "",
"chatMessageLayoutFullWidth": "",
"chatMessageLayoutHelp": ""
},
"auth": {
"apiKeyCleared": "API 密钥已清除",

View File

@@ -5831,7 +5831,11 @@
"taskPopupsBoardListOnlyHelp": "",
"taskDetailChatFirstHelp": "",
"showCostBadgeOnCards": "",
"showCostBadgeOnCardsHelp": ""
"showCostBadgeOnCardsHelp": "",
"chatMessageLayout": "",
"chatMessageLayoutBubbles": "",
"chatMessageLayoutFullWidth": "",
"chatMessageLayoutHelp": ""
},
"auth": {
"apiKeyCleared": "API 金鑰已清除",

View File

@@ -5954,6 +5954,10 @@ export default interface Resources {
"perAgentSettingsOverrideProjectDefaultsEachCategory": "Project defaults apply to permanent agents, ephemeral task workers, and fallback executor workers unless a per-agent override is set. Exact tool rules compose with the legacy ephemeral create-task toggle. Default: unset — every action category defaults to allow until a category is explicitly restricted."
},
"appearance": {
"chatMessageLayout": "Conversation layout",
"chatMessageLayoutBubbles": "Bubbles",
"chatMessageLayoutFullWidth": "Full width",
"chatMessageLayoutHelp": "Choose Bubbles or Full width for normal Chat, Quick Chat, dock Chat, task Activity, and Planner Chat. Project-scoped; default: Bubbles.",
"hideAISessionNotificationBanners": "Hide AI session notification banners",
"language": "Language",
"languageAuto": "Auto",