From dd659c8d175858bab96012b52b4201e381cd19cc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 01:30:52 -0500 Subject: [PATCH] refactor(desktop): extract composer pure helpers into composer-utils Pull ChatBar's module-level pure helpers, constants, and the QueueEditState type out of the 2.3k-line composer/index.tsx into a focused, testable composer-utils.ts sibling: - constants: COMPOSER_STACK_BREAKPOINT_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_FADE_BACKGROUND, DRAFT_PERSIST_DEBOUNCE_MS - helpers: pickPlaceholder, COMPLETION_ACTIONS, slashChipKindForItem, slashArgStage, slashCommandToken, cloneAttachments - type: QueueEditState Pure restructuring, no behavior change; adds unit tests for the slash helpers. (The ChatBar component itself is a single tightly-coupled megacomponent; a deeper hook-based decomposition is left for a dedicated follow-up.) --- .../app/chat/composer/composer-utils.test.ts | 40 +++++++++++ .../src/app/chat/composer/composer-utils.ts | 60 ++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 72 ++++--------------- 3 files changed, 115 insertions(+), 57 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/composer-utils.test.ts create mode 100644 apps/desktop/src/app/chat/composer/composer-utils.ts diff --git a/apps/desktop/src/app/chat/composer/composer-utils.test.ts b/apps/desktop/src/app/chat/composer/composer-utils.test.ts new file mode 100644 index 00000000000..9fc5f5b5730 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/composer-utils.test.ts @@ -0,0 +1,40 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' +import { describe, expect, it } from 'vitest' + +import { pickPlaceholder, slashArgStage, slashChipKindForItem, slashCommandToken } from './composer-utils' + +const item = (group: string): Unstable_TriggerItem => + ({ id: 'x', type: 'slash', label: 'x', metadata: { group } }) as unknown as Unstable_TriggerItem + +describe('slashArgStage', () => { + it('is true only once the query is past the command name', () => { + expect(slashArgStage('personality')).toBe(false) + expect(slashArgStage('personality alice')).toBe(true) + }) +}) + +describe('slashCommandToken', () => { + it('extracts the lowercased /command token', () => { + expect(slashCommandToken('Personality alice')).toBe('/personality') + expect(slashCommandToken('model')).toBe('/model') + }) + + it('handles an empty query', () => { + expect(slashCommandToken('')).toBe('/') + }) +}) + +describe('slashChipKindForItem', () => { + it('maps completion groups to chip kinds', () => { + expect(slashChipKindForItem(item('Skills'))).toBe('skill') + expect(slashChipKindForItem(item('Themes'))).toBe('theme') + expect(slashChipKindForItem(item('Commands'))).toBe('command') + }) +}) + +describe('pickPlaceholder', () => { + it('returns a member of the pool', () => { + const pool = ['a', 'b', 'c'] as const + expect(pool).toContain(pickPlaceholder(pool)) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts new file mode 100644 index 00000000000..ad7b63787fd --- /dev/null +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -0,0 +1,60 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' + +import type { SlashChipKind } from '@/components/assistant-ui/directive-text' +import type { ComposerAttachment } from '@/store/composer' +import { setSessionPickerOpen } from '@/store/session' + +export const COMPOSER_STACK_BREAKPOINT_PX = 320 + +// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem +// vertical padding). Anything taller means the text wrapped to a second line, +// which is when the composer should expand to the stacked layout. +export const COMPOSER_SINGLE_LINE_MAX_PX = 36 + +export const COMPOSER_FADE_BACKGROUND = + 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' + +// Quiet period after the last keystroke before persisting the draft; +// unmount/pagehide flushes bypass it. +export const DRAFT_PERSIST_DEBOUNCE_MS = 400 + +export const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)] + +/** Completion items can carry an `action` (set in use-slash-completions) that + * runs a side effect on pick instead of inserting a chip — e.g. the session + * picker's "Browse all…" entry opens the overlay. Table-driven so new action + * items are a registry row, not a composer branch. */ +export const COMPLETION_ACTIONS: Record void> = { + 'session-picker': () => setSessionPickerOpen(true) +} + +/** Map a picked `/` completion to its pill accent. Driven by the completion + * group set in use-slash-completions (Skills / Themes / Commands|Options). */ +export function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind { + const group = (item.metadata as { group?: unknown } | undefined)?.group + + if (group === 'Skills') { + return 'skill' + } + + if (group === 'Themes') { + return 'theme' + } + + return 'command' +} + +/** A `/` query is at its arg stage once it's past the command name. */ +export const slashArgStage = (query: string) => query.includes(' ') + +/** The `/command` token of a slash query (`personality x` → `/personality`). */ +export const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}` + +export interface QueueEditState { + attachments: ComposerAttachment[] + draft: string + entryId: string + sessionKey: string +} + +export const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 796e773153e..f9a47260a33 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -13,7 +13,7 @@ import { useState } from 'react' -import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text' +import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/use-media-query' @@ -65,7 +65,7 @@ import { $previewStatusBySession } from '@/store/preview-status' import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' import { $activeSessionAwaitingInput } from '@/store/prompts' import { toggleReview } from '@/store/review' -import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' +import { $gatewayState, $messages } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' import { isSecondaryWindow } from '@/store/windows' @@ -74,6 +74,19 @@ import { useTheme } from '@/themes' import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions' import { AttachmentList } from './attachments' +import { + cloneAttachments, + COMPLETION_ACTIONS, + COMPOSER_FADE_BACKGROUND, + COMPOSER_SINGLE_LINE_MAX_PX, + COMPOSER_STACK_BREAKPOINT_PX, + DRAFT_PERSIST_DEBOUNCE_MS, + pickPlaceholder, + type QueueEditState, + slashArgStage, + slashChipKindForItem, + slashCommandToken +} from './composer-utils' import { ContextMenu } from './context-menu' import { ComposerControls } from './controls' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' @@ -121,61 +134,6 @@ import type { ChatBarProps } from './types' import { UrlDialog } from './url-dialog' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' -const COMPOSER_STACK_BREAKPOINT_PX = 320 - -// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem -// vertical padding). Anything taller means the text wrapped to a second line, -// which is when the composer should expand to the stacked layout. -const COMPOSER_SINGLE_LINE_MAX_PX = 36 - -const COMPOSER_FADE_BACKGROUND = - 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' - -const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)] - -/** Completion items can carry an `action` (set in use-slash-completions) that - * runs a side effect on pick instead of inserting a chip — e.g. the session - * picker's "Browse all…" entry opens the overlay. Table-driven so new action - * items are a registry row, not a composer branch. */ -const COMPLETION_ACTIONS: Record void> = { - 'session-picker': () => setSessionPickerOpen(true) -} - -/** Map a picked `/` completion to its pill accent. Driven by the completion - * group set in use-slash-completions (Skills / Themes / Commands|Options). */ -function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind { - const group = (item.metadata as { group?: unknown } | undefined)?.group - - if (group === 'Skills') { - return 'skill' - } - - if (group === 'Themes') { - return 'theme' - } - - return 'command' -} - -/** A `/` query is at its arg stage once it's past the command name. */ -const slashArgStage = (query: string) => query.includes(' ') - -/** The `/command` token of a slash query (`personality x` → `/personality`). */ -const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}` - -interface QueueEditState { - attachments: ComposerAttachment[] - draft: string - entryId: string - sessionKey: string -} - -const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) - -// Quiet period after the last keystroke before persisting the draft; -// unmount/pagehide flushes bypass it. -const DRAFT_PERSIST_DEBOUNCE_MS = 400 - export function ChatBar({ busy, cwd,