Skip to content

Commit e7e906c

Browse files
jahoomaclaude
andauthored
feat(desktop): focus composer input when opening a new tab (#493)
Opening a new tab (the "+" button, /new, reopening a closed tab, or a folder pick that routes through newThread) now auto-focuses that tab's composer textarea so the user can type immediately. The Workspace keeps a single ThreadView/Composer instance and swaps the threadId prop on tab switches (no remount), so a plain autoFocus won't fire. Instead newThread/rehydrateLast set a one-shot pendingFocusId in the store; the Composer focuses when it matches its threadId and clears it via consumeComposerFocus. Routing through the signal means focus is grabbed only for newly-opened tabs, not on manual switches back to an existing tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bcc6d2d commit e7e906c

2 files changed

Lines changed: 26 additions & 0 deletions

File tree

freebuff-desktop/src/app/ui/components/Composer.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export function Composer({
3434
const pushToast = useStore((s) => s.pushToast)
3535
const running = useStore((s) => s.threads[threadId]?.thread.turnState === 'running')
3636
const skills = useStore((s) => s.skills)
37+
const pendingFocus = useStore((s) => s.pendingFocusId === threadId)
38+
const consumeComposerFocus = useStore((s) => s.consumeComposerFocus)
3739

3840
const [sel, setSel] = useState(0)
3941
// Set when the user dismisses the menu with Esc; reset on the next edit so a
@@ -49,6 +51,15 @@ export function Composer({
4951
prevLength.current = atts.length
5052
}, [atts.length])
5153

54+
// A newly-opened tab flags itself for focus so the user can type right away
55+
// (see store.pendingFocusId). Consume the one-shot signal once focused so a
56+
// later manual switch back to this tab doesn't re-steal focus.
57+
useEffect(() => {
58+
if (!pendingFocus) return
59+
ref.current?.focus({ preventScroll: true })
60+
consumeComposerFocus(threadId)
61+
}, [pendingFocus, threadId, consumeComposerFocus])
62+
5263
// Grow the textarea to fit when the draft changes, including on tab switch
5364
// (the new thread's draft can be much longer than the previous tab's; without
5465
// this the reused DOM node stays at the old height).

freebuff-desktop/src/app/ui/store/store.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ interface StoreState {
236236
cycleTab: (delta: number) => void
237237
jumpTab: (index: number) => void
238238
ensureLoaded: (id: string) => Promise<void>
239+
/** One-shot signal: a newly-opened tab whose composer should grab focus. The
240+
* Composer clears it via `consumeComposerFocus` once it has focused, so a
241+
* later manual tab switch doesn't re-steal focus. */
242+
pendingFocusId: string | null
243+
consumeComposerFocus: (id: string) => void
239244

240245
/** Toggle a reasoning part between its preview/expanded view (preserves user intent). */
241246
toggleReasoning: (threadId: string, messageId: string, partId: string) => void
@@ -295,6 +300,7 @@ export const useStore = create<StoreState>((set, get) => ({
295300
settingsOpen: false,
296301
recentProjects: [],
297302
toasts: [],
303+
pendingFocusId: null,
298304

299305
setAgentHarness(id) {
300306
// Optimistic: the server echoes the change back via a `state` event too.
@@ -667,6 +673,8 @@ export const useStore = create<StoreState>((set, get) => ({
667673
tabOrder: appendTab(s.tabOrder, t.id),
668674
activeId: t.id,
669675
recentProjects: pushRecent(s.recentProjects, t.projectPath),
676+
// Fresh tab → its composer should be ready to type into immediately.
677+
pendingFocusId: t.id,
670678
}))
671679
},
672680

@@ -735,11 +743,18 @@ export const useStore = create<StoreState>((set, get) => ({
735743
set((s) => ({
736744
tabOrder: appendTab(s.tabOrder, id),
737745
activeId: id,
746+
pendingFocusId: id,
738747
}))
739748
get().ensureLoaded(id)
740749
})
741750
},
742751

752+
consumeComposerFocus(id) {
753+
// Only clear if it's still pointing at the tab that just focused, so a rapid
754+
// second new-tab doesn't get its pending focus wiped by the first's consume.
755+
if (get().pendingFocusId === id) set({ pendingFocusId: null })
756+
},
757+
743758
cycleTab(delta) {
744759
const { tabOrder, activeId } = get()
745760
if (tabOrder.length === 0) return

0 commit comments

Comments
 (0)