kiokuko-dsh 0.1.91 → 0.1.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.cjs CHANGED
@@ -338,9 +338,9 @@ window.__ModuleLoader__.load({
338
338
  ...(error ? [jsx('p', { role: 'alert', children: error })] : []),
339
339
  ...(details ? [jsx('pre', { style: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, children: details })] : []),
340
340
  jsx('p', { children: '未確定の変更は自動で再実行・復元しません。詳細は /kioku-lisp diagnostics でも確認できます。' }),
341
- jsx('button', { type: 'button', disabled: busy, onClick: () => void act('cancel'), children: '停止する' }),
342
- jsx('button', { type: 'button', disabled: busy || ['STOP_UNCONFIRMED', 'SUSPENDED'].includes(status?.state ?? ''), onClick: () => void act('recover'), children: '照合して新しい Lisp を起動' }),
343
- jsx('button', { type: 'button', onClick: () => setOpen(false), children: '閉じる' }),
341
+ jsx(NumberedPicker, { label: 'Lisp の操作', disabled: busy,
342
+ options: [{ value: 'cancel', label: '停止する' }, { value: 'recover', label: '照合して新しい Lisp を起動', disabled: ['STOP_UNCONFIRMED', 'SUSPENDED'].includes(status?.state ?? '') }, { value: 'close', label: '閉じる' }],
343
+ onChoose: (value) => value === 'close' ? setOpen(false) : void act(value) }),
344
344
  ] }) })] });
345
345
  }
346
346
  /** Read-only, Session-bound display with explicit delivery acknowledgement after render. */
@@ -447,17 +447,104 @@ window.__ModuleLoader__.load({
447
447
  ] });
448
448
  }
449
449
  const intakeDrafts = new WeakMap();
450
- /** Option count a single number key addresses: the range the shortcut card covers. */
451
- const DIGIT_ADDRESSABLE_OPTIONS = 9;
452
- const ENNO_SELECTION_QUESTIONS = [
453
- 'enno-execution-mode', 'enno-model-source', 'enno-template', 'enno-template-provider',
454
- 'enno-template-unavailable', 'enno-bind-provider', 'enno-model-review', 'enno-catalog-retry',
455
- 'enno-route-provider', 'enno-route-family', 'enno-route-auth', 'enno-route-protocol',
456
- ];
457
- const DEEP_SELECTION_QUESTIONS = [
458
- 'deep-configuration', 'deep-budget-field', 'deep-budget-value', 'deep-role-model',
459
- 'deep-apply-configuration', 'deep-pending-input', 'deep-uncertain',
460
- ];
450
+ // Named native cards may be reconstructed when reopened. Scope drafts to both
451
+ // native session and request identity, with bounded retention for abandoned cards.
452
+ const namedIntakeDrafts = new Map();
453
+ const emptyDraft = () => ({ selected: null, checked: [], custom: '', ordinal: '' });
454
+ function draftKey(pending) {
455
+ return pending.sessionId && pending.dismissal === 'hide' ? JSON.stringify([pending.sessionId, pending.key]) : undefined;
456
+ }
457
+ function readIntakeDraft(pending) {
458
+ return intakeDrafts.get(pending) ?? namedIntakeDrafts.get(draftKey(pending) ?? '') ?? { page: 0, drafts: pending.questions.map(emptyDraft) };
459
+ }
460
+ function saveIntakeDraft(pending, batch) {
461
+ const key = draftKey(pending);
462
+ if (!batch) {
463
+ intakeDrafts.delete(pending);
464
+ if (key)
465
+ namedIntakeDrafts.delete(key);
466
+ return;
467
+ }
468
+ intakeDrafts.set(pending, batch);
469
+ if (key) {
470
+ namedIntakeDrafts.delete(key);
471
+ namedIntakeDrafts.set(key, batch);
472
+ if (namedIntakeDrafts.size > 100)
473
+ namedIntakeDrafts.delete(namedIntakeDrafts.keys().next().value);
474
+ }
475
+ }
476
+ function pendingState(pending) {
477
+ return pending.snapshot?.() ?? { state: 'open', channel: 'waterfall', closed: false };
478
+ }
479
+ function dismissQuestion(pending) {
480
+ // Prefer the host's current semantics; a failed hide must never cancel instead.
481
+ return pending.dismiss ? pending.dismiss() : pending.cancel();
482
+ }
483
+ function composingKey(event) {
484
+ return !!(event.repeat || event.isComposing || event.keyCode === 229 || event.nativeEvent?.isComposing || event.nativeEvent?.keyCode === 229);
485
+ }
486
+ function editingKey(event) {
487
+ const target = event.target;
488
+ return !!(target?.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? ''));
489
+ }
490
+ function shortcutPlatform() {
491
+ const mac = /Mac|iPhone|iPad|iPod/u.test(globalThis.navigator?.platform ?? '');
492
+ const ua = globalThis.navigator?.userAgent ?? '';
493
+ const control = !mac || /Safari\//u.test(ua) && !/(?:Chrome|Chromium|CriOS|Edg|OPR)\//u.test(ua);
494
+ return { control, label: control ? 'Ctrl' : 'Cmd', aria: control ? 'Control' : 'Meta' };
495
+ }
496
+ function modifiedKey(event) {
497
+ return !!(shortcutPlatform().control ? event.ctrlKey && !event.metaKey : event.metaKey && !event.ctrlKey);
498
+ }
499
+ function shortcutLabel(index, multi = false) {
500
+ return index < 9 ? `${shortcutPlatform().label}+${index + 1}` : `${index + 1} → ${multi ? 'Space' : 'Enter'}`;
501
+ }
502
+ function shortcutAria(index, count) {
503
+ return index < 9 ? { 'aria-keyshortcuts': `${count <= 9 ? `${index + 1} ` : ''}${shortcutPlatform().aria}+${index + 1}` } : {};
504
+ }
505
+ const shortcutOwners = new WeakMap();
506
+ /** Only the focused group (or the most recently focused visible group) owns a key. */
507
+ function useShortcutGroup(ref, handler) {
508
+ const latest = useRef(handler);
509
+ latest.current = handler;
510
+ useEffect(() => {
511
+ const element = ref.current, doc = element?.ownerDocument;
512
+ if (!element || !doc)
513
+ return;
514
+ const stack = shortcutOwners.get(doc) ?? [];
515
+ shortcutOwners.set(doc, stack);
516
+ stack.push(element);
517
+ const claim = () => { const i = stack.indexOf(element); if (i >= 0)
518
+ stack.splice(i, 1); stack.push(element); };
519
+ const listener = (event) => {
520
+ if (event.defaultPrevented || !element.isConnected || !element.getClientRects().length)
521
+ return;
522
+ const target = event.target;
523
+ if (target?.nodeType && !element.contains(target))
524
+ return;
525
+ const group = target?.closest?.('[data-kiokuko-shortcuts]');
526
+ if (group ? group !== element : stack.filter(item => item.isConnected && item.getClientRects().length).at(-1) !== element)
527
+ return;
528
+ const dialog = target?.closest?.('dialog,[role="dialog"]');
529
+ if (dialog && !dialog.contains(element))
530
+ return;
531
+ if (group === element)
532
+ claim();
533
+ latest.current(event);
534
+ };
535
+ element.addEventListener?.('focusin', claim);
536
+ element.addEventListener?.('pointerdown', claim);
537
+ doc.addEventListener('keydown', listener, true);
538
+ return () => {
539
+ element.removeEventListener?.('focusin', claim);
540
+ element.removeEventListener?.('pointerdown', claim);
541
+ doc.removeEventListener('keydown', listener, true);
542
+ const i = stack.indexOf(element);
543
+ if (i >= 0)
544
+ stack.splice(i, 1);
545
+ };
546
+ }, [ref]);
547
+ }
461
548
  function isEnnoSearchQuestion(question) {
462
549
  return question.header === '実行方式とモデル' && /^enno-(?:provider|model)-(?:ideal|zenki|goki|worker|check)$/u.test(question.id);
463
550
  }
