From d82db402a3e1f39aaf0a190bc9efcf10aa8a5df5 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 4 Mar 2026 12:32:25 -0800 Subject: [PATCH] fix: keyboard shortcuts now work after labels load (lazy keymap evaluation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useLabelKeyboard now accepts labels as Label[] | (() => Label[]). The keymap is rebuilt on every keypress from the getter result instead of being captured once at construction time — so keys 1–9 now fire correctly after the async /api/config/labels fetch completes. LabelView passes () => labels.value so the reactive ref is read lazily. New test: 'evaluates labels getter on each keypress' covers the async-load scenario (empty list → no match; push a label → key fires). --- web/src/composables/useLabelKeyboard.test.ts | 14 ++++++++++++++ web/src/composables/useLabelKeyboard.ts | 7 ++++--- web/src/views/LabelView.vue | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/web/src/composables/useLabelKeyboard.test.ts b/web/src/composables/useLabelKeyboard.test.ts index 7e7d650..fcb61ff 100644 --- a/web/src/composables/useLabelKeyboard.test.ts +++ b/web/src/composables/useLabelKeyboard.test.ts @@ -89,4 +89,18 @@ describe('useLabelKeyboard', () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })) expect(onLabel).not.toHaveBeenCalled() }) + + it('evaluates labels getter on each keypress', () => { + const labelList: { name: string; key: string; emoji: string; color: string }[] = [] + const onLabel = vi.fn() + const { cleanup } = useLabelKeyboard({ labels: () => labelList, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() }) + cleanups.push(cleanup) + // Before labels loaded — pressing '1' does nothing + window.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true })) + expect(onLabel).not.toHaveBeenCalled() + // Add a label (simulating async load) + labelList.push({ name: 'interview_scheduled', key: '1', emoji: '🗓️', color: '#4CAF50' }) + window.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true })) + expect(onLabel).toHaveBeenCalledWith('interview_scheduled') + }) }) diff --git a/web/src/composables/useLabelKeyboard.ts b/web/src/composables/useLabelKeyboard.ts index 146528b..1ca80ed 100644 --- a/web/src/composables/useLabelKeyboard.ts +++ b/web/src/composables/useLabelKeyboard.ts @@ -3,7 +3,7 @@ import { onUnmounted, getCurrentInstance } from 'vue' interface Label { name: string; key: string; emoji: string; color: string } interface Options { - labels: Label[] + labels: Label[] | (() => Label[]) onLabel: (name: string) => void onSkip: () => void onDiscard: () => void @@ -12,12 +12,13 @@ interface Options { } export function useLabelKeyboard(opts: Options) { - const keyMap = new Map(opts.labels.map(l => [l.key.toLowerCase(), l.name])) - function handler(e: KeyboardEvent) { if (e.target instanceof HTMLInputElement) return if (e.target instanceof HTMLTextAreaElement) return const k = e.key.toLowerCase() + // Evaluate labels lazily so reactive updates work + const labelList = typeof opts.labels === 'function' ? opts.labels() : opts.labels + const keyMap = new Map(labelList.map(l => [l.key.toLowerCase(), l.name])) if (keyMap.has(k)) { opts.onLabel(keyMap.get(k)!); return } if (k === 'h') { opts.onLabel('hired'); return } if (k === 's') { opts.onSkip(); return } diff --git a/web/src/views/LabelView.vue b/web/src/views/LabelView.vue index 9c9a2c7..c4df517 100644 --- a/web/src/views/LabelView.vue +++ b/web/src/views/LabelView.vue @@ -254,7 +254,7 @@ async function handleUndo() { } useLabelKeyboard({ - labels: [], // will be updated after labels load — keyboard not active until queue loads + labels: () => labels.value, // getter — evaluated on each keypress onLabel: handleLabel, onSkip: handleSkip, onDiscard: handleDiscard,