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:
7
.changeset/fn-011-chat-message-layout.md
Normal file
7
.changeset/fn-011-chat-message-layout.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
16
packages/dashboard/app/context/ChatMessageLayoutContext.tsx
Normal file
16
packages/dashboard/app/context/ChatMessageLayoutContext.tsx
Normal 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);
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5831,7 +5831,11 @@
|
||||
"taskPopupsBoardListOnlyHelp": "",
|
||||
"taskDetailChatFirstHelp": "",
|
||||
"showCostBadgeOnCards": "",
|
||||
"showCostBadgeOnCardsHelp": ""
|
||||
"showCostBadgeOnCardsHelp": "",
|
||||
"chatMessageLayout": "",
|
||||
"chatMessageLayoutBubbles": "",
|
||||
"chatMessageLayoutFullWidth": "",
|
||||
"chatMessageLayoutHelp": ""
|
||||
},
|
||||
"auth": {
|
||||
"apiKeyCleared": "Clave API borrada",
|
||||
|
||||
@@ -5831,7 +5831,11 @@
|
||||
"taskPopupsBoardListOnlyHelp": "",
|
||||
"taskDetailChatFirstHelp": "",
|
||||
"showCostBadgeOnCards": "",
|
||||
"showCostBadgeOnCardsHelp": ""
|
||||
"showCostBadgeOnCardsHelp": "",
|
||||
"chatMessageLayout": "",
|
||||
"chatMessageLayoutBubbles": "",
|
||||
"chatMessageLayoutFullWidth": "",
|
||||
"chatMessageLayoutHelp": ""
|
||||
},
|
||||
"auth": {
|
||||
"apiKeyCleared": "Clé API effacée",
|
||||
|
||||
@@ -5831,7 +5831,11 @@
|
||||
"taskPopupsBoardListOnlyHelp": "",
|
||||
"taskDetailChatFirstHelp": "",
|
||||
"showCostBadgeOnCards": "",
|
||||
"showCostBadgeOnCardsHelp": ""
|
||||
"showCostBadgeOnCardsHelp": "",
|
||||
"chatMessageLayout": "",
|
||||
"chatMessageLayoutBubbles": "",
|
||||
"chatMessageLayoutFullWidth": "",
|
||||
"chatMessageLayoutHelp": ""
|
||||
},
|
||||
"auth": {
|
||||
"apiKeyCleared": "API 키가 지워졌습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5831,7 +5831,11 @@
|
||||
"taskPopupsBoardListOnlyHelp": "",
|
||||
"taskDetailChatFirstHelp": "",
|
||||
"showCostBadgeOnCards": "",
|
||||
"showCostBadgeOnCardsHelp": ""
|
||||
"showCostBadgeOnCardsHelp": "",
|
||||
"chatMessageLayout": "",
|
||||
"chatMessageLayoutBubbles": "",
|
||||
"chatMessageLayoutFullWidth": "",
|
||||
"chatMessageLayoutHelp": ""
|
||||
},
|
||||
"auth": {
|
||||
"apiKeyCleared": "API 密钥已清除",
|
||||
|
||||
@@ -5831,7 +5831,11 @@
|
||||
"taskPopupsBoardListOnlyHelp": "",
|
||||
"taskDetailChatFirstHelp": "",
|
||||
"showCostBadgeOnCards": "",
|
||||
"showCostBadgeOnCardsHelp": ""
|
||||
"showCostBadgeOnCardsHelp": "",
|
||||
"chatMessageLayout": "",
|
||||
"chatMessageLayoutBubbles": "",
|
||||
"chatMessageLayoutFullWidth": "",
|
||||
"chatMessageLayoutHelp": ""
|
||||
},
|
||||
"auth": {
|
||||
"apiKeyCleared": "API 金鑰已清除",
|
||||
|
||||
4
packages/i18n/src/resources.d.ts
vendored
4
packages/i18n/src/resources.d.ts
vendored
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user