@@ -468,230 +555,285 @@ window.__ModuleLoader__.load({
468
555
  return 'value';
469
556
  return 'choice';
470
557
  }
471
- /**
472
- * Decide whether this plugin's numbered card owns a native question carrier.
473
- *
474
- * The card claims every single-select question carrying one to nine options,
475
- * whoever asked it — Kiokuko's own intake, an Enno or Deep selection, another
476
- * plugin, or a question the model composed in chat — so a number-key plus Enter
477
- * shortcut is never missing from a question this composer shows. Longer catalogs
478
- * are claimed only by the Enno and Deep selection questions, whose typed search
479
- * and value flows own their own addressing. Multi-select batches, optionless
480
- * prompts, and multi-question batches have no single number per answer and stay
481
- * with the native composer.
482
- */
483
- function isSupportedQuestion(question) {
484
- if (question === undefined || question.multiSelect === true)
485
- return false;
486
- const options = question.options?.length ?? 0;
487
- if (options < 1)
488
- return false;
489
- if (options <= DIGIT_ADDRESSABLE_OPTIONS)
490
- return true;
491
- if (question.header === '実行方式とモデル' && (ENNO_SELECTION_QUESTIONS.includes(question.id) || isEnnoSearchQuestion(question)))
492
- return true;
493
- if (question.header === 'Deep planning' && DEEP_SELECTION_QUESTIONS.includes(question.id))
494
- return true;
495
- return false;
496
- }
497
558
  function intakePending(props) {
498
- const pending = props.pendingInteraction;
499
- if (!pending || pending.questions?.length !== 1 || typeof pending.answer !== 'function' || typeof pending.cancel !== 'function')
559
+ const p = props.pendingInteraction;
560
+ if (!p || !Array.isArray(p.questions) || !p.questions.length || typeof p.answer !== 'function'
561
+ || typeof p.dismiss !== 'function' && typeof p.cancel !== 'function')
562
+ return null;
563
+ if (p.sessionId && typeof props.sessionId === 'string' && props.sessionId !== p.sessionId)
500
564
  return null;
501
- const question = pending.questions[0];
502
- if (!isSupportedQuestion(question))
565
+ if (!p.questions.every(q => typeof q.id === 'string' && typeof q.question === 'string'
566
+ && (q.options === undefined || Array.isArray(q.options) && q.options.every((o) => typeof o.label === 'string'))))
503
567
  return null;
504
- if (pending.kind === 'question')
505
- return pending;
506
- return pending.kind === 'plan-review' && question.intent?.kind === 'plan-review'
507
- && typeof question.detail === 'string' && question.options.length <= 2
508
- && question.options.some(option => option.label === question.intent.approve) ? pending : null;
568
+ if (p.kind === 'question')
569
+ return p;
570
+ const q = p.questions[0];
571
+ return p.kind === 'plan-review' && p.questions.length === 1 && !q.multiSelect && q.intent?.kind === 'plan-review'
572
+ && typeof q.detail === 'string' && (q.options?.length ?? 0) <= 2 && q.options?.some((o) => o.label === q.intent.approve) ? p : null;
509
573
  }
510
- /**
511
- * Native pending carrier, plugin-only presentation. The card answers through the
512
- * carrier's own protocol, so claiming a question does not change what the asker
513
- * receives — only how the option can be chosen.
514
- */
515
574
  function IntakeQuestion(props) {
516
- const pending = props.matched;
517
- const t = props.t;
518
- return jsx(IntakeQuestionCard, { key: pending.key, pending,
575
+ const pending = props.matched, t = props.t;
576
+ return jsx(IntakeQuestionCard, { key: `${pending.sessionId ?? ''}:${pending.key}`, pending,
519
577
  ...(pending.kind === 'plan-review' ? { reviewCopy: { discuss: t('review.discuss'),
520
578
  labels: { code: { copyLabel: t('review.copy'), copiedLabel: t('review.copied') }, footnotes: t('review.footnotes') } } } : {}),
521
579
  });
522
580
  }
523
581
  function IntakeQuestionCard(props) {
524
582
  const pending = props.pending;
525
- const reviewing = pending.kind === 'plan-review';
526
583
  const reviewCopy = props.reviewCopy;
527
- const original = pending.questions[0];
528
- // The review's first choice cancels back to discussion. Other choices retain
529
- // the host's exact labels; approval stays last, even if supplied first.
530
- const question = reviewing ? { ...original, options: [
531
- { label: reviewCopy.discuss },
532
- ...original.options.filter(option => option.label !== original.intent.approve),
533
- ...original.options.filter(option => option.label === original.intent.approve),
534
- ] } : original;
535
- const inputKind = questionInputKind(question);
536
- const mac = /Mac|iPhone|iPad|iPod/u.test(globalThis.navigator?.platform ?? '');
537
- const userAgent = globalThis.navigator?.userAgent ?? '';
538
- const safari = mac && /Safari\//u.test(userAgent) && !/(?:Chrome|Chromium|CriOS|Edg|OPR)\//u.test(userAgent);
539
- const controlShortcut = !mac || safari;
540
- const shortcutModifier = controlShortcut ? 'Ctrl' : 'Cmd';
541
- const [draft, setDraft] = useState(() => intakeDrafts.get(pending) ?? { selected: null, custom: '' });
542
- const [busy, setBusy] = useState(false);
543
- const [error, setError] = useState('');
544
- const inFlight = useRef(false);
545
- const mounted = useRef(false);
546
- const card = useRef(null);
547
- const optionElements = useRef([]);
584
+ const reviewing = pending.kind === 'plan-review';
585
+ const [batch, setBatch] = useState(() => readIntakeDraft(pending));
586
+ const [snapshot, setSnapshot] = useState(() => pendingState(pending));
587
+ const [busy, setBusy] = useState(false), [error, setError] = useState('');
588
+ const inFlight = useRef(false), mounted = useRef(false), sent = useRef(false);
589
+ const generation = useRef(0);
590
+ const sentChannel = useRef('none');
591
+ const card = useRef(null), optionElements = useRef([]);
592
+ const original = pending.questions[batch.page];
593
+ const options = original.options ?? [];
594
+ const question = { ...original, options: reviewing ? [{ label: reviewCopy.discuss },
595
+ ...options.filter(o => o.label !== original.intent.approve), ...options.filter(o => o.label === original.intent.approve)] : options };
596
+ const draft = batch.drafts[batch.page], multi = question.multiSelect === true, inputKind = questionInputKind(question);
597
+ const editable = () => !pending.review && !pendingState(pending).closed && pendingState(pending).channel !== 'none';
548
598
  useEffect(() => {
599
+ generation.current++;
549
600
  mounted.current = true;
601
+ inFlight.current = false;
602
+ sent.current = false;
603
+ setBusy(false);
604
+ setError('');
605
+ setBatch(readIntakeDraft(pending));
606
+ const sync = () => {
607
+ const next = pendingState(pending);
608
+ setSnapshot(next);
609
+ if (next.closed) {
610
+ saveIntakeDraft(pending);
611
+ return;
612
+ }
613
+ if (sent.current && sentChannel.current === 'waterfall' && next.channel === 'rpc') {
614
+ sent.current = false;
615
+ inFlight.current = false;
616
+ setBusy(false);
617
+ }
618
+ };
619
+ const unsubscribe = pending.subscribe?.(sync);
620
+ sync();
550
621
  card.current?.focus();
551
- return () => { mounted.current = false; };
622
+ pending.holdFocus?.();
623
+ const win = card.current?.ownerDocument?.defaultView;
624
+ const release = () => pending.releaseFocus?.();
625
+ const hold = () => { if (card.current?.contains(card.current.ownerDocument.activeElement))
626
+ pending.holdFocus?.(); };
627
+ win?.addEventListener('blur', release);
628
+ win?.addEventListener('focus', hold);
629
+ return () => { generation.current++; mounted.current = false; unsubscribe?.(); pending.releaseFocus?.(); win?.removeEventListener('blur', release); win?.removeEventListener('focus', hold); };
552
630
  }, [pending]);
553
- useEffect(() => {
554
- if (draft.selected !== null)
555
- optionElements.current[draft.selected]?.scrollIntoView({ block: 'nearest' });
556
- }, [draft.selected]);
631
+ useEffect(() => { if (draft.selected !== null)
632
+ optionElements.current[draft.selected]?.scrollIntoView({ block: 'nearest' }); }, [draft.selected]);
557
633
  const update = (value) => {
558
- if (inFlight.current)
634
+ if (inFlight.current || !editable())
559
635
  return;
560
- intakeDrafts.set(pending, value);
561
- setDraft(value);
636
+ const current = readIntakeDraft(pending), next = { ...current, drafts: current.drafts.map((d, i) => i === current.page ? value : d) };
637
+ saveIntakeDraft(pending, next);
638
+ setBatch(next);
562
639
  setError('');
640
+ pending.engage?.();
563
641
  };
564
- const settle = (cancel = false) => {
565
- if (inFlight.current)
566
- return;
567
- // Read the synchronous draft: a digit and Enter can arrive before React rerenders.
568
- const current = intakeDrafts.get(pending) ?? draft;
569
- let custom = current.custom.trim();
570
- let selected = current.selected;
571
- if (!cancel && selected === null && custom === '') {
572
- setError(reviewing ? '選択肢を選んでから確定してください。' : '選択肢を選ぶか、自由入力してください。');
642
+ const choose = (index) => {
643
+ const d = readIntakeDraft(pending).drafts[batch.page];
644
+ update({ selected: index, checked: multi ? d.checked.includes(index) ? d.checked.filter(n => n !== index) : [...d.checked, index] : [], custom: '', ordinal: '' });
645
+ };
646
+ const page = (index) => { const next = { ...readIntakeDraft(pending), page: index }; saveIntakeDraft(pending, next); setBatch(next); setError(''); card.current?.focus(); };
647
+ const settle = (cancel = false, all = false) => {
648
+ if (inFlight.current || !mounted.current || pendingState(pending).closed || !cancel && !editable())
573
649
  return;
574
- }
575
- if (!cancel && !reviewing && inputKind === 'choice' && /^[0-90-9]+$/u.test(custom)) {
576
- const ordinal = Number(custom.normalize('NFKC'));
577
- if (ordinal < 1 || ordinal > question.options.length) {
578
- setError(`番号は1〜${question.options.length}で入力してください。`);
650
+ const current = readIntakeDraft(pending), d = current.drafts[current.page];
651
+ const answers = [];
652
+ if (!cancel) {
653
+ for (let i = 0; i <= (all ? pending.questions.length - 1 : current.page); i++) {
654
+ const q = pending.questions[i], value = current.drafts[i], opts = reviewing ? question.options : q.options ?? [];
655
+ let selected = q.multiSelect ? value.checked : value.selected === null ? [] : [value.selected];
656
+ let custom = value.custom.trim();
657
+ if (value.ordinal && (Number(value.ordinal) < 1 || Number(value.ordinal) > opts.length || q.multiSelect)) {
658
+ setError(q.multiSelect ? '入力した番号はSpaceでチェックしてください。' : `番号は1〜${opts.length}で入力してください。`);
659
+ return;
660
+ }
661
+ if (!reviewing && questionInputKind(q) === 'choice' && opts.length && /^[0-90-9]+$/u.test(custom)) {
662
+ const n = Number(custom.normalize('NFKC'));
663
+ if (n < 1 || n > opts.length) {
664
+ setError(`番号は1〜${opts.length}で入力してください。`);
665
+ return;
666
+ }
667
+ selected = q.multiSelect ? [...new Set([...selected, n - 1])] : [n - 1];
668
+ custom = '';
669
+ }
670
+ if (!selected.length && !custom) {
671
+ page(i);
672
+ setError('選択肢を選ぶか、自由入力してください。');
673
+ return;
674
+ }
675
+ answers.push({ id: q.id, selected: selected.map(n => opts[n].label), ...(custom ? { custom } : {}) });
676
+ }
677
+ if (!all && current.page < pending.questions.length - 1) {
678
+ page(current.page + 1);
579
679
  return;
580
680
  }
581
- selected = ordinal - 1;
582
- custom = '';
583
681
  }
584
682
  inFlight.current = true;
585
683
  setBusy(true);
586
684
  setError('');
587
- // Enter is a separate confirmation. Typing, key-repeat and IME cannot submit twice.
588
- void Promise.resolve().then(() => cancel || (reviewing && selected === 0) ? pending.cancel() : pending.answer({ answers: [{
589
- id: question.id,
590
- selected: selected === null ? [] : [question.options[selected].label],
591
- ...(custom ? { custom } : {}),
592
- }] })).then(() => { intakeDrafts.delete(pending); }).catch(cause => {
593
- if (!mounted.current)
685
+ const epoch = generation.current;
686
+ const currentCard = () => mounted.current && generation.current === epoch;
687
+ const dismissing = cancel || reviewing && d.selected === 0;
688
+ void Promise.resolve().then(() => {
689
+ if (!currentCard() || pendingState(pending).closed || !dismissing && !editable())
690
+ throw new Error('この質問には現在回答できません。');
691
+ sentChannel.current = pendingState(pending).channel;
692
+ return dismissing ? dismissQuestion(pending) : pending.answer({ answers });
693
+ }).then(() => {
694
+ if (!currentCard())
594
695
  return;
696
+ if (!pending.snapshot || pendingState(pending).closed || dismissing && pending.dismissal !== 'hide')
697
+ saveIntakeDraft(pending);
698
+ if (pending.snapshot && !dismissing && !pendingState(pending).closed) {
699
+ sent.current = true;
700
+ if (sentChannel.current === 'waterfall' && pendingState(pending).channel === 'rpc') {
701
+ sent.current = false;
702
+ inFlight.current = false;
703
+ if (mounted.current)
704
+ setBusy(false);
705
+ }
706
+ }
707
+ }).catch(cause => { if (currentCard()) {
595
708
  inFlight.current = false;
596
709
  setBusy(false);
597
710
  setError(cause instanceof Error ? cause.message : String(cause));
598
- });
711
+ } });
599
712
  };
600
- const selectShortcut = (event) => {
601
- if (busy || inFlight.current || event.repeat || event.isComposing || event.keyCode === 229
602
- || event.nativeEvent?.isComposing || event.nativeEvent?.keyCode === 229 || event.altKey || event.shiftKey
603
- || !(controlShortcut ? event.ctrlKey && !event.metaKey : event.metaKey && !event.ctrlKey))
604
- return false;
605
- const digit = /^(?:Digit|Numpad)([1-9])$/u.exec(event.code ?? '')?.[1] ?? event.key;
606
- if (!/^[1-9]$/u.test(digit) || Number(digit) > question.options.length)
607
- return false;
608
- event.preventDefault();
609
- event.stopPropagation();
610
- update({ selected: Number(digit) - 1, custom: '' });
611
- card.current?.focus();
612
- return true;
613
- };
614
- useEffect(() => {
615
- const element = card.current;
616
- const owner = element?.ownerDocument;
617
- if (!owner)
618
- return;
619
- // Capture before the host's composer handlers, even if it has moved focus.
620
- // Only the visible, mounted question owns these modified shortcuts.
621
- const listener = (event) => {
622
- if (element.isConnected && element.getClientRects().length > 0)
623
- selectShortcut(event);
624
- };
625
- owner.addEventListener('keydown', listener, true);
626
- return () => owner.removeEventListener('keydown', listener, true);
627
- }, [pending, busy, controlShortcut]);
628
713
  const keyDown = (event) => {
629
- if (selectShortcut(event))
714
+ if (event.defaultPrevented || inFlight.current || composingKey(event) || event.altKey || event.shiftKey)
715
+ return;
716
+ if (event.key === 'Escape') {
717
+ event.preventDefault();
718
+ event.stopPropagation();
719
+ void settle(true);
720
+ return;
721
+ }
722
+ if (pending.review && event.key === 'Enter' && !event.ctrlKey && !event.metaKey && event.target?.tagName !== 'BUTTON' && batch.page < pending.questions.length - 1) {
723
+ event.preventDefault();
724
+ event.stopPropagation();
725
+ page(batch.page + 1);
726
+ return;
727
+ }
728
+ if (!editable())
630
729
  return;
631
- if (busy || inFlight.current || event.repeat || event.isComposing || event.keyCode === 229 || event.nativeEvent?.isComposing || event.nativeEvent?.keyCode === 229
632
- || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey)
730
+ if (event.key === 'Enter' && modifiedKey(event)) {
731
+ event.preventDefault();
732
+ event.stopPropagation();
733
+ settle(false, true);
734
+ return;
735
+ }
736
+ if (editingKey(event)) {
737
+ if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.target?.isContentEditable) {
738
+ event.preventDefault();
739
+ event.stopPropagation();
740
+ if (!multi || batch.page < pending.questions.length - 1)
741
+ settle();
742
+ }
743
+ return;
744
+ }
745
+ const modified = modifiedKey(event);
746
+ if ((event.ctrlKey || event.metaKey) && !modified)
747
+ return;
748
+ const digit = /^(?:Digit|Numpad)([0-9])$/u.exec(event.code ?? '')?.[1] ?? event.key;
749
+ const d = readIntakeDraft(pending).drafts[batch.page];
750
+ if (modified && /^[1-9]$/u.test(digit) && Number(digit) <= question.options.length) {
751
+ event.preventDefault();
752
+ event.stopPropagation();
753
+ choose(Number(digit) - 1);
754
+ card.current?.focus();
755
+ return;
756
+ }
757
+ if (modified)
633
758
  return;
634
- const editing = event.target?.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target?.tagName ?? '');
635
- const current = intakeDrafts.get(pending) ?? draft;
636
- if (!editing && (/^[0-9]$/u.test(event.key) || (event.key === 'Backspace' && current.ordinal))) {
637
- const multipleDigits = question.options.length > 9;
638
- if (!multipleDigits && (!/^[1-9]$/u.test(event.key) || Number(event.key) > question.options.length))
759
+ if (question.options.length && (/^[0-9]$/u.test(digit) || event.key === 'Backspace' && d.ordinal)) {
760
+ if (question.options.length <= 9 && (!/^[1-9]$/u.test(digit) || Number(digit) > question.options.length))
639
761
  return;
640
762
  event.preventDefault();
641
763
  event.stopPropagation();
642
- const ordinal = event.key === 'Backspace' ? current.ordinal.slice(0, -1) : multipleDigits ? (current.ordinal ?? '') + event.key : event.key;
643
- const index = Number(ordinal) - 1;
644
- const valid = ordinal !== '' && index >= 0 && index < question.options.length;
645
- update({ selected: valid ? index : null, custom: '', ordinal });
646
- if (ordinal && !valid)
647
- setError(`番号「${ordinal}」は範囲外です。1〜${question.options.length}で入力してください。Backspaceで訂正できます。`);
764
+ if (question.options.length <= 9)
765
+ choose(Number(digit) - 1);
766
+ else {
767
+ const ordinal = event.key === 'Backspace' ? d.ordinal.slice(0, -1) : (d.ordinal + digit).slice(0, 9);
768
+ const n = Number(ordinal), valid = n >= 1 && n <= question.options.length;
769
+ update({ ...d, ordinal, custom: '', selected: !multi && valid ? n - 1 : multi ? d.selected : null });
770
+ if (ordinal && !valid)
771
+ setError(`番号「${ordinal}」は範囲外です。Backspaceで訂正してください。`);
772
+ }
648
773
  card.current?.focus();
774
+ return;
775
+ }
776
+ if (event.key === ' ' && multi && d.ordinal) {
777
+ event.preventDefault();
778
+ event.stopPropagation();
779
+ const n = Number(d.ordinal);
780
+ if (n >= 1 && n <= question.options.length)
781
+ choose(n - 1);
782
+ return;
649
783
  }
650
- else if (event.key === 'Enter' && event.target?.tagName !== 'BUTTON' && !event.target?.isContentEditable) {
784
+ if (event.key === 'Enter' && event.target?.tagName !== 'BUTTON') {
651
785
  event.preventDefault();
652
786
  event.stopPropagation();
653
- settle();
787
+ if (!multi || batch.page < pending.questions.length - 1)
788
+ settle();
654
789
  }
655
790
  };
656
- const titleId = `kiokuko-intake-${pending.key}`;
657
- return jsxs('section', {
658
- className: 'kiokuko-intake', tabIndex: 0, ref: (element) => { card.current = element; },
659
- 'aria-labelledby': titleId, 'aria-busy': busy, onKeyDown: keyDown,
791
+ useShortcutGroup(card, keyDown);
792
+ const disabled = busy || !editable(), titleId = `kiokuko-intake-${pending.key}`;
793
+ const hint = question.options.length ? `${shortcutPlatform().label}+1〜${Math.min(9, question.options.length)}、または数字で${multi ? 'チェック切替' : '選択'}。${question.options.length > 9 ? `10以上は番号を続けて入力、Backspaceで訂正${multi ? '、Spaceでチェック' : ''}。` : ''}${multi ? `${shortcutPlatform().label}+Enterで全回答を送信。` : 'Enterで確定。'}` : `自由入力後、${multi ? shortcutPlatform().label + '+' : ''}Enterで確定。Shift+Enterで改行。`;
794
+ return jsxs('section', { className: 'kiokuko-intake', tabIndex: 0, 'data-kiokuko-shortcuts': '',
795
+ ref: (el) => { card.current = el; }, 'aria-labelledby': titleId, 'aria-busy': busy, onKeyDown: keyDown,
796
+ onFocusCapture: () => pending.holdFocus?.(),
797
+ onBlurCapture: (event) => { if (!card.current?.contains(event.relatedTarget))
798
+ pending.releaseFocus?.(); },
660
799
  children: [
661
800
  jsxs('header', { children: [jsx('h2', { id: titleId, children: question.question }),
662
- jsx('button', { type: 'button', disabled: busy, onClick: () => settle(true), 'aria-label': '質問を閉じる', children: '閉じる' })] }),
801
+ jsx('button', { type: 'button', disabled: busy || snapshot.closed, onClick: () => settle(true), 'aria-label': '質問を閉じる', children: '閉じる(Esc)' })] }),
802
+ jsx('p', { id: `${titleId}-keys`, children: pending.review ? '回答済みの内容です。Enterで次の質問を表示、Escで閉じます。' : hint }),
663
803
  jsxs('div', { className: 'kiokuko-intake-body', children: [
664
- reviewing ? jsx(MarkdownText, { text: question.detail, labels: reviewCopy.labels })
665
- : question.detail ? jsx('p', { children: question.detail }) : null,
666
- jsx('p', { children: question.options.length > 9
667
- ? `${shortcutModifier}+1〜9で選択、Enterで確定。10以上は番号(1〜${question.options.length})を数字で続けて入力、Backspaceで訂正できます。`
668
- : `${shortcutModifier}+1〜${question.options.length}で選択、Enterで確定。` }),
669
- jsx('div', { 'aria-label': '選択肢', children: question.options.map((option, index) => jsxs('button', {
670
- key: index, type: 'button', className: 'kiokuko-intake-option', disabled: busy,
671
- ref: (element) => { optionElements.current[index] = element; },
672
- 'aria-pressed': draft.selected === index,
673
- ...(index < 9 ? { 'aria-keyshortcuts': `${question.options.length <= 9 ? `${index + 1} ` : ''}${controlShortcut ? 'Control' : 'Meta'}+${index + 1}` } : {}),
674
- onClick: () => update({ selected: index, custom: '' }),
804
+ reviewing ? jsx(MarkdownText, { text: question.detail, labels: reviewCopy.labels }) : question.detail ? jsx('p', { children: question.detail }) : null,
805
+ !pending.review && !snapshot.closed && snapshot.state === 'continued' ? jsx('p', { children: '処理は継続中です。この質問には後から回答できます。' }) : null,
806
+ snapshot.countdown ? jsx('p', { children: `回答待ち: ${Math.ceil(snapshot.countdown.remainingMs / 1000)}秒${snapshot.countdown.running ? '' : '(操作中は停止)'}` }) : null,
807
+ pending.review ? jsx('p', { children: `回答済み: ${pending.review.find(a => a.id === question.id)?.selected.join('、') ?? ''} ${pending.review.find(a => a.id === question.id)?.custom ?? ''}` }) : null,
808
+ !pending.review && !editable() ? jsx('p', { role: 'status', children: snapshot.closed ? 'この質問は終了しました。' : '回答経路の接続を待っています。' }) : null,
809
+ jsx('div', { 'aria-label': '選択肢', 'aria-describedby': `${titleId}-keys`, children: question.options.map((option, index) => jsxs('button', {
810
+ type: 'button', className: 'kiokuko-intake-option', disabled,
811
+ ref: (el) => { optionElements.current[index] = el; },
812
+ ...(multi ? { role: 'checkbox', 'aria-checked': draft.checked.includes(index) } : { 'aria-pressed': draft.selected === index }),
813
+ ...(!pending.review ? shortcutAria(index, question.options.length) : {}),
814
+ onClick: () => choose(index),
675
815
  onKeyDown: (event) => {
676
- if (event.key !== 'Enter' || event.repeat || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey || event.isComposing || event.keyCode === 229 || event.nativeEvent?.isComposing || event.nativeEvent?.keyCode === 229 || (intakeDrafts.get(pending) ?? draft).selected !== index)
677
- return;
678
- event.preventDefault();
679
- event.stopPropagation();
680
- settle();
816
+ if (event.key === 'Enter' && !multi && !composingKey(event) && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey
817
+ && readIntakeDraft(pending).drafts[batch.page].selected === index) {
818
+ event.preventDefault();
819
+ event.stopPropagation();
820
+ settle();
821
+ }
681
822
  },
682
823
  children: [jsx('strong', { children: `${index + 1}. ${option.label}` }), option.description ? jsx('span', { children: option.description }) : null,
683
- jsx('kbd', { className: 'kiokuko-intake-shortcut', 'aria-hidden': true, children: index < 9 ? `${shortcutModifier}+${index + 1}` : `${index + 1} → Enter` })],
684
- })) }),
685
- !reviewing ? jsx('label', { htmlFor: `${titleId}-custom`, children: inputKind === 'search' ? '検索(Enterで検索・数字も検索語として入力できます)' : inputKind === 'value' ? '値を入力(Enterで確定)' : '自由入力(任意)' }) : null,
686
- inputKind === 'choice' && question.header === '実行方式とモデル'
687
- ? jsx('p', { children: '選択肢に当てはまらない内容はAIに渡し、会話に戻ります。実行方式やモデル構成は確定しません。' }) : null,
688
- !reviewing ? jsx('textarea', { id: `${titleId}-custom`, rows: 1, disabled: busy, value: draft.custom,
689
- onChange: (event) => update({ selected: null, custom: event.target.value }),
824
+ !pending.review ? jsx('kbd', { className: 'kiokuko-intake-shortcut', 'aria-hidden': true, children: shortcutLabel(index, multi) }) : null],
825
+ }, index)) }),
826
+ !reviewing ? jsx('label', { htmlFor: `${titleId}-custom`, children: inputKind === 'search' ? '検索(数字も検索語として入力できます)' : inputKind === 'value' ? '値を入力' : '自由入力(任意・Shift+Enterで改行)' }) : null,
827
+ !reviewing ? jsx('textarea', { id: `${titleId}-custom`, rows: 1, disabled, value: draft.custom,
828
+ onChange: (e) => update({ ...emptyDraft(), checked: draft.checked, custom: e.target.value }),
690
829
  }) : null,
691
830
  ] }),
692
831
  jsxs('footer', { children: [
693
- jsx('span', { role: 'status', 'aria-live': 'polite', children: error || (busy ? '送信中…' : draft.selected === null ? '' : `${draft.selected + 1}. ${question.options[draft.selected].label}を選択中`) }),
694
- jsx('button', { type: 'button', disabled: busy || (draft.selected === null && !draft.custom.trim()), onClick: () => settle(), children: '確定(Enter)' }),
832
+ batch.page > 0 ? jsx('button', { type: 'button', disabled: busy, onClick: () => page(batch.page - 1), children: '前の質問' }) : null,
833
+ pending.questions.length > 1 ? jsx('span', { children: `${batch.page + 1}/${pending.questions.length}` }) : null,
834
+ jsx('span', { role: 'status', 'aria-live': 'polite', children: error || (busy ? '送信中…' : draft.ordinal ? `入力番号: ${draft.ordinal}` : multi ? `${draft.checked.length}件を選択中` : draft.selected === null ? '' : `${draft.selected + 1}. ${question.options[draft.selected].label}を選択中`) }),
835
+ pending.review ? batch.page < pending.questions.length - 1 ? jsx('button', { type: 'button', onClick: () => page(batch.page + 1), children: '次の質問(Enter)' }) : null
836
+ : jsx('button', { type: 'button', disabled: disabled || (multi ? !draft.checked.length && !draft.custom.trim() : draft.selected === null && !draft.custom.trim()), onClick: () => settle(), children: batch.page < pending.questions.length - 1 ? '次の質問(Enter)' : `確定(${multi ? shortcutPlatform().label + '+' : ''}Enter)` }),
695
837
  ] }),
696
838
  ],
697
839
  });
@@ -701,6 +843,13 @@ window.__ModuleLoader__.load({
701
843
  return undefined;
702
844
  const style = document.createElement('style');
703
845
  style.textContent = `
846
+ .kiokuko-numbered-picker{min-width:0;position:relative}
847
+ .kiokuko-numbered-options{border:1px solid var(--dsw-alias-border-l4,#bbb);padding:8px;border-radius:8px;max-height:50dvh;overflow:auto;min-width:0}
848
+ .kiokuko-numbered-options[hidden]{display:none}
849
+ .kiokuko-numbered-options button{display:flex;gap:12px;align-items:center;justify-content:space-between;overflow-wrap:anywhere;text-align:left;min-height:40px;width:100%}
850
+ .kiokuko-numbered-options kbd{white-space:nowrap;font:inherit;font-size:11px}
851
+ .kiokuko-numbered-options [aria-pressed=true]{outline:1px solid currentColor}
852
+ .kiokuko-numbered-picker :focus-visible{outline:2px solid Highlight;outline-offset:2px}
704
853
  .kiokuko-intake{box-sizing:border-box;width:100%;max-width:680px;align-self:center;margin-inline:auto;border:1px solid var(--dsw-alias-border-l4,#bbb);border-radius:12px;padding:12px;background:var(--dsw-alias-background-primary,Canvas);color:var(--dsw-alias-label-primary,CanvasText);display:flex;flex-direction:column;min-height:0;max-height:min(480px,70dvh);gap:8px;font-size:14px}
705
854
  .kiokuko-intake header,.kiokuko-intake footer{display:flex;gap:8px;align-items:center;flex-wrap:wrap;flex-shrink:0}
706
855
  .kiokuko-intake h2{font-size:16px;line-height:1.4;margin:0;flex:1}
@@ -712,12 +861,13 @@ window.__ModuleLoader__.load({
712
861
  .kiokuko-intake-option strong,.kiokuko-intake-option span{grid-column:1}
713
862
  .kiokuko-intake-shortcut{grid-column:2;grid-row:1 / span 2;align-self:center;font-family:inherit;font-size:11px;line-height:1.4;white-space:nowrap}
714
863
  .kiokuko-intake-option:last-child{margin-bottom:0}
715
- .kiokuko-intake-option[aria-pressed=true]{border-color:var(--dsw-alias-label-primary,CanvasText);box-shadow:inset 0 0 0 1px currentColor;background:var(--dsw-alias-interactive-bg-hover,#eee)}
864
+ .kiokuko-intake-option[aria-pressed=true],.kiokuko-intake-option[aria-checked=true]{border-color:var(--dsw-alias-label-primary,CanvasText);box-shadow:inset 0 0 0 1px currentColor;background:var(--dsw-alias-interactive-bg-hover,#eee)}
716
865
  .kiokuko-intake-option span{font-size:12px;line-height:1.4}
717
866
  .kiokuko-intake label{font-size:12px}
718
867
  .kiokuko-intake textarea{box-sizing:border-box;width:100%;min-height:36px;max-height:96px;resize:vertical;font:inherit;line-height:1.4;color:inherit;background:transparent;border:1px solid var(--dsw-alias-border-l4,#bbb);padding:8px;border-radius:8px}
719
868
  .kiokuko-intake :focus-visible,.kiokuko-intake:focus-visible{outline:2px solid Highlight;outline-offset:2px}
720
869
  .kiokuko-intake footer [role=status]{flex:1;min-width:0;font-size:12px;overflow-wrap:anywhere}
870
+ @media(max-width:640px){.kiokuko-intake{position:fixed;inset:auto 8px 8px;width:auto;max-width:none;max-height:min(480px,75dvh);z-index:10}}
721
871
  `;
722
872
  document.head.appendChild(style);
723
873
  return () => style.remove();
@@ -979,6 +1129,97 @@ window.__ModuleLoader__.load({
979
1129
  ] }, hunk.id);
980
1130
  }) });
981
1131
  }
1132
+ /** A local selection surface: choosing never starts the consumer's operation. */
1133
+ function NumberedPicker(props) {
1134
+ const [open, setOpen] = useState(false), [index, setIndex] = useState(-1), [ordinal, setOrdinal] = useState('');
1135
+ const buffer = useRef(''), selected = useRef(-1), group = useRef(null), trigger = useRef(null);
1136
+ const shown = props.inline || open;
1137
+ const close = () => { setOpen(false); buffer.current = ''; setOrdinal(''); trigger.current?.focus(); };
1138
+ useEffect(() => { if (shown && !props.inline)
1139
+ group.current?.focus(); }, [shown, props.inline]);
1140
+ const pick = (n, confirm) => {
1141
+ const option = props.options[n];
1142
+ if (!option || option.disabled && !props.onToggle)
1143
+ return;
1144
+ selected.current = n;
1145
+ setIndex(n);
1146
+ if (props.inline || confirm)
1147
+ props.onChoose(option.value);
1148
+ if (confirm && !props.inline)
1149
+ close();
1150
+ };
1151
+ const keys = (e) => {
1152
+ if (!shown || props.disabled || composingKey(e) || editingKey(e) || e.altKey || e.shiftKey || e.defaultPrevented)
1153
+ return;
1154
+ if (e.key === 'Escape' && !props.inline) {
1155
+ e.preventDefault();
1156
+ e.stopPropagation();
1157
+ close();
1158
+ return;
1159
+ }
1160
+ const optionButton = e.target?.closest?.('button[data-kiokuko-option]');
1161
+ if (e.key === ' ' && props.onToggle && optionButton && !e.ctrlKey && !e.metaKey) {
1162
+ e.preventDefault();
1163
+ e.stopPropagation();
1164
+ const option = props.options[Number(optionButton.dataset.kiokukoOption)];
1165
+ if (option && !option.disabled)
1166
+ props.onToggle(option.value);
1167
+ return;
1168
+ }
1169
+ if ((e.key === 'Enter' || e.key === ' ') && e.target?.tagName === 'BUTTON')
1170
+ return;
1171
+ const modified = modifiedKey(e);
1172
+ if ((e.ctrlKey || e.metaKey) && !modified)
1173
+ return;
1174
+ const digit = /^(?:Digit|Numpad)([0-9])$/u.exec(e.code ?? '')?.[1] ?? e.key;
1175
+ if (/^[0-9]$/u.test(digit) && (!modified || digit !== '0') || !modified && e.key === 'Backspace' && buffer.current) {
1176
+ e.preventDefault();
1177
+ e.stopPropagation();
1178
+ buffer.current = modified || props.options.length <= 9 ? digit : e.key === 'Backspace' ? buffer.current.slice(0, -1) : (buffer.current + digit).slice(0, 9);
1179
+ setOrdinal(buffer.current);
1180
+ pick(Number(buffer.current) - 1, false);
1181
+ return;
1182
+ }
1183
+ if (modified)
1184
+ return;
1185
+ if (e.key === 'Enter' || e.key === ' ' && props.onToggle) {
1186
+ e.preventDefault();
1187
+ e.stopPropagation();
1188
+ const n = buffer.current ? Number(buffer.current) - 1 : selected.current;
1189
+ const item = props.options[n];
1190
+ if (!item)
1191
+ return;
1192
+ if (e.key === ' ') {
1193
+ if (!item.disabled)
1194
+ props.onToggle?.(item.value);
1195
+ }
1196
+ else
1197
+ pick(n, true);
1198
+ buffer.current = '';
1199
+ setOrdinal('');
1200
+ }
1201
+ };
1202
+ useShortcutGroup(group, keys);
1203
+ // Keep the group mounted so a popup opened after its parent mounts acquires
1204
+ // the same document listener and relinquishes ownership when hidden.
1205
+ return jsxs('div', { className: 'kiokuko-numbered-picker', children: [
1206
+ !props.inline ? jsx('button', { type: 'button', ref: trigger, disabled: props.disabled, 'aria-haspopup': 'dialog', 'aria-expanded': open,
1207
+ onClick: () => setOpen(!open), children: `${props.label}: ${props.options.find(o => o.value === props.value)?.label ?? '選択'}` }) : null,
1208
+ jsxs('section', { hidden: !shown, tabIndex: 0, ref: (el) => { group.current = el; },
1209
+ 'data-kiokuko-shortcuts': '', role: props.inline ? 'group' : 'dialog', 'aria-label': props.label, className: 'kiokuko-numbered-options', onKeyDown: keys,
1210
+ children: [jsx('p', { children: `${props.label} — 数字 / ${shortcutPlatform().label}+1〜9で選択。${props.inline ? 'Spaceでチェック切替。' : 'Enterで確定、Escで閉じる。'}10以上は連続入力、Backspaceで訂正。` }),
1211
+ jsx('p', { role: 'status', children: ordinal ? `入力番号: ${ordinal}${Number(ordinal) < 1 || Number(ordinal) > props.options.length ? '(範囲外)' : ''}` : '' }),
1212
+ ...props.options.map((o, n) => jsxs('div', { className: 'kiokuko-review-file-row', children: [
1213
+ props.onToggle ? jsx('input', { type: 'checkbox', checked: props.checked?.includes(o.value) ?? false, disabled: props.disabled || o.disabled,
1214
+ 'aria-label': `${o.label} を選択対象にする`, onChange: () => props.onToggle?.(o.value) }) : null,
1215
+ jsxs('button', { type: 'button', 'data-kiokuko-option': n, disabled: props.disabled || !props.onToggle && o.disabled, 'aria-pressed': (index < 0 ? props.value === o.value : index === n), ...shortcutAria(n, props.options.length),
1216
+ onClick: () => { buffer.current = ''; setOrdinal(''); pick(n, !props.inline); },
1217
+ children: [jsx('span', { children: `${n + 1}. ${o.label}` }), jsx('kbd', { children: shortcutLabel(n, !!props.onToggle) })] }),
1218
+ ] }, o.value)),
1219
+ !props.inline ? jsx('button', { type: 'button', onClick: close, children: '閉じる(Esc)' }) : null,
1220
+ ] }),
1221
+ ] });
1222
+ }
982
1223
  function DiffReviewTab(props) {
983
1224
  const sessionId = String(props.sessionId);
984
1225
  const useReview = props.useDiffReview;
@@ -1033,23 +1274,30 @@ window.__ModuleLoader__.load({
1033
1274
  return jsxs('section', { className: 'kiokuko-review', 'aria-label': 'Diff レビュー', children: [
1034
1275
  jsx('h2', { children: 'Diff レビュー' }),
1035
1276
  jsxs('div', { className: 'kiokuko-review-controls', children: [
1036
- jsxs('label', { children: ['比較対象', jsx('select', { value: state.mode, onChange: (event) => controller.change(sessionId, { mode: event.target.value }), children: [
1037
- jsx('option', { value: 'current', children: '未コミット全体' }), jsx('option', { value: 'staged', children: 'ステージ済み' }),
1038
- jsx('option', { value: 'unstaged', children: '未ステージ' }), jsx('option', { value: 'turn', children: 'このターン' }),
1039
- ] })] }),
1040
- ...(state.mode === 'turn' ? [jsxs('label', { children: ['ターン差分', jsx('select', { value: String(state.turnSeq ?? ''), onChange: (event) => controller.change(sessionId, { turnSeq: event.target.value === '' ? undefined : Number(event.target.value) }),
1041
- children: [jsx('option', { value: '', children: '選択' }), ...state.turns.map(seq => jsx('option', { value: String(seq), children: `seq ${seq}` }, seq))] })] }, 'turn')] : []),
1277
+ jsx(NumberedPicker, { label: '比較対象', value: state.mode, options: [
1278
+ { value: 'current', label: '未コミット全体' }, { value: 'staged', label: 'ステージ済み' },
1279
+ { value: 'unstaged', label: '未ステージ' }, { value: 'turn', label: 'このターン' }
1280
+ ],
1281
+ onChoose: (value) => controller.change(sessionId, { mode: value }) }),
1282
+ ...(state.mode === 'turn' ? [jsx(NumberedPicker, { label: 'ターン差分', value: String(state.turnSeq ?? ''),
1283
+ options: [{ value: '', label: '選択' }, ...state.turns.map(seq => ({ value: String(seq), label: `seq ${seq}` }))],
1284
+ onChoose: (value) => controller.change(sessionId, { turnSeq: value === '' ? undefined : Number(value) }) }, 'turn')] : []),
1042
1285
  jsx('button', { type: 'button', disabled: state.busy || state.loading || state.cancelling || review?.state === 'analyzing' || state.mode === 'turn' && state.turnSeq === undefined, onClick: () => void controller.capture(sessionId), children: review ? '取得し直す' : '差分を取得' }),
1043
1286
  review ? jsx('button', { type: 'button', disabled: state.loading || state.cancelling || state.busy && review.state !== 'analyzing', onClick: () => void controller.refreshReview(sessionId, review.reviewId), children: '状態を確認' }) : null,
1044
1287
  ] }),
1045
- state.untracked.length ? jsxs('fieldset', { children: [jsx('legend', { children: '未追跡ファイル(明示選択)' }), ...state.untracked.map(path => jsxs('label', { children: [jsx('input', { type: 'checkbox', checked: state.selectedUntracked.includes(path), onChange: () => controller.change(sessionId, { selectedUntracked: state.selectedUntracked.includes(path) ? state.selectedUntracked.filter(item => item !== path) : [...state.selectedUntracked, path] }) }), path] }, path))] }) : null,
1288
+ state.untracked.length ? jsx(NumberedPicker, { label: '未追跡ファイル(明示選択)', inline: true,
1289
+ options: state.untracked.map(path => ({ value: path, label: path })), checked: state.selectedUntracked,
1290
+ onChoose: () => { }, onToggle: (path) => controller.change(sessionId, { selectedUntracked: state.selectedUntracked.includes(path)
1291
+ ? state.selectedUntracked.filter(item => item !== path) : [...state.selectedUntracked, path] }) }) : null,
1046
1292
  jsx('p', { role: 'status', 'aria-live': 'polite', children: state.cancelling ? '分析を停止中' : state.loading ? '確認中' : state.busy ? '処理中' : state.error || (state.availability === 'repo_unavailable' ? 'Git リポジトリを確認できません' : '') }),
1047
1293
  review ? jsxs(Fragment, { children: [
1048
1294
  jsx('p', { children: `${review.snapshot.capturedAt} / ${review.snapshot.mode} / ${REVIEW_STATES[review.state] ?? review.state} / 鮮度: ${REVIEW_FRESHNESS[review.freshness] ?? review.freshness}` }),
1049
1295
  jsx('p', { children: `文脈: ${REVIEW_CONTEXT_SOURCES[review.context.source] ?? review.context.source} / メモリ: ${REVIEW_MEMORY_STATES[review.context.memory] ?? review.context.memory}${review.context.reason ? ` (${review.context.reason})` : ''}` }),
1050
1296
  review.context.task ? jsx('p', { children: `タスク: ${review.context.task}` }) : null,
1051
1297
  review.context.reviewInput ? jsx('p', { children: `このレビューの目的(利用者入力): ${review.context.reviewInput}` }) : null,
1052
- ...(review.context.candidates?.length ? [jsxs('label', { children: ['タスク', jsx('select', { value: state.runId, onChange: (event) => controller.change(sessionId, { runId: event.target.value }), children: [jsx('option', { value: '', children: '指定なし' }), ...review.context.candidates.map(item => jsx('option', { value: item.runId, children: `${item.task} (${item.status})` }, item.runId))] })] }, 'runs')] : []),
1298
+ ...(review.context.candidates?.length ? [jsx(NumberedPicker, { label: 'タスク', value: state.runId,
1299
+ options: [{ value: '', label: '指定なし' }, ...review.context.candidates.map(item => ({ value: item.runId, label: `${item.task} (${item.status})` }))],
1300
+ onChoose: (value) => controller.change(sessionId, { runId: value }) }, 'runs')] : []),
1053
1301
  jsx('p', { children: `変更ファイル: ${review.snapshot.files.length} / ${review.snapshot.totalFiles}` }),
1054
1302
  review.snapshot.totalFiles > review.snapshot.files.length ? jsx('p', { role: 'status', children: `${review.snapshot.totalFiles - review.snapshot.files.length} 件は取得上限で未収集です。全体リスクは未確定です。` }) : null,
1055
1303
  jsx('p', { children: `送信対象: ${state.selected.length} ファイル / ${review.snapshot.files.filter(file => file.kind !== 'text').length} ファイルはテキスト分析対象外` }),
@@ -1057,8 +1305,9 @@ window.__ModuleLoader__.load({
1057
1305
  !state.selected.length ? jsx('p', { role: 'status', children: review.snapshot.files.some(file => file.kind === 'text')
1058
1306
  ? '分析するテキストファイルを変更ファイル一覧で選択してください。' : '分析できるテキストファイルがありません。差分は引き続き確認できます。' }) : null,
1059
1307
  jsxs('div', { className: 'kiokuko-review-analysis-controls', children: [
1060
- jsxs('label', { children: ['モデル', jsx('select', { value: state.modelKey, disabled: state.busy || state.loading || state.cancelling, onChange: (event) => controller.change(sessionId, { modelKey: event.target.value }),
1061
- children: [jsx('option', { value: '', children: '選択' }), ...state.models.map(item => jsx('option', { value: JSON.stringify([item.provider, item.model]), children: `${item.provider}/${item.model}` }, `${item.provider}:${item.model}`))] })] }),
1308
+ jsx(NumberedPicker, { label: 'モデル', value: state.modelKey, disabled: state.busy || state.loading || state.cancelling,
1309
+ options: [{ value: '', label: '選択' }, ...state.models.map(item => ({ value: JSON.stringify([item.provider, item.model]), label: `${item.provider}/${item.model}` }))],
1310
+ onChoose: (value) => controller.change(sessionId, { modelKey: value }) }),
1062
1311
  jsxs('label', { children: ['レビューの目的(任意)', jsx('textarea', { value: state.purpose, disabled: state.busy || state.loading || state.cancelling, maxLength: 4000, onChange: (event) => controller.change(sessionId, { purpose: event.target.value }) })] }),
1063
1312
  jsx('button', { type: 'button', disabled: state.busy || state.loading || state.cancelling || review.state === 'analyzing' || !reviewMatchesSelection || !state.selected.length || !state.modelKey, onClick: () => void controller.analyze(sessionId), children: '分析する' }),
1064
1313
  review.state === 'analyzing' ? jsx('button', { type: 'button', disabled: state.cancelling, onClick: () => void controller.cancel(sessionId, review.reviewId), children: state.cancelling ? '停止中' : '停止' }) : null,
@@ -1072,17 +1321,16 @@ window.__ModuleLoader__.load({
1072
1321
  ] }) : null,
1073
1322
  jsx('button', { type: 'button', className: 'kiokuko-review-list-toggle', onClick: () => {
1074
1323
  controller.change(sessionId, { fileListOpen: !state.fileListOpen });
1075
- requestAnimationFrame(() => document.getElementById(`kiokuko-review-${review.reviewId}-${state.fileListOpen ? 'detail' : 'files'}`)?.focus());
1324
+ requestAnimationFrame(() => {
1325
+ const pane = document.getElementById(`kiokuko-review-${review.reviewId}-${state.fileListOpen ? 'detail' : 'files'}`);
1326
+ (pane?.querySelector('[data-kiokuko-shortcuts]') ?? pane)?.focus();
1327
+ });
1076
1328
  },
1077
1329
  children: state.fileListOpen ? '差分に戻る' : '変更ファイル一覧' }),
1078
1330
  jsxs('div', { className: 'kiokuko-review-main', 'data-list-open': String(state.fileListOpen), children: [
1079
- jsxs('nav', { id: `kiokuko-review-${review.reviewId}-files`, tabIndex: -1, 'aria-label': '変更ファイル', children: [jsx('h3', { children: '変更ファイル' }), ...review.snapshot.files.map(file => jsxs('div', { className: 'kiokuko-review-file-row', children: [
1080
- jsx('input', { type: 'checkbox', 'aria-label': `${file.displayPath} を分析対象にする`, checked: state.selected.includes(file.fileId), disabled: file.kind !== 'text', onChange: () => selectFile(file.fileId) }),
1081
- jsx('button', { type: 'button', 'aria-current': active?.fileId === file.fileId ? 'true' : undefined, onClick: () => {
1082
- controller.change(sessionId, { activeFileId: file.fileId, fileListOpen: false });
1083
- requestAnimationFrame(() => document.getElementById(`kiokuko-review-${review.reviewId}-detail`)?.focus());
1084
- }, children: `${file.displayPath} (${file.layer}, ${file.kind})` }),
1085
- ] }, file.fileId))] }),
1331
+ jsx('nav', { id: `kiokuko-review-${review.reviewId}-files`, tabIndex: -1, 'aria-label': '変更ファイル', children: jsx(NumberedPicker, { label: '変更ファイル', inline: true, value: active?.fileId, checked: state.selected,
1332
+ options: review.snapshot.files.map(file => ({ value: file.fileId, label: `${file.displayPath} (${file.layer}, ${file.kind})`, disabled: file.kind !== 'text' })),
1333
+ onChoose: (fileId) => controller.change(sessionId, { activeFileId: fileId }), onToggle: selectFile }) }),
1086
1334
  active ? jsxs('article', { id: `kiokuko-review-${review.reviewId}-detail`, tabIndex: -1, children: [jsx('h3', { children: active.displayPath }),
1087
1335
  active.kind === 'text' && active.newDigest && review.snapshot.mode !== 'turn' ? jsx('button', { type: 'button',
1088
1336
  onClick: () => void controller.openCurrentFile(sessionId, review.reviewId, active.fileId, openFile), children: '現在のファイルへ' }) : null,