diff --git a/.changeset/fn-9111-quick-add-enter.md b/.changeset/fn-9111-quick-add-enter.md new file mode 100644 index 0000000000..0ff443d073 --- /dev/null +++ b/.changeset/fn-9111-quick-add-enter.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a global Quick Add preference for Enter-to-save behavior. +category: feature +dev: Configure `quickAddSubmitOnEnter` in Settings → Global → General. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 6c3fc1357d..367a395d36 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -643,6 +643,8 @@ Optional workflow steps declared by the active workflow are available from the q FNXC:PriorityColorCoding 2026-07-11-00:00: Priority glyphs share urgency colors across Quick Add, the New Task inline row, and task-card badges: low=info/blue, normal=muted, high=warning/amber, urgent=error/red. --> Quick Add and Inline Create model selection include Plan, Executor, Reviewer, and Merger lanes. Each lane can inherit its default or select a task-specific model; Plan, Reviewer, and Merger also provide independent thinking-level overrides. +Quick Add saves with Enter by default. Operators who prefer multi-line descriptions can disable **Settings → Global → General → Press Enter to save a task in Quick Add**; plain Enter then inserts a newline, while Cmd/Ctrl+Enter saves. + diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 5b90697ae1..ce7d6980a5 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -81,6 +81,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. | | `dismissModalsOnOutsideClick` | `boolean` | `false` | Global dashboard preference for closing fixed modal overlays by clicking/tapping the backdrop. Off by default to prevent accidental modal dismissal; explicit close, cancel, and Escape paths remain available. | | `skipConfirmationDialogs` | `boolean` | `false` | Global-only operator preference that skips centralized confirmation dialogs for critical actions. When enabled, destructive actions such as deleting a task or resetting progress immediately take the dialog's primary/default action; project settings cannot enable it for shared-project collaborators. | +| `quickAddSubmitOnEnter` | `boolean` | `true` | Global-only operator keyboard preference for Quick Add. Enter saves by default; disabling it makes Enter insert a newline and reserves Cmd/Ctrl+Enter as the save accelerator. | | `credentialInstanceId` | `string` | `undefined` | Optional named credential instance for a resolved model selection. A named instance takes precedence over the provider default for that session and is scoped to that provider only; fallback providers retain their own default credentials. A deleted/malformed named instance uses the provider default and records `credentialInstanceMissing` with requested/resolved ids on `session:runtime-resolved`; no provider default is a visible resolution error. | | `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three independent surfaces, all executing on the direct `anthropic` provider except the CLI: (1) **direct OAuth** — a Claude subscription/OAuth login drives `anthropic/*` selections; Fusion sends the OAuth token to `https://api.anthropic.com/v1` with Claude Code identity headers (the same path the Claude Code CLI uses), so a subscription needs no API key. Credentials live under the `anthropic-subscription` auth/status/usage/banner id but are resolved for the direct provider at runtime; they are never stored or resolved as raw `ANTHROPIC_API_KEY` material. (2) **raw API key** — `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential uses `x-api-key` on the same direct provider and takes precedence over OAuth by default (see [`anthropicAuthPreference`](#anthropicauthpreference) to invert this). (3) **Claude CLI** — the explicit `pi-claude-cli` model provider runs sessions through the local `claude` CLI. There is no runtime rerouting between these surfaces. | | `defaultModelId` | `string` | `undefined` | Default AI model ID. | diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index f8f9dd629d..a25afeab3d 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -291,6 +291,16 @@ describe("settings defaults invariants", () => { }); }); + describe("quickAddSubmitOnEnter default", () => { + it("defaults Quick Add Enter submission on and global-scoped only", () => { + expect(DEFAULT_GLOBAL_SETTINGS.quickAddSubmitOnEnter).toBe(true); + expect(GLOBAL_SETTINGS_KEYS).toContain("quickAddSubmitOnEnter"); + expect("quickAddSubmitOnEnter" in DEFAULT_PROJECT_SETTINGS).toBe(false); + expect(PROJECT_SETTINGS_KEYS).not.toContain("quickAddSubmitOnEnter"); + expect(isGlobalOnlySettingsKey("quickAddSubmitOnEnter")).toBe(true); + }); + }); + describe("mergeIntegrationWorktree default", () => { it("defaults project settings to reuse-task-worktree", () => { expect(DEFAULT_PROJECT_SETTINGS.mergeIntegrationWorktree).toBe("reuse-task-worktree"); diff --git a/packages/core/src/config/settings-schema.ts b/packages/core/src/config/settings-schema.ts index bd0b9303c3..440840ef17 100644 --- a/packages/core/src/config/settings-schema.ts +++ b/packages/core/src/config/settings-schema.ts @@ -145,6 +145,11 @@ export const DEFAULT_GLOBAL_SETTINGS = { Critical-action confirmation dialogs stay enabled by default. This global-only preference may opt an operator into primary/default auto-approval, but project settings cannot enable it for collaborators. */ skipConfirmationDialogs: false, + /* + FNXC:QuickEntry 2026-08-16-03:15: + Quick Add keeps its historical Enter-to-submit behavior by default; only an operator's global preference may opt into newline-first entry. + */ + quickAddSubmitOnEnter: true, language: undefined, defaultProvider: undefined, defaultCredentialInstanceId: undefined, diff --git a/packages/core/src/types/settings/settings-scope.ts b/packages/core/src/types/settings/settings-scope.ts index d278183938..425254c2b6 100644 --- a/packages/core/src/types/settings/settings-scope.ts +++ b/packages/core/src/types/settings/settings-scope.ts @@ -353,6 +353,11 @@ export interface GlobalSettings { * This global operator preference defaults to false. When enabled, the dashboard skips centralized critical-action confirmations and proceeds with their primary/default choice. It must never be project-scoped so shared projects cannot force destructive actions without a prompt. */ skipConfirmationDialogs?: boolean; + /** + * FNXC:QuickEntry 2026-08-16-03:15: + * This global-only operator keyboard preference defaults to true to preserve Enter-submits behavior. When disabled, Enter inserts a newline and Cmd/Ctrl+Enter submits; shared projects must never change an operator's keyboard behavior. + */ + quickAddSubmitOnEnter?: boolean; /** Active UI locale (e.g. `"en"`, `"zh-CN"`, `"fr"`). One of `SUPPORTED_LOCALES`. * When unset, each surface resolves the locale at runtime (browser/env * detection) and falls back to `DEFAULT_LOCALE` ("en"). */ diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 766554c42c..7eeaed9def 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -44,6 +44,7 @@ import { useModalManager, type DetailTaskOrigin, type DetailTaskTab } from "./ho import { useAppSettings } from "./hooks/useAppSettings"; import { useDashboardKeyboardShortcuts } from "./hooks/useDashboardKeyboardShortcuts"; import { ModalDismissPreferenceProvider } from "./hooks/useOverlayDismiss"; +import { QuickAddSubmitOnEnterProvider } from "./hooks/useQuickAddSubmitOnEnter"; import { useDeepLink } from "./hooks/useDeepLink"; import { useFavorites } from "./hooks/useFavorites"; import { useAuthOnboarding } from "./hooks/useAuthOnboarding"; @@ -864,6 +865,7 @@ function AppInner() { quickChatCloseOnOutsideClick, dashboardKeyboardShortcuts, dismissModalsOnOutsideClick, + quickAddSubmitOnEnter, skipConfirmationDialogs, maxTotalRetriesBeforeFail, prAuthAvailable, @@ -1861,6 +1863,7 @@ function AppInner() { return ( + @@ -2288,6 +2291,7 @@ function AppInner() { + ); diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index b063295cc3..62ab7011ef 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -24,6 +24,7 @@ import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartInitialColumn, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart"; import { computeFixedMenuPosition, getLayoutViewportSize } from "../utils/fixedMenuPosition"; import { isInsidePortaledModelMenu } from "../utils/portalSurfaces"; +import { useQuickAddSubmitOnEnter } from "../hooks/useQuickAddSubmitOnEnter"; const STORAGE_KEY = "kb-quick-entry-text"; const ALLOWED_TASK_ATTACHMENT_TYPES = new Set([ @@ -89,6 +90,8 @@ interface QuickEntryBoxProps { List view renders quick-add as a COMPACT single-line input so the box isn't tall. When true, the textarea stays one line: isExpanded initializes false, focus does NOT auto-expand it, and auto-resize-to-scrollHeight is short-circuited (capped to the one-line min-height). Board/columns omit singleLine, preserving the tall 80px + auto-grow behavior. singleLine governs only textarea height, not the disclosure/controls panel (which List already collapses via defaultExpanded={false}). */ singleLine?: boolean; + /** Explicit override of the global Enter-submit preference. */ + submitOnEnter?: boolean; /** * Favorited provider IDs from shared app-level state. * When provided (alongside availableModels), the component uses these @@ -158,8 +161,10 @@ function hasMeaningfulNodeChoice(nodes: NodeInfo[]): boolean { return nodes.length > 1 || nodes.some((node) => node.type !== "local"); } -export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], availableModels, onSubtaskBreakdown, workflowId, workflowOptions, defaultWorkflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) { +export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], availableModels, onSubtaskBreakdown, workflowId, workflowOptions, defaultWorkflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, submitOnEnter, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) { const { t } = useTranslation("app"); + const contextSubmitOnEnter = useQuickAddSubmitOnEnter(); + const enterSubmits = submitOnEnter ?? contextSubmitOnEnter; const [description, setDescription] = useState(() => { if (typeof window !== "undefined") { return getScopedItem(STORAGE_KEY, projectId) || ""; @@ -1025,11 +1030,23 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai } return; } - // Enter without Shift submits - e.preventDefault(); if (duplicateMatches || submitInFlightRef.current) { + e.preventDefault(); return; } + /* + FNXC:QuickEntry 2026-08-16-03:15: + The global Quick Add option defaults on to preserve historical Enter submission. When disabled, plain Enter remains a browser newline while Cmd/Ctrl+Enter stays the explicit save accelerator. + */ + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + handleSubmit(); + return; + } + if (!enterSubmits) { + return; + } + e.preventDefault(); handleSubmit(); } else if (e.key === "Escape") { e.preventDefault(); @@ -1100,6 +1117,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai projectId, setIsDisclosureExpanded, duplicateMatches, + enterSubmits, singleLine, ], ); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.submit-on-enter.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.submit-on-enter.test.tsx new file mode 100644 index 0000000000..61e1b4e4e4 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.submit-on-enter.test.tsx @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QuickEntryBox } from "../QuickEntryBox"; +import { QuickAddSubmitOnEnterProvider } from "../../hooks/useQuickAddSubmitOnEnter"; +import { checkDuplicateTasks } from "../../api"; + +vi.mock("../../api", () => ({ + checkDuplicateTasks: vi.fn().mockResolvedValue([]), + fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }), + fetchSettings: vi.fn().mockResolvedValue({}), + fetchAuthStatus: vi.fn().mockResolvedValue({ providers: [] }), + fetchAgents: vi.fn().mockResolvedValue([]), + fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]), + refineText: vi.fn(), + uploadAttachment: vi.fn().mockResolvedValue({}), + updateGlobalSettings: vi.fn().mockResolvedValue({}), +})); +vi.mock("../../hooks/useComposerDictation", () => ({ + useComposerDictation: () => ({ micProps: { enabled: false, supported: false, state: "idle", start: vi.fn(), stop: vi.fn() } }), +})); +vi.mock("../../hooks/useNodes", () => ({ useNodes: () => ({ nodes: [], loading: false, error: null }) })); + +const duplicate = { id: "FN-existing", title: "Existing", description: "Existing task", column: "todo", score: 0.9 }; + +function renderEntry({ enabled, singleLine = false }: { enabled?: boolean; singleLine?: boolean } = {}) { + const onCreate = vi.fn().mockResolvedValue(undefined); + const entry = ; + render(enabled === undefined ? entry : {entry}); + const textarea = screen.getByPlaceholderText("Add a task...") as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "Task description" } }); + return { onCreate, textarea }; +} + +function pressEnter(textarea: HTMLTextAreaElement, options: KeyboardEventInit = {}) { + const event = createEvent.keyDown(textarea, { key: "Enter", ...options }); + fireEvent(textarea, event); + return event; +} + +afterEach(() => { + vi.mocked(checkDuplicateTasks).mockReset(); + vi.mocked(checkDuplicateTasks).mockResolvedValue([]); + vi.restoreAllMocks(); +}); + +describe("QuickEntryBox submit-on-Enter preference", () => { + it.each([{ singleLine: false, host: "Board" }, { singleLine: true, host: "List" }])("submits on Enter by default in $host Quick Add", async ({ singleLine }) => { + const { onCreate, textarea } = renderEntry({ singleLine }); + const event = pressEnter(textarea); + expect(event.defaultPrevented).toBe(true); + await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + }); + + it("uses the enabled provider in the Board host", async () => { + const { onCreate, textarea } = renderEntry({ enabled: true }); + expect(pressEnter(textarea).defaultPrevented).toBe(true); + await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + }); + + it.each([{ singleLine: false, host: "Board" }, { singleLine: true, host: "List" }])("keeps plain Enter as a browser newline when disabled in $host Quick Add", ({ singleLine }) => { + const { onCreate, textarea } = renderEntry({ enabled: false, singleLine }); + const event = pressEnter(textarea); + expect(event.defaultPrevented).toBe(false); + // jsdom does not perform textarea's native default action, so model the browser newline after proving it was allowed. + fireEvent.change(textarea, { target: { value: `${textarea.value}\n` } }); + expect(textarea).toHaveValue("Task description\n"); + expect(onCreate).not.toHaveBeenCalled(); + }); + + it.each([{ metaKey: true, platform: "Cmd" }, { ctrlKey: true, platform: "Ctrl" }])("uses $platform+Enter as the save accelerator when disabled", async (modifier) => { + const { onCreate, textarea } = renderEntry({ enabled: false }); + expect(pressEnter(textarea, modifier).defaultPrevented).toBe(true); + await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + }); + + it.each([{ enabled: true }, { enabled: false }])("keeps Shift+Enter non-submitting and does not expand the single-line textarea when preference is $enabled", ({ enabled }) => { + const { onCreate, textarea } = renderEntry({ enabled, singleLine: true }); + expect(textarea.closest(".quick-entry-box")?.className).not.toContain("expanded"); + expect(pressEnter(textarea, { shiftKey: true }).defaultPrevented).toBe(false); + expect(onCreate).not.toHaveBeenCalled(); + expect(textarea.closest(".quick-entry-box")?.className).not.toContain("expanded"); + }); + + it.each(["", " \n "])("never creates an empty or whitespace-only task (%j)", async (description) => { + const { onCreate, textarea } = renderEntry({ enabled: true }); + fireEvent.change(textarea, { target: { value: description } }); + expect(pressEnter(textarea).defaultPrevented).toBe(true); + await waitFor(() => expect(onCreate).not.toHaveBeenCalled()); + }); + + it.each([{ enabled: true }, { enabled: false }])("keeps Enter a no-op while duplicate confirmation is open when preference is $enabled", async ({ enabled }) => { + vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([duplicate]); + const { onCreate, textarea } = renderEntry({ enabled }); + pressEnter(textarea, enabled ? {} : { ctrlKey: true }); + await screen.findByText("Possible duplicates"); + const event = pressEnter(textarea); + expect(event.defaultPrevented).toBe(true); + expect(onCreate).not.toHaveBeenCalled(); + }); + + it.each([{ enabled: true }, { enabled: false }])("keeps Enter a no-op while a submission is in flight when preference is $enabled", async ({ enabled }) => { + let resolveDuplicateCheck!: (matches: typeof duplicate[]) => void; + vi.mocked(checkDuplicateTasks).mockImplementationOnce(() => new Promise((resolve) => { resolveDuplicateCheck = resolve; })); + const { onCreate, textarea } = renderEntry({ enabled }); + pressEnter(textarea, enabled ? {} : { ctrlKey: true }); + const event = pressEnter(textarea); + expect(event.defaultPrevented).toBe(true); + resolveDuplicateCheck([]); + await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + }); + + it("uses the same disabled Enter behavior at the mobile viewport", () => { + vi.spyOn(window, "innerWidth", "get").mockReturnValue(375); + const { onCreate, textarea } = renderEntry({ enabled: false }); + expect(pressEnter(textarea).defaultPrevented).toBe(false); + expect(onCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 31ecfff413..862b4be59a 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -1194,6 +1194,24 @@ describe("SettingsModal", () => { } }); + it("saves quickAddSubmitOnEnter only via global settings payload", async () => { + renderModal({ initialSection: "global-general" }); + await waitForSettingsModalReady(); + + vi.useFakeTimers(); + fireEvent.click(screen.getByRole("checkbox", { name: "Press Enter to save a task in Quick Add" })); + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + expect(mockUpdateGlobalSettings).toHaveBeenCalled(); + vi.useRealTimers(); + + const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record; + expect(globalPayload.quickAddSubmitOnEnter).toBe(false); + if (mockUpdateSettings.mock.calls.length > 0) { + const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record; + expect(projectPayload.quickAddSubmitOnEnter).toBeUndefined(); + } + }); + it("saves persistAgentToolOutput only via global settings payload", async () => { renderModal({ initialSection: "global-general" }); await waitForSettingsModalReady(); diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index 84da30b4bb..17598ec6ab 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -149,6 +149,7 @@ export const GLOBAL_SECTION_KEYS: Record> = { "language", "dismissModalsOnOutsideClick", "skipConfirmationDialogs", + "quickAddSubmitOnEnter", "persistAgentToolOutput", "agentToolOutputMaxChars", "proactiveTaskChatEnabled", diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts index b5f8725153..a563e6f7df 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts @@ -31,6 +31,15 @@ export const globalGeneralSearchEntries: SettingsSearchEntry[] = [ " When enabled, destructive actions such as deleting a task or resetting progress run immediately without a prompt. Default: disabled", keywords: ["confirm", "critical action", "delete", "reset", "destructive"], }, + { + sectionId: "global-general", + key: "quickAddSubmitOnEnter", + labelKey: "settings.globalGeneral.quickAddSubmitOnEnter", + labelFallback: " Press Enter to save a task in Quick Add ", + helpKey: "settings.globalGeneral.quickAddSubmitOnEnterHint", + helpFallback: " Default: enabled. When disabled, Enter inserts a newline and Cmd/Ctrl+Enter saves. ", + keywords: ["enter", "keyboard", "quick add", "newline", "submit"], + }, { sectionId: "global-general", key: "persistAgentToolOutput", diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index f0bb9a2035..adf7e19db7 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -47,6 +47,16 @@ export function GlobalGeneralSection({ form, setForm }: GlobalGeneralSectionProp value={form.skipConfirmationDialogs === true} onChange={(v) => setForm((f) => ({ ...f, skipConfirmationDialogs: v === true }))} /> + setForm((f) => ({ ...f, quickAddSubmitOnEnter: v === true }))} + /> = { gitlabAuthToken: "globalGeneral.gitLabAuthTokenHint", dismissModalsOnOutsideClick: "globalGeneral.dismissModalsByClickingOutsideHint", skipConfirmationDialogs: "globalGeneral.skipConfirmationDialogsHint", + quickAddSubmitOnEnter: "globalGeneral.quickAddSubmitOnEnterHint", persistAgentToolOutput: "globalGeneral.whenDisabledToolRowsAreStillLoggedBut", agentToolOutputMaxChars: "globalGeneral.agentToolOutputLimitHint", proactiveTaskChatEnabled: "globalGeneral.enableProactiveTaskChatHint", diff --git a/packages/dashboard/app/hooks/__tests__/useQuickAddSubmitOnEnter.test.tsx b/packages/dashboard/app/hooks/__tests__/useQuickAddSubmitOnEnter.test.tsx new file mode 100644 index 0000000000..c0d79dd128 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useQuickAddSubmitOnEnter.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QuickAddSubmitOnEnterProvider, useQuickAddSubmitOnEnter } from "../useQuickAddSubmitOnEnter"; + +function PreferenceValue() { + return {String(useQuickAddSubmitOnEnter())}; +} + +describe("useQuickAddSubmitOnEnter", () => { + it("defaults to enabled without a provider", () => { + render(); + expect(screen.getByText("true")).toBeInTheDocument(); + }); + + it("reads a disabled provider value", () => { + render(); + expect(screen.getByText("false")).toBeInTheDocument(); + }); + + it("reads an enabled provider value", () => { + render(); + expect(screen.getByText("true")).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/hooks/useAppSettings.ts b/packages/dashboard/app/hooks/useAppSettings.ts index 9c40c6a28d..17c9afccb9 100644 --- a/packages/dashboard/app/hooks/useAppSettings.ts +++ b/packages/dashboard/app/hooks/useAppSettings.ts @@ -39,6 +39,7 @@ export interface UseAppSettingsResult { quickChatCloseOnOutsideClick: boolean; dashboardKeyboardShortcuts: Required; dismissModalsOnOutsideClick: boolean; + quickAddSubmitOnEnter: boolean; skipConfirmationDialogs: boolean; showQuickChatFAB: boolean; maxTotalRetriesBeforeFail: number; @@ -99,6 +100,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true); const [dashboardKeyboardShortcuts, setDashboardKeyboardShortcuts] = useState>(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS); const [dismissModalsOnOutsideClick, setDismissModalsOnOutsideClick] = useState(false); + const [quickAddSubmitOnEnter, setQuickAddSubmitOnEnter] = useState(true); const [skipConfirmationDialogs, setSkipConfirmationDialogs] = useState(false); const [showQuickChatFAB, setShowQuickChatFAB] = useState(false); const [maxTotalRetriesBeforeFail, setMaxTotalRetriesBeforeFail] = useState(25); @@ -167,6 +169,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { setQuickChatCloseOnOutsideClick(settings.quickChatCloseOnOutsideClick !== false); setDashboardKeyboardShortcuts(resolveDashboardKeyboardShortcuts((settings as GlobalSettings).dashboardKeyboardShortcuts)); setDismissModalsOnOutsideClick(settings.dismissModalsOnOutsideClick === true); + setQuickAddSubmitOnEnter(settings.quickAddSubmitOnEnter !== false); setSkipConfirmationDialogs(settings.skipConfirmationDialogs === true); setShowQuickChatFAB(nextQuickChatButtonMode === "floating"); setMaxTotalRetriesBeforeFail(settings.maxTotalRetriesBeforeFail ?? 25); @@ -363,6 +366,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { quickChatCloseOnOutsideClick, dashboardKeyboardShortcuts, dismissModalsOnOutsideClick, + quickAddSubmitOnEnter, skipConfirmationDialogs, showQuickChatFAB, maxTotalRetriesBeforeFail, diff --git a/packages/dashboard/app/hooks/useQuickAddSubmitOnEnter.ts b/packages/dashboard/app/hooks/useQuickAddSubmitOnEnter.ts new file mode 100644 index 0000000000..e2509731a6 --- /dev/null +++ b/packages/dashboard/app/hooks/useQuickAddSubmitOnEnter.ts @@ -0,0 +1,21 @@ +import { createContext, createElement, useContext, type ReactNode } from "react"; + +/* +FNXC:QuickEntry 2026-08-16-03:15: +Quick Add renders from the deep Column and ListView hosts without a shared settings prop channel. This context mirrors ModalDismissPreferenceProvider so global keyboard preference reaches both hosts while the true default preserves unwrapped components and existing tests. +*/ +const QuickAddSubmitOnEnterContext = createContext(true); + +export function QuickAddSubmitOnEnterProvider({ + enabled, + children, +}: { + enabled: boolean; + children: ReactNode; +}) { + return createElement(QuickAddSubmitOnEnterContext.Provider, { value: enabled }, children); +} + +export function useQuickAddSubmitOnEnter(): boolean { + return useContext(QuickAddSubmitOnEnterContext); +} diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 19e5120eaa..c8de983c21 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6140,6 +6140,8 @@ "dismissModalsByClickingOutsideHint": " When enabled, clicking or tapping a modal backdrop closes the modal. Default: disabled, to prevent accidental dismissal. ", "skipConfirmationDialogs": " Skip confirmation dialogs for critical actions ", "skipConfirmationDialogsHint": " When enabled, destructive actions such as deleting a task or resetting progress run immediately without a prompt. Default: disabled", + "quickAddSubmitOnEnter": " Press Enter to save a task in Quick Add ", + "quickAddSubmitOnEnterHint": " Default: enabled. When disabled, Enter inserts a newline and Cmd/Ctrl+Enter saves. ", "releaseChannel": "Release channel", "releaseChannelHelp": "Stable follows official releases. Beta follows pre-releases cut from main and also picks up each stable release once it overtakes the beta. Switching back to Stable never downgrades. Default: stable.", "autoUpdateAndRestart": " Auto-update and restart ", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 3299cf0a7e..d460f4ffd3 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -6121,7 +6121,9 @@ "agentToolOutputLimit": "", "agentToolOutputLimitHint": "", "noLimitOnAgentToolOutput": "", - "noLimitOnAgentToolOutputHint": "" + "noLimitOnAgentToolOutputHint": "", + "quickAddSubmitOnEnter": "", + "quickAddSubmitOnEnterHint": "" }, "globalModels": { "allow": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 285e4c653b..6859235677 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -6121,7 +6121,9 @@ "agentToolOutputLimit": "", "agentToolOutputLimitHint": "", "noLimitOnAgentToolOutput": "", - "noLimitOnAgentToolOutputHint": "" + "noLimitOnAgentToolOutputHint": "", + "quickAddSubmitOnEnter": "", + "quickAddSubmitOnEnterHint": "" }, "globalModels": { "allow": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 9d4ddbede1..9a731dfd50 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -6121,7 +6121,9 @@ "agentToolOutputLimit": "", "agentToolOutputLimitHint": "", "noLimitOnAgentToolOutput": "", - "noLimitOnAgentToolOutputHint": "" + "noLimitOnAgentToolOutputHint": "", + "quickAddSubmitOnEnter": "", + "quickAddSubmitOnEnterHint": "" }, "globalModels": { "allow": "", diff --git a/packages/i18n/locales/pt-BR/app.json b/packages/i18n/locales/pt-BR/app.json index 5eb98e8e28..dd365c44dd 100644 --- a/packages/i18n/locales/pt-BR/app.json +++ b/packages/i18n/locales/pt-BR/app.json @@ -6669,7 +6669,9 @@ "autoUpdateAndRestart": " Atualizar e reiniciar automaticamente ", "autoUpdateAndRestartHelp": "Quando ativado, o Fusion instala automaticamente as atualizações disponíveis no canal de lançamento selecionado e reinicia para aplicá-las. Requer um processo supervisor pai (o padrão para `fn dashboard`); hosts iniciados com --no-supervise pulam a instalação. Padrão: desativado.", "channelStable": "Stable (recomendado)", - "channelBeta": "Beta — builds iniciais da main" + "channelBeta": "Beta — builds iniciais da main", + "quickAddSubmitOnEnter": "", + "quickAddSubmitOnEnterHint": "" }, "notifications": { "accessTokenOptional": "Token de acesso (opcional)", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index d7e7626b9c..49a41aa114 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -6121,7 +6121,9 @@ "agentToolOutputLimit": "", "agentToolOutputLimitHint": "", "noLimitOnAgentToolOutput": "", - "noLimitOnAgentToolOutputHint": "" + "noLimitOnAgentToolOutputHint": "", + "quickAddSubmitOnEnter": "", + "quickAddSubmitOnEnterHint": "" }, "globalModels": { "allow": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 785d28ad34..29d664eae5 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -6121,7 +6121,9 @@ "agentToolOutputLimit": "", "agentToolOutputLimitHint": "", "noLimitOnAgentToolOutput": "", - "noLimitOnAgentToolOutputHint": "" + "noLimitOnAgentToolOutputHint": "", + "quickAddSubmitOnEnter": "", + "quickAddSubmitOnEnterHint": "" }, "globalModels": { "allow": "", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index af4ee872ec..b47dc707a6 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -6114,7 +6114,7 @@ export default interface Resources { "whenEnabledFusionChecksOpenAndClosedIssues": " When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. Default: enabled. ", "whenEnabledImportedGitHubIssuesUseTheirSource": "When enabled, GitHub issue imports become tracked tasks that adopt the source issue. This does not turn GitHub tracking on for ordinary new tasks. Default: disabled.", "workflowsOrChangelogModeWhenContributorsShouldUpdate": " workflows, or changelog mode when contributors should update an existing changelog file. Default: off. ", - "workspaceModeHint": "When enabled, the project root is treated as a workspace containing multiple git sub-repos. Tasks run per-sub-repo and no git repo is created at the root. Disable for single-repo projects. No default — unset (disabled)." + "workspaceModeHint": "Enabling detects git sub-repositories and requires at least one; it creates .fusion/workspace.json. Disabling removes .fusion/workspace.json and returns to single-repo mode. No default — unset (disabled)." }, "globalGeneral": { "agentToolOutputLimit": " Agent tool-output limit ", @@ -6150,6 +6150,8 @@ export default interface Resources { "onStartupOncePerServerLaunch": "On startup — once per server launch", "ownerRepo": "owner/repo", "projectsInheritThisValueWhenTheyDoNot": "Projects inherit this value when they do not set a project default tracking repo. No default — unset.", + "quickAddSubmitOnEnter": " Press Enter to save a task in Quick Add ", + "quickAddSubmitOnEnterHint": " Default: enabled. When disabled, Enter inserts a newline and Cmd/Ctrl+Enter saves. ", "releaseChannel": "Release channel", "releaseChannelHelp": "Stable follows official releases. Beta follows pre-releases cut from main and also picks up each stable release once it overtakes the beta. Switching back to Stable never downgrades. Default: stable.", "reportRoadmapDedupeEnabled": "Global public-roadmap deduplication",