pi-interview 0.6.2 → 0.8.1
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/README.md +5 -3
- package/form/script.js +1282 -327
- package/form/styles.css +465 -17
- package/index.ts +415 -62
- package/package.json +1 -1
- package/server.ts +612 -81
package/form/script.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
const cwd = data.cwd || "";
|
|
7
7
|
const gitBranch = data.gitBranch || "";
|
|
8
8
|
const timeout = typeof data.timeout === "number" ? data.timeout : 0;
|
|
9
|
+
const askModels = Array.isArray(data.askModels)
|
|
10
|
+
? data.askModels.filter((model) => model && typeof model.value === "string" && typeof model.provider === "string")
|
|
11
|
+
: [];
|
|
12
|
+
const defaultAskModel = typeof data.defaultAskModel === "string" ? data.defaultAskModel : null;
|
|
9
13
|
|
|
10
14
|
const titleEl = document.getElementById("form-title");
|
|
11
15
|
const descriptionEl = document.getElementById("form-description");
|
|
@@ -37,6 +41,12 @@
|
|
|
37
41
|
const imagePathState = new Map();
|
|
38
42
|
const attachState = new Map();
|
|
39
43
|
const attachPathState = new Map();
|
|
44
|
+
const optionKeyState = new Map();
|
|
45
|
+
const choiceNoteState = new Map();
|
|
46
|
+
const optionInsightState = {
|
|
47
|
+
active: null,
|
|
48
|
+
pinned: new Map(),
|
|
49
|
+
};
|
|
40
50
|
const nav = {
|
|
41
51
|
questionIndex: 0,
|
|
42
52
|
optionIndex: 0,
|
|
@@ -76,6 +86,12 @@
|
|
|
76
86
|
dismissed: false,
|
|
77
87
|
knownIds: new Set(),
|
|
78
88
|
};
|
|
89
|
+
const ASK_PROMPT_CHIPS = [
|
|
90
|
+
{ key: "explain", label: "Explain this", prompt: "Explain this better." },
|
|
91
|
+
{ key: "why", label: "Why this option?", prompt: "Why is this option like that?" },
|
|
92
|
+
{ key: "tradeoffs", label: "Tradeoffs", prompt: "What are the tradeoffs of this option?" },
|
|
93
|
+
{ key: "fail", label: "When would this fail?", prompt: "When would this option fail or be the wrong choice?" },
|
|
94
|
+
];
|
|
79
95
|
|
|
80
96
|
function updateCountdownBadge(secondsLeft, totalSeconds) {
|
|
81
97
|
if (!countdownBadge || !countdownValue || !countdownRingProgress) return;
|
|
@@ -513,15 +529,111 @@
|
|
|
513
529
|
return typeof option === "string" ? option : option.label;
|
|
514
530
|
}
|
|
515
531
|
|
|
532
|
+
function questionCanClarifyOption(question) {
|
|
533
|
+
return (question.type === "single" || question.type === "multi")
|
|
534
|
+
&& Array.isArray(question.options)
|
|
535
|
+
&& question.options.length > 0
|
|
536
|
+
&& question.options.every((option) => typeof option === "string");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function isChoiceResponseValue(value) {
|
|
540
|
+
return value && typeof value === "object" && !Array.isArray(value) && typeof value.option === "string";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function normalizeChoiceResponseValue(value) {
|
|
544
|
+
if (!isChoiceResponseValue(value)) return null;
|
|
545
|
+
const option = value.option.trim();
|
|
546
|
+
if (!option) return null;
|
|
547
|
+
const note = typeof value.note === "string" ? value.note.trim() : "";
|
|
548
|
+
return note ? { option, note } : { option };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function renameChoiceAnswerValue(question, value, previousOption, nextOption) {
|
|
552
|
+
if (!nextOption || (question.type !== "single" && question.type !== "multi")) {
|
|
553
|
+
return value;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (question.type === "single") {
|
|
557
|
+
const choiceValue = normalizeChoiceResponseValue(value);
|
|
558
|
+
if (!choiceValue || choiceValue.option !== previousOption) return value;
|
|
559
|
+
return choiceValue.note ? { option: nextOption, note: choiceValue.note } : { option: nextOption };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (!Array.isArray(value)) return value;
|
|
563
|
+
return value.map((item) => {
|
|
564
|
+
const choiceValue = normalizeChoiceResponseValue(item);
|
|
565
|
+
if (!choiceValue || choiceValue.option !== previousOption) return item;
|
|
566
|
+
return choiceValue.note ? { option: nextOption, note: choiceValue.note } : { option: nextOption };
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function preserveChoiceAnswerValue(question, value, validLabels) {
|
|
571
|
+
if (question.type === "single") {
|
|
572
|
+
const choiceValue = normalizeChoiceResponseValue(value);
|
|
573
|
+
if (!choiceValue || !validLabels.has(choiceValue.option)) return "";
|
|
574
|
+
return choiceValue;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (question.type === "multi") {
|
|
578
|
+
if (!Array.isArray(value)) return [];
|
|
579
|
+
return value
|
|
580
|
+
.map((item) => normalizeChoiceResponseValue(item))
|
|
581
|
+
.filter((item) => item && validLabels.has(item.option));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return value;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function getChoiceNotes(questionId) {
|
|
588
|
+
return choiceNoteState.get(questionId) || new Map();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function getChoiceNote(questionId, optionLabel) {
|
|
592
|
+
return getChoiceNotes(questionId).get(optionLabel) || "";
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function setChoiceNote(questionId, optionLabel, note) {
|
|
596
|
+
const normalizedNote = typeof note === "string" ? note.trim() : "";
|
|
597
|
+
const existing = choiceNoteState.get(questionId) || new Map();
|
|
598
|
+
if (!normalizedNote) {
|
|
599
|
+
existing.delete(optionLabel);
|
|
600
|
+
if (existing.size === 0) {
|
|
601
|
+
choiceNoteState.delete(questionId);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
choiceNoteState.set(questionId, existing);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
existing.set(optionLabel, normalizedNote);
|
|
608
|
+
choiceNoteState.set(questionId, existing);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function clearChoiceNotes(questionId) {
|
|
612
|
+
choiceNoteState.delete(questionId);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function getSelectedOptionLabels(questionId) {
|
|
616
|
+
return Array.from(
|
|
617
|
+
formEl.querySelectorAll(`input[name="${escapeSelector(questionId)}"]:checked`)
|
|
618
|
+
)
|
|
619
|
+
.map((input) => input.value)
|
|
620
|
+
.filter((value) => value && value !== "__other__");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function syncChoiceNotesWithSelection(question) {
|
|
624
|
+
if (!questionCanClarifyOption(question)) return;
|
|
625
|
+
}
|
|
626
|
+
|
|
516
627
|
function isRichOption(option) {
|
|
517
628
|
return typeof option === "object" && option !== null && "label" in option;
|
|
518
629
|
}
|
|
519
630
|
|
|
520
631
|
function syncRecommendations(question, options) {
|
|
632
|
+
const optionLabels = options.map((option) => getOptionLabel(option));
|
|
521
633
|
if (!question.recommended) return;
|
|
522
634
|
|
|
523
635
|
if (question.type === "single") {
|
|
524
|
-
if (typeof question.recommended === "string" &&
|
|
636
|
+
if (typeof question.recommended === "string" && optionLabels.includes(question.recommended)) {
|
|
525
637
|
return;
|
|
526
638
|
}
|
|
527
639
|
delete question.recommended;
|
|
@@ -537,7 +649,7 @@
|
|
|
537
649
|
|
|
538
650
|
const nextRecommended = (Array.isArray(question.recommended)
|
|
539
651
|
? question.recommended
|
|
540
|
-
: [question.recommended]).filter((option) =>
|
|
652
|
+
: [question.recommended]).filter((option) => optionLabels.includes(option));
|
|
541
653
|
if (nextRecommended.length === 0) {
|
|
542
654
|
delete question.recommended;
|
|
543
655
|
delete question.conviction;
|
|
@@ -546,6 +658,983 @@
|
|
|
546
658
|
question.recommended = nextRecommended;
|
|
547
659
|
}
|
|
548
660
|
|
|
661
|
+
function makeClientId(prefix = "id") {
|
|
662
|
+
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
|
663
|
+
return `${prefix}-${window.crypto.randomUUID()}`;
|
|
664
|
+
}
|
|
665
|
+
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function questionSupportsOptionInsights(question) {
|
|
669
|
+
return (question.type === "single" || question.type === "multi") &&
|
|
670
|
+
Array.isArray(question.options) &&
|
|
671
|
+
question.options.length > 0;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function questionCanAskAboutOption(question) {
|
|
675
|
+
return !!data.canGenerate && questionSupportsOptionInsights(question);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function normalizeOptionKeysFromData() {
|
|
679
|
+
const raw = data.optionKeysByQuestion && typeof data.optionKeysByQuestion === "object"
|
|
680
|
+
? data.optionKeysByQuestion
|
|
681
|
+
: {};
|
|
682
|
+
|
|
683
|
+
questions.forEach((question) => {
|
|
684
|
+
if (!questionSupportsOptionInsights(question)) return;
|
|
685
|
+
const rawKeys = Array.isArray(raw[question.id]) ? raw[question.id] : [];
|
|
686
|
+
const keys = rawKeys.length === question.options.length && rawKeys.every((key) => typeof key === "string" && key)
|
|
687
|
+
? [...rawKeys]
|
|
688
|
+
: question.options.map(() => makeClientId(`opt-${question.id}`));
|
|
689
|
+
optionKeyState.set(question.id, keys);
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function getOptionKeys(questionId) {
|
|
694
|
+
return optionKeyState.get(questionId) || [];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function setOptionKeys(questionId, keys) {
|
|
698
|
+
optionKeyState.set(questionId, Array.isArray(keys) ? [...keys] : []);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function getOptionIndexByKey(questionId, optionKey) {
|
|
702
|
+
return getOptionKeys(questionId).indexOf(optionKey);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function getOptionTextByKey(questionId, optionKey) {
|
|
706
|
+
const question = questions.find((q) => q.id === questionId);
|
|
707
|
+
if (!question || !Array.isArray(question.options)) return "";
|
|
708
|
+
const index = getOptionIndexByKey(questionId, optionKey);
|
|
709
|
+
if (index < 0 || index >= question.options.length) return "";
|
|
710
|
+
return getOptionLabel(question.options[index]);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function providerLabel(provider) {
|
|
714
|
+
if (!provider) return "";
|
|
715
|
+
if (provider === "openai") return "OpenAI";
|
|
716
|
+
if (provider === "google") return "Google";
|
|
717
|
+
if (provider === "anthropic") return "Anthropic";
|
|
718
|
+
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function parseModelValue(modelValue) {
|
|
722
|
+
if (typeof modelValue !== "string") return { provider: "", model: "" };
|
|
723
|
+
const slashIndex = modelValue.indexOf("/");
|
|
724
|
+
if (slashIndex <= 0 || slashIndex === modelValue.length - 1) {
|
|
725
|
+
return { provider: "", model: modelValue };
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
provider: modelValue.slice(0, slashIndex),
|
|
729
|
+
model: modelValue.slice(slashIndex + 1),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function getModelsForProvider(provider) {
|
|
734
|
+
return askModels.filter((model) => model.provider === provider);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function getFirstProvider() {
|
|
738
|
+
return askModels[0]?.provider || "";
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const ASK_DEPTH_OPTIONS = [
|
|
742
|
+
{ key: "quick", label: "Quick" },
|
|
743
|
+
{ key: "standard", label: "Standard" },
|
|
744
|
+
{ key: "deep", label: "Deep" },
|
|
745
|
+
];
|
|
746
|
+
|
|
747
|
+
function createDefaultActiveInsight(questionId, optionKey) {
|
|
748
|
+
const selectedModel = askModels.some((model) => model.value === defaultAskModel)
|
|
749
|
+
? defaultAskModel
|
|
750
|
+
: (askModels[0]?.value || null);
|
|
751
|
+
const parsed = parseModelValue(selectedModel);
|
|
752
|
+
return {
|
|
753
|
+
questionId,
|
|
754
|
+
optionKey,
|
|
755
|
+
prompt: "",
|
|
756
|
+
selectedChip: null,
|
|
757
|
+
loading: false,
|
|
758
|
+
error: "",
|
|
759
|
+
result: null,
|
|
760
|
+
advancedOpen: false,
|
|
761
|
+
selectedProvider: parsed.provider || getFirstProvider(),
|
|
762
|
+
selectedModel,
|
|
763
|
+
selectedDepth: "standard",
|
|
764
|
+
abortController: null,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function getActiveInsight(questionId, optionKey) {
|
|
769
|
+
const active = optionInsightState.active;
|
|
770
|
+
return active && active.questionId === questionId && active.optionKey === optionKey
|
|
771
|
+
? active
|
|
772
|
+
: null;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function getPinnedInsights(questionId, optionKey) {
|
|
776
|
+
const questionInsights = optionInsightState.pinned.get(questionId) || [];
|
|
777
|
+
return questionInsights.filter((insight) => insight.optionKey === optionKey);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function normalizeSavedOptionInsights(input) {
|
|
781
|
+
if (!Array.isArray(input)) return [];
|
|
782
|
+
return input
|
|
783
|
+
.filter((item) => item && typeof item === "object")
|
|
784
|
+
.map((item) => ({
|
|
785
|
+
id: typeof item.id === "string" ? item.id : makeClientId("insight"),
|
|
786
|
+
questionId: typeof item.questionId === "string" ? item.questionId : "",
|
|
787
|
+
optionKey: typeof item.optionKey === "string" ? item.optionKey : "",
|
|
788
|
+
optionText: typeof item.optionText === "string" ? item.optionText : "",
|
|
789
|
+
prompt: typeof item.prompt === "string" ? item.prompt : "",
|
|
790
|
+
summary: typeof item.summary === "string" ? item.summary : "",
|
|
791
|
+
bullets: Array.isArray(item.bullets)
|
|
792
|
+
? item.bullets.filter((bullet) => typeof bullet === "string" && bullet.trim()).map((bullet) => bullet.trim())
|
|
793
|
+
: [],
|
|
794
|
+
suggestedText: typeof item.suggestedText === "string" ? item.suggestedText : undefined,
|
|
795
|
+
modelUsed: typeof item.modelUsed === "string" ? item.modelUsed : item.modelUsed === null ? null : undefined,
|
|
796
|
+
createdAt: typeof item.createdAt === "string" ? item.createdAt : new Date().toISOString(),
|
|
797
|
+
}))
|
|
798
|
+
.filter((item) => item.questionId && item.optionKey && item.summary);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function restoreSavedOptionInsights(input) {
|
|
802
|
+
optionInsightState.pinned.clear();
|
|
803
|
+
normalizeSavedOptionInsights(input).forEach((insight) => {
|
|
804
|
+
const existing = optionInsightState.pinned.get(insight.questionId) || [];
|
|
805
|
+
existing.push(insight);
|
|
806
|
+
optionInsightState.pinned.set(insight.questionId, existing);
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function serializeSavedOptionInsights() {
|
|
811
|
+
return Array.from(optionInsightState.pinned.values())
|
|
812
|
+
.flat()
|
|
813
|
+
.map((insight) => ({
|
|
814
|
+
id: insight.id,
|
|
815
|
+
questionId: insight.questionId,
|
|
816
|
+
optionKey: insight.optionKey,
|
|
817
|
+
optionText: insight.optionText,
|
|
818
|
+
prompt: insight.prompt,
|
|
819
|
+
summary: insight.summary,
|
|
820
|
+
bullets: Array.isArray(insight.bullets) ? [...insight.bullets] : [],
|
|
821
|
+
suggestedText: insight.suggestedText,
|
|
822
|
+
modelUsed: insight.modelUsed ?? null,
|
|
823
|
+
createdAt: insight.createdAt,
|
|
824
|
+
}));
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function removePinnedInsight(questionId, insightId) {
|
|
828
|
+
const existing = optionInsightState.pinned.get(questionId) || [];
|
|
829
|
+
const next = existing.filter((insight) => insight.id !== insightId);
|
|
830
|
+
if (next.length > 0) {
|
|
831
|
+
optionInsightState.pinned.set(questionId, next);
|
|
832
|
+
} else {
|
|
833
|
+
optionInsightState.pinned.delete(questionId);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function updatePinnedInsightOptionText(questionId, optionKey, optionText) {
|
|
838
|
+
const existing = optionInsightState.pinned.get(questionId) || [];
|
|
839
|
+
existing.forEach((insight) => {
|
|
840
|
+
if (insight.optionKey === optionKey) {
|
|
841
|
+
insight.optionText = optionText;
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function pruneQuestionOptionInsights(questionId) {
|
|
847
|
+
const validKeys = new Set(getOptionKeys(questionId));
|
|
848
|
+
const existing = optionInsightState.pinned.get(questionId) || [];
|
|
849
|
+
const next = existing.filter((insight) => validKeys.has(insight.optionKey));
|
|
850
|
+
if (next.length > 0) {
|
|
851
|
+
optionInsightState.pinned.set(questionId, next);
|
|
852
|
+
} else {
|
|
853
|
+
optionInsightState.pinned.delete(questionId);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const active = optionInsightState.active;
|
|
857
|
+
if (!active || active.questionId !== questionId || validKeys.has(active.optionKey)) {
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (active.abortController) {
|
|
861
|
+
active.abortController.abort();
|
|
862
|
+
}
|
|
863
|
+
optionInsightState.active = null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function closeOptionInsightPanel(questionId, optionKey) {
|
|
867
|
+
const active = optionInsightState.active;
|
|
868
|
+
if (!active) return;
|
|
869
|
+
if (questionId && active.questionId !== questionId) return;
|
|
870
|
+
if (optionKey && active.optionKey !== optionKey) return;
|
|
871
|
+
if (active.abortController) {
|
|
872
|
+
active.abortController.abort();
|
|
873
|
+
}
|
|
874
|
+
optionInsightState.active = null;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function openOptionInsightPanel(question, optionKey) {
|
|
878
|
+
if (!questionCanAskAboutOption(question)) return;
|
|
879
|
+
const currentValue = getQuestionValue(question);
|
|
880
|
+
const active = getActiveInsight(question.id, optionKey);
|
|
881
|
+
if (active) {
|
|
882
|
+
closeOptionInsightPanel(question.id, optionKey);
|
|
883
|
+
replaceQuestionOptionList(question, currentValue, optionKey);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const previousActive = optionInsightState.active;
|
|
888
|
+
if (previousActive?.abortController) {
|
|
889
|
+
previousActive.abortController.abort();
|
|
890
|
+
}
|
|
891
|
+
if (previousActive && (previousActive.questionId !== question.id || previousActive.optionKey !== optionKey)) {
|
|
892
|
+
const previousQuestion = questions.find((item) => item.id === previousActive.questionId);
|
|
893
|
+
if (previousQuestion) {
|
|
894
|
+
const previousValue = getQuestionValue(previousQuestion);
|
|
895
|
+
optionInsightState.active = null;
|
|
896
|
+
replaceQuestionOptionList(previousQuestion, previousValue, previousActive.optionKey);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
optionInsightState.active = createDefaultActiveInsight(question.id, optionKey);
|
|
901
|
+
replaceQuestionOptionList(question, currentValue, optionKey, { focusComposer: true });
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function getSelectedInsightModel(activeInsight) {
|
|
905
|
+
if (!activeInsight) return null;
|
|
906
|
+
return typeof activeInsight.selectedModel === "string" && activeInsight.selectedModel
|
|
907
|
+
? activeInsight.selectedModel
|
|
908
|
+
: defaultAskModel;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function getInsightModelLabel(activeInsight) {
|
|
912
|
+
const selectedModel = getSelectedInsightModel(activeInsight);
|
|
913
|
+
if (!selectedModel) return "No model selected";
|
|
914
|
+
const parsed = parseModelValue(selectedModel);
|
|
915
|
+
return `${providerLabel(parsed.provider)} / ${parsed.model}`;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function applyQuestionValue(question, value) {
|
|
919
|
+
populateForm({ [question.id]: value }, { preserveChoiceNotes: true });
|
|
920
|
+
if (question.type === "multi") {
|
|
921
|
+
updateDoneState(question.id);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function replaceQuestionOptionList(question, preserveValue, focusOptionKey, options = {}) {
|
|
926
|
+
const card = containerEl.querySelector(`.question-card[data-question-id="${escapeSelector(question.id)}"]`);
|
|
927
|
+
const currentList = card?.querySelector('.option-list');
|
|
928
|
+
const title = card?.querySelector('.question-title');
|
|
929
|
+
if (!card || !currentList || !title) return;
|
|
930
|
+
|
|
931
|
+
const nextList = createChoiceQuestionList(question, title, options);
|
|
932
|
+
currentList.replaceWith(nextList);
|
|
933
|
+
applyQuestionValue(question, preserveValue);
|
|
934
|
+
|
|
935
|
+
if (nav.cards[nav.questionIndex] === card && !nav.inSubmitArea && focusOptionKey) {
|
|
936
|
+
const optionIndex = getOptionIndexByKey(question.id, focusOptionKey);
|
|
937
|
+
if (optionIndex >= 0) {
|
|
938
|
+
nav.optionIndex = optionIndex;
|
|
939
|
+
highlightOption(card, optionIndex, false);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (options.focusComposer) {
|
|
944
|
+
requestAnimationFrame(() => {
|
|
945
|
+
const composer = card.querySelector(`.option-insight-input[data-question-id="${escapeSelector(question.id)}"][data-option-key="${escapeSelector(focusOptionKey || "")}"]`);
|
|
946
|
+
composer?.focus();
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function runOptionAction(question, optionKey, action, text) {
|
|
952
|
+
const preservedValue = getQuestionValue(question);
|
|
953
|
+
const previousText = getOptionTextByKey(question.id, optionKey);
|
|
954
|
+
try {
|
|
955
|
+
const response = await fetch("/option-action", {
|
|
956
|
+
method: "POST",
|
|
957
|
+
headers: { "Content-Type": "application/json" },
|
|
958
|
+
body: JSON.stringify({ token: sessionToken, questionId: question.id, optionKey, action, text }),
|
|
959
|
+
});
|
|
960
|
+
const result = await response.json();
|
|
961
|
+
if (!result.ok) throw new Error(result.error || "Option action failed");
|
|
962
|
+
|
|
963
|
+
if (result.question && Array.isArray(result.question.options)) {
|
|
964
|
+
question.options = result.question.options;
|
|
965
|
+
question.recommended = result.question.recommended;
|
|
966
|
+
question.conviction = result.question.conviction;
|
|
967
|
+
}
|
|
968
|
+
if (Array.isArray(result.optionKeys)) {
|
|
969
|
+
setOptionKeys(question.id, result.optionKeys);
|
|
970
|
+
pruneQuestionOptionInsights(question.id);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (action === "replace-text") {
|
|
974
|
+
const nextText = getOptionTextByKey(question.id, optionKey);
|
|
975
|
+
updatePinnedInsightOptionText(question.id, optionKey, nextText);
|
|
976
|
+
if (optionInsightState.active && optionInsightState.active.questionId === question.id && optionInsightState.active.optionKey === optionKey && optionInsightState.active.result) {
|
|
977
|
+
optionInsightState.active.result.suggestedText = nextText;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
let nextValue = preservedValue;
|
|
982
|
+
if (action === "replace-text" && text) {
|
|
983
|
+
nextValue = renameChoiceAnswerValue(question, preservedValue, previousText, text);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
replaceQuestionOptionList(question, nextValue, optionKey);
|
|
987
|
+
debounceSave();
|
|
988
|
+
refreshCountdown();
|
|
989
|
+
return true;
|
|
990
|
+
} catch (err) {
|
|
991
|
+
const active = getActiveInsight(question.id, optionKey);
|
|
992
|
+
if (active) {
|
|
993
|
+
active.error = err instanceof Error ? err.message : "Option action failed";
|
|
994
|
+
replaceQuestionOptionList(question, preservedValue, optionKey);
|
|
995
|
+
}
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async function submitOptionInsight(question, optionKey) {
|
|
1001
|
+
const active = getActiveInsight(question.id, optionKey);
|
|
1002
|
+
if (!active) return;
|
|
1003
|
+
const prompt = active.prompt.trim();
|
|
1004
|
+
if (!prompt) {
|
|
1005
|
+
active.error = "Prompt is required";
|
|
1006
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey, { focusComposer: true });
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (active.loading) {
|
|
1011
|
+
active.abortController?.abort();
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
active.loading = true;
|
|
1016
|
+
active.error = "";
|
|
1017
|
+
active.abortController = new AbortController();
|
|
1018
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1019
|
+
|
|
1020
|
+
try {
|
|
1021
|
+
const modelOverride = getSelectedInsightModel(active);
|
|
1022
|
+
const response = await fetch("/option-insight", {
|
|
1023
|
+
method: "POST",
|
|
1024
|
+
headers: { "Content-Type": "application/json" },
|
|
1025
|
+
body: JSON.stringify({
|
|
1026
|
+
token: sessionToken,
|
|
1027
|
+
questionId: question.id,
|
|
1028
|
+
optionKey,
|
|
1029
|
+
prompt,
|
|
1030
|
+
model: modelOverride && modelOverride !== defaultAskModel ? modelOverride : null,
|
|
1031
|
+
depth: active.selectedDepth || "standard",
|
|
1032
|
+
}),
|
|
1033
|
+
signal: active.abortController.signal,
|
|
1034
|
+
});
|
|
1035
|
+
const result = await response.json();
|
|
1036
|
+
if (!result.ok) throw new Error(result.error || "Option insight failed");
|
|
1037
|
+
active.result = {
|
|
1038
|
+
summary: result.summary,
|
|
1039
|
+
bullets: Array.isArray(result.bullets) ? result.bullets : [],
|
|
1040
|
+
suggestedText: typeof result.suggestedText === "string" ? result.suggestedText : undefined,
|
|
1041
|
+
modelUsed: typeof result.modelUsed === "string" ? result.modelUsed : null,
|
|
1042
|
+
};
|
|
1043
|
+
active.error = "";
|
|
1044
|
+
const optionText = typeof result.optionText === "string" ? result.optionText : getOptionTextByKey(question.id, optionKey);
|
|
1045
|
+
updatePinnedInsightOptionText(question.id, optionKey, optionText);
|
|
1046
|
+
refreshCountdown();
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
if (!(err instanceof Error && err.name === "AbortError")) {
|
|
1049
|
+
active.error = err instanceof Error ? err.message : "Option insight failed";
|
|
1050
|
+
}
|
|
1051
|
+
} finally {
|
|
1052
|
+
if (optionInsightState.active && optionInsightState.active.questionId === question.id && optionInsightState.active.optionKey === optionKey) {
|
|
1053
|
+
optionInsightState.active.loading = false;
|
|
1054
|
+
optionInsightState.active.abortController = null;
|
|
1055
|
+
}
|
|
1056
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function pinActiveInsight(question, optionKey) {
|
|
1061
|
+
const active = getActiveInsight(question.id, optionKey);
|
|
1062
|
+
if (!active || !active.result) return;
|
|
1063
|
+
const optionText = getOptionTextByKey(question.id, optionKey);
|
|
1064
|
+
const questionInsights = optionInsightState.pinned.get(question.id) || [];
|
|
1065
|
+
questionInsights.push({
|
|
1066
|
+
id: makeClientId("insight"),
|
|
1067
|
+
questionId: question.id,
|
|
1068
|
+
optionKey,
|
|
1069
|
+
optionText,
|
|
1070
|
+
prompt: active.prompt.trim(),
|
|
1071
|
+
summary: active.result.summary,
|
|
1072
|
+
bullets: Array.isArray(active.result.bullets) ? [...active.result.bullets] : [],
|
|
1073
|
+
suggestedText: active.result.suggestedText,
|
|
1074
|
+
modelUsed: active.result.modelUsed ?? null,
|
|
1075
|
+
createdAt: new Date().toISOString(),
|
|
1076
|
+
});
|
|
1077
|
+
optionInsightState.pinned.set(question.id, questionInsights);
|
|
1078
|
+
debounceSave();
|
|
1079
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function createPinnedInsightCard(question, optionKey, insight) {
|
|
1083
|
+
const card = document.createElement("div");
|
|
1084
|
+
card.className = "option-insight-pinned";
|
|
1085
|
+
|
|
1086
|
+
const head = document.createElement("div");
|
|
1087
|
+
head.className = "option-insight-pinned-head";
|
|
1088
|
+
|
|
1089
|
+
const prompt = document.createElement("div");
|
|
1090
|
+
prompt.className = "option-insight-pinned-prompt";
|
|
1091
|
+
prompt.textContent = insight.prompt;
|
|
1092
|
+
head.appendChild(prompt);
|
|
1093
|
+
|
|
1094
|
+
const unpin = document.createElement("button");
|
|
1095
|
+
unpin.type = "button";
|
|
1096
|
+
unpin.className = "option-insight-unpin";
|
|
1097
|
+
unpin.textContent = "Unpin";
|
|
1098
|
+
unpin.addEventListener("click", (event) => {
|
|
1099
|
+
event.preventDefault();
|
|
1100
|
+
event.stopPropagation();
|
|
1101
|
+
removePinnedInsight(question.id, insight.id);
|
|
1102
|
+
debounceSave();
|
|
1103
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1104
|
+
});
|
|
1105
|
+
head.appendChild(unpin);
|
|
1106
|
+
card.appendChild(head);
|
|
1107
|
+
|
|
1108
|
+
const summary = document.createElement("p");
|
|
1109
|
+
summary.className = "option-insight-summary pinned";
|
|
1110
|
+
summary.textContent = insight.summary;
|
|
1111
|
+
card.appendChild(summary);
|
|
1112
|
+
|
|
1113
|
+
if (Array.isArray(insight.bullets) && insight.bullets.length > 0) {
|
|
1114
|
+
const list = document.createElement("ul");
|
|
1115
|
+
list.className = "option-insight-bullets";
|
|
1116
|
+
insight.bullets.forEach((bullet) => {
|
|
1117
|
+
const item = document.createElement("li");
|
|
1118
|
+
item.textContent = bullet;
|
|
1119
|
+
list.appendChild(item);
|
|
1120
|
+
});
|
|
1121
|
+
card.appendChild(list);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (insight.suggestedText) {
|
|
1125
|
+
const suggestion = document.createElement("code");
|
|
1126
|
+
suggestion.className = "option-insight-suggested-text compact";
|
|
1127
|
+
suggestion.textContent = insight.suggestedText;
|
|
1128
|
+
card.appendChild(suggestion);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (insight.modelUsed) {
|
|
1132
|
+
const meta = document.createElement("div");
|
|
1133
|
+
meta.className = "option-insight-meta";
|
|
1134
|
+
meta.textContent = insight.modelUsed;
|
|
1135
|
+
card.appendChild(meta);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
return card;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function createOptionInsightPanel(question, optionKey) {
|
|
1142
|
+
const active = getActiveInsight(question.id, optionKey);
|
|
1143
|
+
if (!active) return null;
|
|
1144
|
+
|
|
1145
|
+
const panel = document.createElement("div");
|
|
1146
|
+
panel.className = "option-insight-panel";
|
|
1147
|
+
panel.dataset.optionInsightFor = optionKey;
|
|
1148
|
+
|
|
1149
|
+
const chips = document.createElement("div");
|
|
1150
|
+
chips.className = "option-insight-chips";
|
|
1151
|
+
ASK_PROMPT_CHIPS.forEach((chip) => {
|
|
1152
|
+
const btn = document.createElement("button");
|
|
1153
|
+
btn.type = "button";
|
|
1154
|
+
btn.className = "option-insight-chip" + (active.selectedChip === chip.key ? " active" : "");
|
|
1155
|
+
btn.textContent = chip.label;
|
|
1156
|
+
btn.addEventListener("click", (event) => {
|
|
1157
|
+
event.preventDefault();
|
|
1158
|
+
event.stopPropagation();
|
|
1159
|
+
active.selectedChip = chip.key;
|
|
1160
|
+
active.prompt = chip.prompt;
|
|
1161
|
+
active.error = "";
|
|
1162
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey, { focusComposer: true });
|
|
1163
|
+
});
|
|
1164
|
+
chips.appendChild(btn);
|
|
1165
|
+
});
|
|
1166
|
+
panel.appendChild(chips);
|
|
1167
|
+
|
|
1168
|
+
const input = document.createElement("textarea");
|
|
1169
|
+
input.className = "option-insight-input";
|
|
1170
|
+
input.rows = 2;
|
|
1171
|
+
input.placeholder = "Ask why it works, where it fails, or how to rewrite it...";
|
|
1172
|
+
input.dataset.questionId = question.id;
|
|
1173
|
+
input.dataset.optionKey = optionKey;
|
|
1174
|
+
input.value = active.prompt;
|
|
1175
|
+
input.addEventListener("input", () => {
|
|
1176
|
+
active.prompt = input.value;
|
|
1177
|
+
active.selectedChip = null;
|
|
1178
|
+
active.error = "";
|
|
1179
|
+
});
|
|
1180
|
+
input.addEventListener("keydown", (event) => {
|
|
1181
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
1182
|
+
event.preventDefault();
|
|
1183
|
+
submitOptionInsight(question, optionKey);
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
panel.appendChild(input);
|
|
1187
|
+
|
|
1188
|
+
const metaRow = document.createElement("div");
|
|
1189
|
+
metaRow.className = "option-insight-meta-row";
|
|
1190
|
+
|
|
1191
|
+
const modelBadge = document.createElement("button");
|
|
1192
|
+
modelBadge.type = "button";
|
|
1193
|
+
modelBadge.className = "option-insight-model-badge";
|
|
1194
|
+
const badgeLabel = document.createElement("span");
|
|
1195
|
+
badgeLabel.className = "badge-label";
|
|
1196
|
+
badgeLabel.textContent = "Model";
|
|
1197
|
+
const badgeValue = document.createElement("span");
|
|
1198
|
+
badgeValue.textContent = getInsightModelLabel(active);
|
|
1199
|
+
const badgeCaret = document.createElement("span");
|
|
1200
|
+
badgeCaret.className = "badge-caret";
|
|
1201
|
+
badgeCaret.textContent = active.advancedOpen ? "▾" : "▸";
|
|
1202
|
+
modelBadge.appendChild(badgeLabel);
|
|
1203
|
+
modelBadge.appendChild(badgeValue);
|
|
1204
|
+
modelBadge.appendChild(badgeCaret);
|
|
1205
|
+
modelBadge.addEventListener("click", (event) => {
|
|
1206
|
+
event.preventDefault();
|
|
1207
|
+
event.stopPropagation();
|
|
1208
|
+
active.advancedOpen = !active.advancedOpen;
|
|
1209
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1210
|
+
});
|
|
1211
|
+
metaRow.appendChild(modelBadge);
|
|
1212
|
+
|
|
1213
|
+
panel.appendChild(metaRow);
|
|
1214
|
+
|
|
1215
|
+
if (active.advancedOpen) {
|
|
1216
|
+
const advanced = document.createElement("div");
|
|
1217
|
+
advanced.className = "option-insight-advanced";
|
|
1218
|
+
|
|
1219
|
+
const providers = [...new Set(askModels.map((model) => model.provider))];
|
|
1220
|
+
const providerRow = document.createElement("div");
|
|
1221
|
+
providerRow.className = "option-insight-provider-row";
|
|
1222
|
+
providers.forEach((provider) => {
|
|
1223
|
+
const btn = document.createElement("button");
|
|
1224
|
+
btn.type = "button";
|
|
1225
|
+
const isSelected = (active.selectedProvider || providers[0] || "") === provider;
|
|
1226
|
+
btn.className = "option-insight-provider-btn" + (isSelected ? " is-selected" : "");
|
|
1227
|
+
btn.textContent = providerLabel(provider);
|
|
1228
|
+
btn.addEventListener("click", (event) => {
|
|
1229
|
+
event.preventDefault();
|
|
1230
|
+
event.stopPropagation();
|
|
1231
|
+
active.selectedProvider = provider;
|
|
1232
|
+
const providerModels = getModelsForProvider(provider);
|
|
1233
|
+
active.selectedModel = providerModels[0]?.value || null;
|
|
1234
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1235
|
+
});
|
|
1236
|
+
providerRow.appendChild(btn);
|
|
1237
|
+
});
|
|
1238
|
+
advanced.appendChild(providerRow);
|
|
1239
|
+
|
|
1240
|
+
const providerModels = getModelsForProvider(active.selectedProvider);
|
|
1241
|
+
const modelRow = document.createElement("div");
|
|
1242
|
+
modelRow.className = "option-insight-model-row";
|
|
1243
|
+
providerModels.forEach((modelOption) => {
|
|
1244
|
+
const btn = document.createElement("button");
|
|
1245
|
+
btn.type = "button";
|
|
1246
|
+
const isSelected = active.selectedModel === modelOption.value;
|
|
1247
|
+
btn.className = "option-insight-model-btn" + (isSelected ? " is-selected" : "");
|
|
1248
|
+
btn.textContent = modelOption.label;
|
|
1249
|
+
btn.addEventListener("click", (event) => {
|
|
1250
|
+
event.preventDefault();
|
|
1251
|
+
event.stopPropagation();
|
|
1252
|
+
active.selectedModel = modelOption.value;
|
|
1253
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1254
|
+
});
|
|
1255
|
+
modelRow.appendChild(btn);
|
|
1256
|
+
});
|
|
1257
|
+
advanced.appendChild(modelRow);
|
|
1258
|
+
|
|
1259
|
+
const depthRow = document.createElement("div");
|
|
1260
|
+
depthRow.className = "option-insight-depth-row";
|
|
1261
|
+
ASK_DEPTH_OPTIONS.forEach((depth) => {
|
|
1262
|
+
const btn = document.createElement("button");
|
|
1263
|
+
btn.type = "button";
|
|
1264
|
+
const isSelected = active.selectedDepth === depth.key;
|
|
1265
|
+
btn.className = "option-insight-depth-btn" + (isSelected ? " is-selected" : "");
|
|
1266
|
+
btn.textContent = depth.label;
|
|
1267
|
+
btn.addEventListener("click", (event) => {
|
|
1268
|
+
event.preventDefault();
|
|
1269
|
+
event.stopPropagation();
|
|
1270
|
+
active.selectedDepth = depth.key;
|
|
1271
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1272
|
+
});
|
|
1273
|
+
depthRow.appendChild(btn);
|
|
1274
|
+
});
|
|
1275
|
+
advanced.appendChild(depthRow);
|
|
1276
|
+
|
|
1277
|
+
panel.appendChild(advanced);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const actions = document.createElement("div");
|
|
1281
|
+
actions.className = "option-insight-actions";
|
|
1282
|
+
|
|
1283
|
+
const askButton = document.createElement("button");
|
|
1284
|
+
askButton.type = "button";
|
|
1285
|
+
askButton.className = "option-insight-submit" + (active.loading ? " loading" : "");
|
|
1286
|
+
askButton.textContent = active.loading ? "Cancel" : "Ask";
|
|
1287
|
+
askButton.addEventListener("click", (event) => {
|
|
1288
|
+
event.preventDefault();
|
|
1289
|
+
event.stopPropagation();
|
|
1290
|
+
submitOptionInsight(question, optionKey);
|
|
1291
|
+
});
|
|
1292
|
+
actions.appendChild(askButton);
|
|
1293
|
+
|
|
1294
|
+
panel.appendChild(actions);
|
|
1295
|
+
|
|
1296
|
+
if (active.loading && !active.result) {
|
|
1297
|
+
const loading = document.createElement("div");
|
|
1298
|
+
loading.className = "option-insight-loading";
|
|
1299
|
+
const spinner = document.createElement("span");
|
|
1300
|
+
spinner.className = "option-insight-spinner";
|
|
1301
|
+
spinner.textContent = "Thinking";
|
|
1302
|
+
loading.appendChild(spinner);
|
|
1303
|
+
const dots = document.createElement("span");
|
|
1304
|
+
dots.className = "option-insight-dots";
|
|
1305
|
+
dots.textContent = "...";
|
|
1306
|
+
loading.appendChild(dots);
|
|
1307
|
+
panel.appendChild(loading);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (active.error) {
|
|
1311
|
+
const error = document.createElement("div");
|
|
1312
|
+
error.className = "option-insight-error";
|
|
1313
|
+
error.textContent = active.error;
|
|
1314
|
+
panel.appendChild(error);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (active.result) {
|
|
1318
|
+
const result = document.createElement("div");
|
|
1319
|
+
result.className = "option-insight-result";
|
|
1320
|
+
|
|
1321
|
+
const summary = document.createElement("p");
|
|
1322
|
+
summary.className = "option-insight-summary";
|
|
1323
|
+
summary.textContent = active.result.summary;
|
|
1324
|
+
result.appendChild(summary);
|
|
1325
|
+
|
|
1326
|
+
if (Array.isArray(active.result.bullets) && active.result.bullets.length > 0) {
|
|
1327
|
+
const list = document.createElement("ul");
|
|
1328
|
+
list.className = "option-insight-bullets";
|
|
1329
|
+
active.result.bullets.forEach((bullet) => {
|
|
1330
|
+
const item = document.createElement("li");
|
|
1331
|
+
item.textContent = bullet;
|
|
1332
|
+
list.appendChild(item);
|
|
1333
|
+
});
|
|
1334
|
+
result.appendChild(list);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
if (active.result.suggestedText) {
|
|
1338
|
+
const suggestionLabel = document.createElement("div");
|
|
1339
|
+
suggestionLabel.className = "option-insight-suggestion-label";
|
|
1340
|
+
suggestionLabel.textContent = "Suggested rewrite";
|
|
1341
|
+
result.appendChild(suggestionLabel);
|
|
1342
|
+
|
|
1343
|
+
const suggestion = document.createElement("code");
|
|
1344
|
+
suggestion.className = "option-insight-suggested-text";
|
|
1345
|
+
suggestion.textContent = active.result.suggestedText;
|
|
1346
|
+
result.appendChild(suggestion);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (active.result.modelUsed) {
|
|
1350
|
+
const meta = document.createElement("div");
|
|
1351
|
+
meta.className = "option-insight-meta";
|
|
1352
|
+
meta.textContent = active.result.modelUsed;
|
|
1353
|
+
result.appendChild(meta);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const resultActions = document.createElement("div");
|
|
1357
|
+
resultActions.className = "option-insight-result-actions";
|
|
1358
|
+
|
|
1359
|
+
const pinBtn = document.createElement("button");
|
|
1360
|
+
pinBtn.type = "button";
|
|
1361
|
+
pinBtn.className = "option-insight-secondary";
|
|
1362
|
+
pinBtn.textContent = "Pin";
|
|
1363
|
+
pinBtn.addEventListener("click", (event) => {
|
|
1364
|
+
event.preventDefault();
|
|
1365
|
+
event.stopPropagation();
|
|
1366
|
+
pinActiveInsight(question, optionKey);
|
|
1367
|
+
});
|
|
1368
|
+
resultActions.appendChild(pinBtn);
|
|
1369
|
+
|
|
1370
|
+
const moveUpBtn = document.createElement("button");
|
|
1371
|
+
moveUpBtn.type = "button";
|
|
1372
|
+
moveUpBtn.className = "option-insight-secondary";
|
|
1373
|
+
moveUpBtn.textContent = "Move up";
|
|
1374
|
+
moveUpBtn.disabled = getOptionIndexByKey(question.id, optionKey) <= 0;
|
|
1375
|
+
moveUpBtn.addEventListener("click", (event) => {
|
|
1376
|
+
event.preventDefault();
|
|
1377
|
+
event.stopPropagation();
|
|
1378
|
+
runOptionAction(question, optionKey, "move-up");
|
|
1379
|
+
});
|
|
1380
|
+
resultActions.appendChild(moveUpBtn);
|
|
1381
|
+
|
|
1382
|
+
if (active.result.suggestedText) {
|
|
1383
|
+
const replaceBtn = document.createElement("button");
|
|
1384
|
+
replaceBtn.type = "button";
|
|
1385
|
+
replaceBtn.className = "option-insight-primary";
|
|
1386
|
+
replaceBtn.textContent = "Use rewrite";
|
|
1387
|
+
replaceBtn.addEventListener("click", (event) => {
|
|
1388
|
+
event.preventDefault();
|
|
1389
|
+
event.stopPropagation();
|
|
1390
|
+
runOptionAction(question, optionKey, "replace-text", active.result.suggestedText);
|
|
1391
|
+
});
|
|
1392
|
+
resultActions.appendChild(replaceBtn);
|
|
1393
|
+
|
|
1394
|
+
const addBtn = document.createElement("button");
|
|
1395
|
+
addBtn.type = "button";
|
|
1396
|
+
addBtn.className = "option-insight-secondary";
|
|
1397
|
+
addBtn.textContent = "Add rewrite as option";
|
|
1398
|
+
addBtn.addEventListener("click", (event) => {
|
|
1399
|
+
event.preventDefault();
|
|
1400
|
+
event.stopPropagation();
|
|
1401
|
+
runOptionAction(question, optionKey, "add-option", active.result.suggestedText);
|
|
1402
|
+
});
|
|
1403
|
+
resultActions.appendChild(addBtn);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
result.appendChild(resultActions);
|
|
1407
|
+
panel.appendChild(result);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
return panel;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function createOptionNoteInput(question, optionLabel, isSelected) {
|
|
1414
|
+
if (!questionCanClarifyOption(question) || !isSelected) return null;
|
|
1415
|
+
|
|
1416
|
+
const wrap = document.createElement("div");
|
|
1417
|
+
wrap.className = "option-note-wrap";
|
|
1418
|
+
|
|
1419
|
+
const input = document.createElement("input");
|
|
1420
|
+
input.type = "text";
|
|
1421
|
+
input.className = "option-note-input";
|
|
1422
|
+
input.placeholder = "Optional clarification...";
|
|
1423
|
+
input.dataset.questionId = question.id;
|
|
1424
|
+
input.dataset.optionLabel = optionLabel;
|
|
1425
|
+
input.value = getChoiceNote(question.id, optionLabel);
|
|
1426
|
+
input.addEventListener("input", () => {
|
|
1427
|
+
setChoiceNote(question.id, optionLabel, input.value);
|
|
1428
|
+
debounceSave();
|
|
1429
|
+
});
|
|
1430
|
+
setupEdgeNavigation(input);
|
|
1431
|
+
wrap.appendChild(input);
|
|
1432
|
+
|
|
1433
|
+
return wrap;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function createChoiceOptionRow(question, option, optionIndex, options = {}) {
|
|
1437
|
+
const optionLabel = getOptionLabel(option);
|
|
1438
|
+
const optionContent = isRichOption(option) ? option.content : null;
|
|
1439
|
+
const optionKey = getOptionKeys(question.id)[optionIndex] || null;
|
|
1440
|
+
const generatedSet = options.generatedKeys || new Set();
|
|
1441
|
+
const insightable = questionSupportsOptionInsights(question) && !!optionKey;
|
|
1442
|
+
const askable = questionCanAskAboutOption(question) && !!optionKey;
|
|
1443
|
+
const activeInsight = optionKey ? getActiveInsight(question.id, optionKey) : null;
|
|
1444
|
+
|
|
1445
|
+
const row = document.createElement("div");
|
|
1446
|
+
row.className = "option-row";
|
|
1447
|
+
if (generatedSet.has(optionKey)) {
|
|
1448
|
+
row.classList.add("generated");
|
|
1449
|
+
}
|
|
1450
|
+
if (activeInsight) {
|
|
1451
|
+
row.classList.add("ask-open");
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const main = document.createElement("div");
|
|
1455
|
+
main.className = "option-row-main";
|
|
1456
|
+
|
|
1457
|
+
const label = document.createElement("label");
|
|
1458
|
+
label.className = "option-item";
|
|
1459
|
+
if (optionContent) {
|
|
1460
|
+
label.classList.add("has-code");
|
|
1461
|
+
}
|
|
1462
|
+
const input = document.createElement("input");
|
|
1463
|
+
input.type = question.type === "single" ? "radio" : "checkbox";
|
|
1464
|
+
input.name = question.id;
|
|
1465
|
+
input.value = optionLabel;
|
|
1466
|
+
input.id = `q-${question.id}-${optionIndex}`;
|
|
1467
|
+
|
|
1468
|
+
input.addEventListener("change", () => {
|
|
1469
|
+
syncChoiceNotesWithSelection(question);
|
|
1470
|
+
debounceSave();
|
|
1471
|
+
if (question.type === "multi") {
|
|
1472
|
+
updateDoneState(question.id);
|
|
1473
|
+
}
|
|
1474
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
const body = document.createElement("div");
|
|
1478
|
+
body.className = "option-item-body";
|
|
1479
|
+
|
|
1480
|
+
const text = document.createElement("span");
|
|
1481
|
+
text.className = "option-item-label";
|
|
1482
|
+
text.textContent = optionLabel;
|
|
1483
|
+
|
|
1484
|
+
const recommended = question.recommended;
|
|
1485
|
+
const recommendedList = Array.isArray(recommended)
|
|
1486
|
+
? recommended
|
|
1487
|
+
: recommended
|
|
1488
|
+
? [recommended]
|
|
1489
|
+
: [];
|
|
1490
|
+
const shouldPreselect = recommendedList.length > 0 && question.conviction !== "slight";
|
|
1491
|
+
|
|
1492
|
+
if (recommendedList.includes(optionLabel)) {
|
|
1493
|
+
const pill = document.createElement("span");
|
|
1494
|
+
pill.className = "recommended-pill";
|
|
1495
|
+
pill.textContent = "Recommended";
|
|
1496
|
+
text.appendChild(pill);
|
|
1497
|
+
if (shouldPreselect) {
|
|
1498
|
+
input.checked = true;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
body.appendChild(text);
|
|
1503
|
+
|
|
1504
|
+
if (optionContent) {
|
|
1505
|
+
const contentBlockEl = renderContentBlock(optionContent);
|
|
1506
|
+
if (contentBlockEl) {
|
|
1507
|
+
body.appendChild(contentBlockEl);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
label.appendChild(input);
|
|
1512
|
+
label.appendChild(body);
|
|
1513
|
+
|
|
1514
|
+
main.appendChild(label);
|
|
1515
|
+
const selectedLabels = new Set(getSelectedOptionLabels(question.id));
|
|
1516
|
+
const noteInput = createOptionNoteInput(question, optionLabel, input.checked || selectedLabels.has(optionLabel));
|
|
1517
|
+
|
|
1518
|
+
if (insightable && optionKey) {
|
|
1519
|
+
if (askable) {
|
|
1520
|
+
const askButton = document.createElement("button");
|
|
1521
|
+
askButton.type = "button";
|
|
1522
|
+
askButton.className = "option-ask-btn";
|
|
1523
|
+
askButton.textContent = activeInsight ? "Hide" : "Ask";
|
|
1524
|
+
askButton.addEventListener("click", (event) => {
|
|
1525
|
+
event.preventDefault();
|
|
1526
|
+
event.stopPropagation();
|
|
1527
|
+
openOptionInsightPanel(question, optionKey);
|
|
1528
|
+
});
|
|
1529
|
+
main.appendChild(askButton);
|
|
1530
|
+
|
|
1531
|
+
const panel = createOptionInsightPanel(question, optionKey);
|
|
1532
|
+
row.appendChild(main);
|
|
1533
|
+
if (noteInput) row.appendChild(noteInput);
|
|
1534
|
+
if (panel) row.appendChild(panel);
|
|
1535
|
+
} else {
|
|
1536
|
+
row.appendChild(main);
|
|
1537
|
+
if (noteInput) row.appendChild(noteInput);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
const pinnedInsights = getPinnedInsights(question.id, optionKey);
|
|
1541
|
+
if (pinnedInsights.length > 0) {
|
|
1542
|
+
const pinnedWrap = document.createElement("div");
|
|
1543
|
+
pinnedWrap.className = "option-insight-pinned-list";
|
|
1544
|
+
pinnedInsights.forEach((insight) => {
|
|
1545
|
+
pinnedWrap.appendChild(createPinnedInsightCard(question, optionKey, insight));
|
|
1546
|
+
});
|
|
1547
|
+
row.appendChild(pinnedWrap);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
return row;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
row.appendChild(main);
|
|
1554
|
+
if (noteInput) row.appendChild(noteInput);
|
|
1555
|
+
return row;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function createChoiceQuestionList(question, title, options = {}) {
|
|
1559
|
+
const list = document.createElement("div");
|
|
1560
|
+
list.className = "option-list";
|
|
1561
|
+
list.setAttribute("role", question.type === "single" ? "radiogroup" : "group");
|
|
1562
|
+
list.setAttribute("aria-labelledby", title.id);
|
|
1563
|
+
|
|
1564
|
+
const generatedKeys = new Set(options.generatedKeys || []);
|
|
1565
|
+
|
|
1566
|
+
question.options.forEach((option, optionIndex) => {
|
|
1567
|
+
list.appendChild(createChoiceOptionRow(question, option, optionIndex, { generatedKeys }));
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
const generateMoreEl = createGenerateMoreUI(question, list);
|
|
1571
|
+
if (generateMoreEl) list.appendChild(generateMoreEl);
|
|
1572
|
+
|
|
1573
|
+
const otherLabel = document.createElement("label");
|
|
1574
|
+
otherLabel.className = "option-item option-other";
|
|
1575
|
+
const otherCheck = document.createElement("input");
|
|
1576
|
+
otherCheck.type = question.type === "single" ? "radio" : "checkbox";
|
|
1577
|
+
otherCheck.name = question.id;
|
|
1578
|
+
otherCheck.value = "__other__";
|
|
1579
|
+
otherCheck.id = `q-${question.id}-other`;
|
|
1580
|
+
const otherInput = document.createElement("textarea");
|
|
1581
|
+
otherInput.className = "other-input";
|
|
1582
|
+
otherInput.placeholder = "Other...";
|
|
1583
|
+
otherInput.rows = 1;
|
|
1584
|
+
otherInput.dataset.questionId = question.id;
|
|
1585
|
+
const autoResizeOther = () => {
|
|
1586
|
+
otherInput.style.height = "auto";
|
|
1587
|
+
otherInput.style.height = otherInput.scrollHeight + "px";
|
|
1588
|
+
};
|
|
1589
|
+
otherInput.addEventListener("input", () => {
|
|
1590
|
+
autoResizeOther();
|
|
1591
|
+
if (otherInput.value && !otherCheck.checked) {
|
|
1592
|
+
otherCheck.checked = true;
|
|
1593
|
+
if (question.type === "multi") updateDoneState(question.id);
|
|
1594
|
+
}
|
|
1595
|
+
debounceSave();
|
|
1596
|
+
});
|
|
1597
|
+
otherInput.addEventListener("focus", () => {
|
|
1598
|
+
if (!otherCheck.checked) {
|
|
1599
|
+
otherCheck.checked = true;
|
|
1600
|
+
if (question.type === "multi") updateDoneState(question.id);
|
|
1601
|
+
debounceSave();
|
|
1602
|
+
}
|
|
1603
|
+
});
|
|
1604
|
+
otherCheck.addEventListener("change", () => {
|
|
1605
|
+
debounceSave();
|
|
1606
|
+
if (question.type === "multi") updateDoneState(question.id);
|
|
1607
|
+
if (otherCheck.checked) otherInput.focus();
|
|
1608
|
+
});
|
|
1609
|
+
setupEdgeNavigation(otherInput);
|
|
1610
|
+
otherLabel.appendChild(otherCheck);
|
|
1611
|
+
otherLabel.appendChild(otherInput);
|
|
1612
|
+
list.appendChild(otherLabel);
|
|
1613
|
+
|
|
1614
|
+
if (question.type === "multi") {
|
|
1615
|
+
const doneItem = document.createElement("div");
|
|
1616
|
+
doneItem.className = "option-item done-item disabled";
|
|
1617
|
+
doneItem.setAttribute("tabindex", "0");
|
|
1618
|
+
doneItem.dataset.doneFor = question.id;
|
|
1619
|
+
doneItem.innerHTML = '<span class="done-check">✓</span><span>Done</span>';
|
|
1620
|
+
doneItem.addEventListener("click", () => {
|
|
1621
|
+
if (!doneItem.classList.contains("disabled")) {
|
|
1622
|
+
nextQuestion();
|
|
1623
|
+
}
|
|
1624
|
+
});
|
|
1625
|
+
doneItem.addEventListener("keydown", (e) => {
|
|
1626
|
+
if ((e.key === "Enter" || e.key === " ") && !doneItem.classList.contains("disabled")) {
|
|
1627
|
+
e.preventDefault();
|
|
1628
|
+
e.stopPropagation();
|
|
1629
|
+
nextQuestion();
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
list.appendChild(doneItem);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
return list;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
549
1638
|
function renderContentBlock(block) {
|
|
550
1639
|
if (!block || !block.source) return null;
|
|
551
1640
|
|
|
@@ -1203,8 +2292,14 @@
|
|
|
1203
2292
|
return items;
|
|
1204
2293
|
}
|
|
1205
2294
|
|
|
2295
|
+
function getTabStopsForCard(card) {
|
|
2296
|
+
return Array.from(
|
|
2297
|
+
card.querySelectorAll('input[type="radio"], input[type="checkbox"], .option-note-input, .option-ask-btn, .file-dropzone, .image-path-input, .done-item')
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
|
|
1206
2301
|
function isPathInput(el) {
|
|
1207
|
-
return el && (el.classList.contains('image-path-input') || el.classList.contains('attach-inline-path') || el.classList.contains('other-input'));
|
|
2302
|
+
return el && (el.classList.contains('image-path-input') || el.classList.contains('attach-inline-path') || el.classList.contains('other-input') || el.classList.contains('option-note-input'));
|
|
1208
2303
|
}
|
|
1209
2304
|
|
|
1210
2305
|
function isDropzone(el) {
|
|
@@ -1260,7 +2355,7 @@
|
|
|
1260
2355
|
function highlightOption(card, optionIndex, isKeyboard = true) {
|
|
1261
2356
|
const options = getOptionsForCard(card);
|
|
1262
2357
|
options.forEach((opt, i) => {
|
|
1263
|
-
const item = isOptionInput(opt) ? opt.closest('.option-item') : opt;
|
|
2358
|
+
const item = isOptionInput(opt) ? (opt.closest('.option-row') || opt.closest('.option-item')) : opt;
|
|
1264
2359
|
item?.classList.toggle('focused', i === optionIndex);
|
|
1265
2360
|
});
|
|
1266
2361
|
const current = options[optionIndex];
|
|
@@ -1272,8 +2367,31 @@
|
|
|
1272
2367
|
}
|
|
1273
2368
|
}
|
|
1274
2369
|
|
|
2370
|
+
function focusCardTabStop(card, target, isKeyboard = true) {
|
|
2371
|
+
if (!target) return;
|
|
2372
|
+
|
|
2373
|
+
clearOptionHighlight(card);
|
|
2374
|
+
|
|
2375
|
+
const row = target.closest?.('.option-row');
|
|
2376
|
+
const highlightTarget = row || (isOptionInput(target) ? target.closest('.option-item') : target);
|
|
2377
|
+
highlightTarget?.classList.add('focused');
|
|
2378
|
+
|
|
2379
|
+
const rowInput = row?.querySelector('input[type="radio"], input[type="checkbox"]');
|
|
2380
|
+
const options = getOptionsForCard(card);
|
|
2381
|
+
const navTarget = rowInput || target;
|
|
2382
|
+
const nextIndex = options.indexOf(navTarget);
|
|
2383
|
+
if (nextIndex >= 0) {
|
|
2384
|
+
nav.optionIndex = nextIndex;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
target.focus();
|
|
2388
|
+
if (isKeyboard) {
|
|
2389
|
+
card.classList.add('keyboard-nav');
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
|
|
1275
2393
|
function clearOptionHighlight(card) {
|
|
1276
|
-
card.querySelectorAll('.option-item, .done-item, .file-dropzone, .image-path-input').forEach(item => {
|
|
2394
|
+
card.querySelectorAll('.option-row, .option-item, .done-item, .file-dropzone, .image-path-input').forEach(item => {
|
|
1277
2395
|
item.classList.remove('focused');
|
|
1278
2396
|
});
|
|
1279
2397
|
}
|
|
@@ -1385,25 +2503,33 @@
|
|
|
1385
2503
|
const options = getOptionsForCard(card);
|
|
1386
2504
|
const textarea = card.querySelector('textarea');
|
|
1387
2505
|
const isTextFocused = document.activeElement === textarea;
|
|
1388
|
-
|
|
2506
|
+
const inAskArea = document.activeElement?.closest('.option-insight-panel, .option-ask-btn, .option-insight-pinned');
|
|
2507
|
+
const inOptionNote = document.activeElement?.closest('.option-note-wrap');
|
|
2508
|
+
|
|
1389
2509
|
if (event.key === 'Tab') {
|
|
1390
2510
|
const inAttachArea = document.activeElement?.closest('.attach-inline');
|
|
1391
2511
|
const inGenerateArea = document.activeElement?.closest('.generate-more');
|
|
1392
|
-
if (inAttachArea || inGenerateArea) return;
|
|
2512
|
+
if (inAttachArea || inGenerateArea || inAskArea || inOptionNote) return;
|
|
1393
2513
|
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
if (event.shiftKey) {
|
|
1398
|
-
nav.optionIndex = (nav.optionIndex - 1 + options.length) % options.length;
|
|
1399
|
-
} else {
|
|
1400
|
-
nav.optionIndex = (nav.optionIndex + 1) % options.length;
|
|
1401
|
-
}
|
|
1402
|
-
highlightOption(card, nav.optionIndex);
|
|
2514
|
+
const tabStops = getTabStopsForCard(card);
|
|
2515
|
+
if (tabStops.length === 0) {
|
|
2516
|
+
return;
|
|
1403
2517
|
}
|
|
2518
|
+
|
|
2519
|
+
event.preventDefault();
|
|
2520
|
+
|
|
2521
|
+
const activeIndex = tabStops.indexOf(document.activeElement);
|
|
2522
|
+
const fallbackIndex = options[nav.optionIndex] ? tabStops.indexOf(options[nav.optionIndex]) : -1;
|
|
2523
|
+
const currentIndex = activeIndex >= 0 ? activeIndex : (fallbackIndex >= 0 ? fallbackIndex : 0);
|
|
2524
|
+
const nextIndex = event.shiftKey
|
|
2525
|
+
? (currentIndex - 1 + tabStops.length) % tabStops.length
|
|
2526
|
+
: (currentIndex + 1) % tabStops.length;
|
|
2527
|
+
focusCardTabStop(card, tabStops[nextIndex]);
|
|
1404
2528
|
return;
|
|
1405
2529
|
}
|
|
1406
|
-
|
|
2530
|
+
|
|
2531
|
+
if (inAskArea || inOptionNote) return;
|
|
2532
|
+
|
|
1407
2533
|
if (event.key === 'ArrowLeft') {
|
|
1408
2534
|
if (isTextFocused || isPathInput(document.activeElement)) {
|
|
1409
2535
|
return;
|
|
@@ -1438,16 +2564,19 @@
|
|
|
1438
2564
|
}
|
|
1439
2565
|
|
|
1440
2566
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
2567
|
+
if (isPathInput(document.activeElement)) {
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
if (document.activeElement?.closest('.attach-inline')) {
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
if (document.activeElement?.closest('.generate-more')) {
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
if (document.activeElement?.closest('.option-insight-panel, .option-ask-btn')) {
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
event.preventDefault();
|
|
1451
2580
|
const option = options[nav.optionIndex];
|
|
1452
2581
|
if (option) {
|
|
1453
2582
|
if (isDoneItem(option)) {
|
|
@@ -1482,6 +2611,17 @@
|
|
|
1482
2611
|
}
|
|
1483
2612
|
return;
|
|
1484
2613
|
}
|
|
2614
|
+
|
|
2615
|
+
if ((event.key === 'a' || event.key === 'A') && isOptionInput(document.activeElement)) {
|
|
2616
|
+
event.preventDefault();
|
|
2617
|
+
const focusedInput = options[nav.optionIndex];
|
|
2618
|
+
const row = focusedInput?.closest('.option-row');
|
|
2619
|
+
const askButton = row?.querySelector('.option-ask-btn');
|
|
2620
|
+
if (askButton) {
|
|
2621
|
+
askButton.click();
|
|
2622
|
+
}
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
1485
2625
|
}
|
|
1486
2626
|
|
|
1487
2627
|
if (textarea && !isTextFocused) {
|
|
@@ -1550,7 +2690,6 @@
|
|
|
1550
2690
|
|
|
1551
2691
|
function createGenerateMoreUI(question, list) {
|
|
1552
2692
|
if (!data.canGenerate) return null;
|
|
1553
|
-
if (question.options.some(isRichOption)) return null;
|
|
1554
2693
|
|
|
1555
2694
|
const container = document.createElement("div");
|
|
1556
2695
|
container.className = "generate-more";
|
|
@@ -1609,15 +2748,6 @@
|
|
|
1609
2748
|
}, timeoutMs);
|
|
1610
2749
|
}
|
|
1611
2750
|
|
|
1612
|
-
function getExistingOptions() {
|
|
1613
|
-
const inputs = list.querySelectorAll(
|
|
1614
|
-
'input[name="' + escapeSelector(question.id) + '"]'
|
|
1615
|
-
);
|
|
1616
|
-
return Array.from(inputs)
|
|
1617
|
-
.map((input) => input.value)
|
|
1618
|
-
.filter((v) => v && v !== "__other__");
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
2751
|
async function runGenerate(btn, mode) {
|
|
1622
2752
|
if (generating) {
|
|
1623
2753
|
if (abortController) abortController.abort();
|
|
@@ -1634,7 +2764,7 @@
|
|
|
1634
2764
|
clearStatus();
|
|
1635
2765
|
|
|
1636
2766
|
abortController = new AbortController();
|
|
1637
|
-
const
|
|
2767
|
+
const currentValue = getQuestionValue(question);
|
|
1638
2768
|
|
|
1639
2769
|
try {
|
|
1640
2770
|
const response = await fetch("/generate", {
|
|
@@ -1643,7 +2773,6 @@
|
|
|
1643
2773
|
body: JSON.stringify({
|
|
1644
2774
|
token: sessionToken,
|
|
1645
2775
|
questionId: question.id,
|
|
1646
|
-
existingOptions,
|
|
1647
2776
|
mode,
|
|
1648
2777
|
}),
|
|
1649
2778
|
signal: abortController.signal,
|
|
@@ -1651,25 +2780,23 @@
|
|
|
1651
2780
|
|
|
1652
2781
|
const result = await response.json();
|
|
1653
2782
|
if (!result.ok) throw new Error(result.error || "Generation failed");
|
|
1654
|
-
if (!Array.isArray(result.options)
|
|
2783
|
+
if (!Array.isArray(result.options)) {
|
|
2784
|
+
throw new Error("Generation returned invalid options");
|
|
2785
|
+
}
|
|
2786
|
+
if (mode === "review" && result.options.length === 0) {
|
|
1655
2787
|
throw new Error("No options generated");
|
|
1656
2788
|
}
|
|
2789
|
+
if (Array.isArray(result.optionKeys)) {
|
|
2790
|
+
setOptionKeys(question.id, result.optionKeys);
|
|
2791
|
+
pruneQuestionOptionInsights(question.id);
|
|
2792
|
+
}
|
|
1657
2793
|
|
|
1658
2794
|
if (mode === "review") {
|
|
1659
2795
|
if (typeof result.question !== "string" || !result.question.trim()) {
|
|
1660
2796
|
throw new Error("No revised question returned");
|
|
1661
2797
|
}
|
|
1662
2798
|
|
|
1663
|
-
const
|
|
1664
|
-
const revisedOptions = result.options.filter((option) => {
|
|
1665
|
-
const key = option.toLowerCase().trim();
|
|
1666
|
-
if (seen.has(key)) return false;
|
|
1667
|
-
seen.add(key);
|
|
1668
|
-
return true;
|
|
1669
|
-
});
|
|
1670
|
-
if (revisedOptions.length === 0) {
|
|
1671
|
-
throw new Error("No valid options returned for review");
|
|
1672
|
-
}
|
|
2799
|
+
const revisedOptions = result.options;
|
|
1673
2800
|
|
|
1674
2801
|
question.question = result.question.trim();
|
|
1675
2802
|
question.options = revisedOptions;
|
|
@@ -1678,38 +2805,27 @@
|
|
|
1678
2805
|
if (title) {
|
|
1679
2806
|
title.innerHTML = renderLightMarkdown(question.question);
|
|
1680
2807
|
}
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
.forEach((el) => el.remove());
|
|
1685
|
-
revisedOptions.forEach((optionText, i) => {
|
|
1686
|
-
const optionEl = createGeneratedOption(question, optionText, i);
|
|
1687
|
-
list.insertBefore(optionEl, container);
|
|
1688
|
-
});
|
|
1689
|
-
if (question.type === "multi") updateDoneState(question.id);
|
|
2808
|
+
const revisedLabels = new Set(revisedOptions.map((option) => getOptionLabel(option)));
|
|
2809
|
+
const nextValue = preserveChoiceAnswerValue(question, currentValue, revisedLabels);
|
|
2810
|
+
replaceQuestionOptionList(question, nextValue);
|
|
1690
2811
|
debounceSave();
|
|
1691
2812
|
showStatus(
|
|
1692
2813
|
"Question updated and " + revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
|
|
1693
2814
|
2500,
|
|
1694
2815
|
);
|
|
1695
2816
|
} else {
|
|
1696
|
-
const
|
|
1697
|
-
const seen = new Set();
|
|
1698
|
-
const newOptions = result.options.filter((o) => {
|
|
1699
|
-
const key = o.toLowerCase().trim();
|
|
1700
|
-
if (existingSet.has(key) || seen.has(key)) return false;
|
|
1701
|
-
seen.add(key);
|
|
1702
|
-
return true;
|
|
1703
|
-
});
|
|
2817
|
+
const newOptions = result.options;
|
|
1704
2818
|
|
|
1705
2819
|
if (newOptions.length === 0) {
|
|
1706
2820
|
showStatus("All generated options already exist", 3000);
|
|
1707
2821
|
} else {
|
|
1708
2822
|
question.options = question.options.concat(newOptions);
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
2823
|
+
const optionKeys = getOptionKeys(question.id);
|
|
2824
|
+
const generatedKeys = optionKeys.slice(-newOptions.length);
|
|
2825
|
+
replaceQuestionOptionList(question, currentValue, generatedKeys[0] || null, {
|
|
2826
|
+
generatedKeys,
|
|
1712
2827
|
});
|
|
2828
|
+
debounceSave();
|
|
1713
2829
|
showStatus(
|
|
1714
2830
|
newOptions.length + " option" + (newOptions.length > 1 ? "s" : "") + " added",
|
|
1715
2831
|
2500,
|
|
@@ -1739,33 +2855,6 @@
|
|
|
1739
2855
|
return container;
|
|
1740
2856
|
}
|
|
1741
2857
|
|
|
1742
|
-
function createGeneratedOption(question, optionText, animIndex) {
|
|
1743
|
-
const label = document.createElement("label");
|
|
1744
|
-
label.className = "option-item generated";
|
|
1745
|
-
label.style.animationDelay = (animIndex * 0.08) + "s";
|
|
1746
|
-
|
|
1747
|
-
const input = document.createElement("input");
|
|
1748
|
-
input.type = question.type === "single" ? "radio" : "checkbox";
|
|
1749
|
-
input.name = question.id;
|
|
1750
|
-
input.value = optionText;
|
|
1751
|
-
input.setAttribute("tabindex", "-1");
|
|
1752
|
-
|
|
1753
|
-
input.addEventListener("change", () => {
|
|
1754
|
-
debounceSave();
|
|
1755
|
-
if (question.type === "multi") {
|
|
1756
|
-
updateDoneState(question.id);
|
|
1757
|
-
}
|
|
1758
|
-
});
|
|
1759
|
-
|
|
1760
|
-
const text = document.createElement("span");
|
|
1761
|
-
text.textContent = optionText;
|
|
1762
|
-
|
|
1763
|
-
label.appendChild(input);
|
|
1764
|
-
label.appendChild(text);
|
|
1765
|
-
|
|
1766
|
-
return label;
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
2858
|
function createQuestionCard(question, index, badgeNumber) {
|
|
1770
2859
|
const card = document.createElement("section");
|
|
1771
2860
|
card.className = "question-card";
|
|
@@ -1838,135 +2927,7 @@
|
|
|
1838
2927
|
}
|
|
1839
2928
|
|
|
1840
2929
|
if (question.type === "single" || question.type === "multi") {
|
|
1841
|
-
|
|
1842
|
-
list.className = "option-list";
|
|
1843
|
-
list.setAttribute("role", question.type === "single" ? "radiogroup" : "group");
|
|
1844
|
-
list.setAttribute("aria-labelledby", title.id);
|
|
1845
|
-
|
|
1846
|
-
const recommended = question.recommended;
|
|
1847
|
-
const recommendedList = Array.isArray(recommended)
|
|
1848
|
-
? recommended
|
|
1849
|
-
: recommended
|
|
1850
|
-
? [recommended]
|
|
1851
|
-
: [];
|
|
1852
|
-
const shouldPreselect = recommendedList.length > 0 && question.conviction !== "slight";
|
|
1853
|
-
|
|
1854
|
-
question.options.forEach((option, optionIndex) => {
|
|
1855
|
-
const optionLabel = getOptionLabel(option);
|
|
1856
|
-
const optionContent = isRichOption(option) ? option.content : null;
|
|
1857
|
-
|
|
1858
|
-
const label = document.createElement("label");
|
|
1859
|
-
label.className = "option-item";
|
|
1860
|
-
if (optionContent) {
|
|
1861
|
-
label.classList.add("has-code");
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
const input = document.createElement("input");
|
|
1865
|
-
input.type = question.type === "single" ? "radio" : "checkbox";
|
|
1866
|
-
input.name = question.id;
|
|
1867
|
-
input.value = optionLabel;
|
|
1868
|
-
input.id = `q-${question.id}-${optionIndex}`;
|
|
1869
|
-
|
|
1870
|
-
input.addEventListener("change", () => {
|
|
1871
|
-
debounceSave();
|
|
1872
|
-
if (question.type === "multi") {
|
|
1873
|
-
updateDoneState(question.id);
|
|
1874
|
-
}
|
|
1875
|
-
});
|
|
1876
|
-
|
|
1877
|
-
const text = document.createElement("span");
|
|
1878
|
-
text.textContent = optionLabel;
|
|
1879
|
-
|
|
1880
|
-
if (recommendedList.includes(optionLabel)) {
|
|
1881
|
-
const pill = document.createElement("span");
|
|
1882
|
-
pill.className = "recommended-pill";
|
|
1883
|
-
pill.textContent = "Recommended";
|
|
1884
|
-
text.appendChild(pill);
|
|
1885
|
-
|
|
1886
|
-
if (shouldPreselect) {
|
|
1887
|
-
input.checked = true;
|
|
1888
|
-
}
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
label.appendChild(input);
|
|
1892
|
-
label.appendChild(text);
|
|
1893
|
-
|
|
1894
|
-
if (optionContent) {
|
|
1895
|
-
const contentBlockEl = renderContentBlock(optionContent);
|
|
1896
|
-
if (contentBlockEl) {
|
|
1897
|
-
label.appendChild(contentBlockEl);
|
|
1898
|
-
}
|
|
1899
|
-
}
|
|
1900
|
-
|
|
1901
|
-
list.appendChild(label);
|
|
1902
|
-
});
|
|
1903
|
-
|
|
1904
|
-
const generateMoreEl = createGenerateMoreUI(question, list);
|
|
1905
|
-
if (generateMoreEl) list.appendChild(generateMoreEl);
|
|
1906
|
-
|
|
1907
|
-
const otherLabel = document.createElement("label");
|
|
1908
|
-
otherLabel.className = "option-item option-other";
|
|
1909
|
-
const otherCheck = document.createElement("input");
|
|
1910
|
-
otherCheck.type = question.type === "single" ? "radio" : "checkbox";
|
|
1911
|
-
otherCheck.name = question.id;
|
|
1912
|
-
otherCheck.value = "__other__";
|
|
1913
|
-
otherCheck.id = `q-${question.id}-other`;
|
|
1914
|
-
const otherInput = document.createElement("textarea");
|
|
1915
|
-
otherInput.className = "other-input";
|
|
1916
|
-
otherInput.placeholder = "Other...";
|
|
1917
|
-
otherInput.rows = 1;
|
|
1918
|
-
otherInput.dataset.questionId = question.id;
|
|
1919
|
-
const autoResizeOther = () => {
|
|
1920
|
-
otherInput.style.height = "auto";
|
|
1921
|
-
otherInput.style.height = otherInput.scrollHeight + "px";
|
|
1922
|
-
};
|
|
1923
|
-
otherInput.addEventListener("input", () => {
|
|
1924
|
-
autoResizeOther();
|
|
1925
|
-
if (otherInput.value && !otherCheck.checked) {
|
|
1926
|
-
otherCheck.checked = true;
|
|
1927
|
-
if (question.type === "multi") updateDoneState(question.id);
|
|
1928
|
-
}
|
|
1929
|
-
debounceSave();
|
|
1930
|
-
});
|
|
1931
|
-
otherInput.addEventListener("focus", () => {
|
|
1932
|
-
if (!otherCheck.checked) {
|
|
1933
|
-
otherCheck.checked = true;
|
|
1934
|
-
if (question.type === "multi") updateDoneState(question.id);
|
|
1935
|
-
debounceSave();
|
|
1936
|
-
}
|
|
1937
|
-
});
|
|
1938
|
-
otherCheck.addEventListener("change", () => {
|
|
1939
|
-
debounceSave();
|
|
1940
|
-
if (question.type === "multi") updateDoneState(question.id);
|
|
1941
|
-
if (otherCheck.checked) otherInput.focus();
|
|
1942
|
-
});
|
|
1943
|
-
setupEdgeNavigation(otherInput);
|
|
1944
|
-
otherLabel.appendChild(otherCheck);
|
|
1945
|
-
otherLabel.appendChild(otherInput);
|
|
1946
|
-
list.appendChild(otherLabel);
|
|
1947
|
-
|
|
1948
|
-
if (question.type === "multi") {
|
|
1949
|
-
const doneItem = document.createElement("div");
|
|
1950
|
-
doneItem.className = "option-item done-item disabled";
|
|
1951
|
-
doneItem.setAttribute("tabindex", "0");
|
|
1952
|
-
doneItem.dataset.doneFor = question.id;
|
|
1953
|
-
doneItem.innerHTML = '<span class="done-check">✓</span><span>Done</span>';
|
|
1954
|
-
doneItem.addEventListener("click", () => {
|
|
1955
|
-
if (!doneItem.classList.contains("disabled")) {
|
|
1956
|
-
nextQuestion();
|
|
1957
|
-
}
|
|
1958
|
-
});
|
|
1959
|
-
doneItem.addEventListener("keydown", (e) => {
|
|
1960
|
-
if ((e.key === "Enter" || e.key === " ") && !doneItem.classList.contains("disabled")) {
|
|
1961
|
-
e.preventDefault();
|
|
1962
|
-
e.stopPropagation();
|
|
1963
|
-
nextQuestion();
|
|
1964
|
-
}
|
|
1965
|
-
});
|
|
1966
|
-
list.appendChild(doneItem);
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
card.appendChild(list);
|
|
2930
|
+
card.appendChild(createChoiceQuestionList(question, title));
|
|
1970
2931
|
}
|
|
1971
2932
|
|
|
1972
2933
|
if (question.type === "text") {
|
|
@@ -2196,17 +3157,7 @@
|
|
|
2196
3157
|
if (files && files.length > 0) {
|
|
2197
3158
|
const file = files[0];
|
|
2198
3159
|
if (!file.type.startsWith("image/")) return;
|
|
2199
|
-
|
|
2200
|
-
const input = card.querySelector('input[type="file"]');
|
|
2201
|
-
if (input) {
|
|
2202
|
-
const dt = new DataTransfer();
|
|
2203
|
-
dt.items.add(file);
|
|
2204
|
-
input.files = dt.files;
|
|
2205
|
-
input.dispatchEvent(new Event("change"));
|
|
2206
|
-
}
|
|
2207
|
-
} else {
|
|
2208
|
-
void addPastedImage(question, file);
|
|
2209
|
-
}
|
|
3160
|
+
void addDroppedImage(question, file);
|
|
2210
3161
|
}
|
|
2211
3162
|
});
|
|
2212
3163
|
|
|
@@ -2306,8 +3257,9 @@
|
|
|
2306
3257
|
input.value = "";
|
|
2307
3258
|
return;
|
|
2308
3259
|
}
|
|
2309
|
-
} catch (
|
|
2310
|
-
|
|
3260
|
+
} catch (err) {
|
|
3261
|
+
const message = err instanceof Error ? err.message : "Failed to validate image.";
|
|
3262
|
+
setFieldError(questionId, message);
|
|
2311
3263
|
input.value = "";
|
|
2312
3264
|
return;
|
|
2313
3265
|
}
|
|
@@ -2315,37 +3267,15 @@
|
|
|
2315
3267
|
manager.addFile(questionId, file);
|
|
2316
3268
|
}
|
|
2317
3269
|
|
|
2318
|
-
function resolveQuestionContext(target) {
|
|
2319
|
-
const element = target && target.closest ? target : null;
|
|
2320
|
-
let card = element ? element.closest(".question-card") : null;
|
|
2321
|
-
|
|
2322
|
-
if (!card) {
|
|
2323
|
-
card = document.querySelector(".question-card.active");
|
|
2324
|
-
}
|
|
2325
|
-
|
|
2326
|
-
if (card?.dataset?.questionId) {
|
|
2327
|
-
const question = questions.find((q) => q.id === card.dataset.questionId);
|
|
2328
|
-
if (question) {
|
|
2329
|
-
return { question, card };
|
|
2330
|
-
}
|
|
2331
|
-
}
|
|
2332
|
-
|
|
2333
|
-
const question = questions[nav.questionIndex];
|
|
2334
|
-
const fallbackCard = nav.cards[nav.questionIndex];
|
|
2335
|
-
if (!question || !fallbackCard) return null;
|
|
2336
|
-
return { question, card: fallbackCard };
|
|
2337
|
-
}
|
|
2338
|
-
|
|
2339
3270
|
function revealAttachmentArea(questionId) {
|
|
2340
3271
|
const attachInline = document.querySelector(
|
|
2341
3272
|
`[data-attach-inline-for="${escapeSelector(questionId)}"]`
|
|
2342
3273
|
);
|
|
2343
|
-
if (attachInline?.classList.contains("hidden"))
|
|
2344
|
-
|
|
2345
|
-
}
|
|
3274
|
+
if (!attachInline?.classList.contains("hidden")) return;
|
|
3275
|
+
attachInline.classList.remove("hidden");
|
|
2346
3276
|
}
|
|
2347
3277
|
|
|
2348
|
-
async function
|
|
3278
|
+
async function addDroppedImage(question, file) {
|
|
2349
3279
|
if (countUploadedFiles(question.id) + 1 > MAX_IMAGES) {
|
|
2350
3280
|
setFieldError(question.id, `Only ${MAX_IMAGES} images allowed.`);
|
|
2351
3281
|
return;
|
|
@@ -2357,59 +3287,20 @@
|
|
|
2357
3287
|
setFieldError(question.id, validation.error);
|
|
2358
3288
|
return;
|
|
2359
3289
|
}
|
|
2360
|
-
} catch (
|
|
2361
|
-
|
|
3290
|
+
} catch (err) {
|
|
3291
|
+
const message = err instanceof Error ? err.message : "Failed to validate image.";
|
|
3292
|
+
setFieldError(question.id, message);
|
|
2362
3293
|
return;
|
|
2363
3294
|
}
|
|
2364
3295
|
|
|
2365
3296
|
setFieldError(question.id, "");
|
|
2366
3297
|
if (question.type === "image") {
|
|
2367
3298
|
questionImages.addFile(question.id, file);
|
|
2368
|
-
} else {
|
|
2369
|
-
revealAttachmentArea(question.id);
|
|
2370
|
-
attachments.addFile(question.id, file);
|
|
2371
|
-
}
|
|
2372
|
-
}
|
|
2373
|
-
|
|
2374
|
-
function handlePaste(event) {
|
|
2375
|
-
if (nav.inSubmitArea || session.expired) return;
|
|
2376
|
-
const clipboard = event.clipboardData;
|
|
2377
|
-
if (!clipboard) return;
|
|
2378
|
-
|
|
2379
|
-
const active = document.activeElement;
|
|
2380
|
-
const isTextInput = active && (active.tagName === "TEXTAREA" || (active.tagName === "INPUT" && active.type === "text"));
|
|
2381
|
-
if (isTextInput && clipboard.getData("text/plain")) {
|
|
2382
3299
|
return;
|
|
2383
3300
|
}
|
|
2384
3301
|
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
const items = Array.from(clipboard.items || []);
|
|
2389
|
-
const imageItem = items.find((item) => item.type && item.type.startsWith("image/"));
|
|
2390
|
-
|
|
2391
|
-
if (imageItem) {
|
|
2392
|
-
const file = imageItem.getAsFile();
|
|
2393
|
-
if (!file) return;
|
|
2394
|
-
event.preventDefault();
|
|
2395
|
-
void addPastedImage(context.question, file);
|
|
2396
|
-
return;
|
|
2397
|
-
}
|
|
2398
|
-
|
|
2399
|
-
const text = clipboard.getData("text/plain")?.trim();
|
|
2400
|
-
const isPathLike = text && (text.startsWith("/") || text.startsWith("~") || text.match(/^[a-zA-Z]:\\/));
|
|
2401
|
-
const hasImageExtension = text && /\.(png|jpe?g|gif|webp)$/i.test(text);
|
|
2402
|
-
|
|
2403
|
-
if (isPathLike && hasImageExtension) {
|
|
2404
|
-
event.preventDefault();
|
|
2405
|
-
const normalizedPath = normalizePath(text);
|
|
2406
|
-
if (context.question.type === "image") {
|
|
2407
|
-
questionImages.addPath(context.question.id, normalizedPath);
|
|
2408
|
-
} else {
|
|
2409
|
-
revealAttachmentArea(context.question.id);
|
|
2410
|
-
attachments.addPath(context.question.id, normalizedPath);
|
|
2411
|
-
}
|
|
2412
|
-
}
|
|
3302
|
+
revealAttachmentArea(question.id);
|
|
3303
|
+
attachments.addFile(question.id, file);
|
|
2413
3304
|
}
|
|
2414
3305
|
|
|
2415
3306
|
function countUploadedFiles(excludingId) {
|
|
@@ -2433,13 +3324,24 @@
|
|
|
2433
3324
|
if (question.type === "single") {
|
|
2434
3325
|
const selected = formEl.querySelector(`input[name="${escapeSelector(id)}"]:checked`);
|
|
2435
3326
|
if (!selected) return "";
|
|
2436
|
-
if (selected.value === "__other__")
|
|
2437
|
-
|
|
3327
|
+
if (selected.value === "__other__") {
|
|
3328
|
+
const otherValue = getOtherValue(id).trim();
|
|
3329
|
+
return otherValue ? { option: otherValue } : "";
|
|
3330
|
+
}
|
|
3331
|
+
const note = questionCanClarifyOption(question) ? getChoiceNote(id, selected.value) : "";
|
|
3332
|
+
return note ? { option: selected.value, note } : { option: selected.value };
|
|
2438
3333
|
}
|
|
2439
3334
|
if (question.type === "multi") {
|
|
2440
3335
|
return Array.from(
|
|
2441
3336
|
formEl.querySelectorAll(`input[name="${escapeSelector(id)}"]:checked`)
|
|
2442
|
-
).map((input) =>
|
|
3337
|
+
).map((input) => {
|
|
3338
|
+
if (input.value === "__other__") {
|
|
3339
|
+
const otherValue = getOtherValue(id).trim();
|
|
3340
|
+
return otherValue ? { option: otherValue } : null;
|
|
3341
|
+
}
|
|
3342
|
+
const note = questionCanClarifyOption(question) ? getChoiceNote(id, input.value) : "";
|
|
3343
|
+
return note ? { option: input.value, note } : { option: input.value };
|
|
3344
|
+
}).filter((value) => value && value.option);
|
|
2443
3345
|
}
|
|
2444
3346
|
if (question.type === "text") {
|
|
2445
3347
|
const textarea = formEl.querySelector(`textarea[data-question-id="${escapeSelector(id)}"]`);
|
|
@@ -2465,31 +3367,54 @@
|
|
|
2465
3367
|
}
|
|
2466
3368
|
|
|
2467
3369
|
function collectPersistedData() {
|
|
2468
|
-
const
|
|
3370
|
+
const answers = {};
|
|
2469
3371
|
questions.forEach((question) => {
|
|
2470
3372
|
if (question.type === "info" || question.type === "image") return;
|
|
2471
|
-
|
|
3373
|
+
answers[question.id] = getQuestionValue(question);
|
|
2472
3374
|
});
|
|
2473
|
-
return
|
|
3375
|
+
return {
|
|
3376
|
+
answers,
|
|
3377
|
+
savedOptionInsights: serializeSavedOptionInsights(),
|
|
3378
|
+
};
|
|
2474
3379
|
}
|
|
2475
3380
|
|
|
2476
|
-
function
|
|
3381
|
+
function getSavedSingleChoiceValue(value) {
|
|
3382
|
+
return normalizeChoiceResponseValue(value);
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
function getSavedMultiChoiceValues(value) {
|
|
3386
|
+
if (!Array.isArray(value)) return [];
|
|
3387
|
+
return value.map((item) => normalizeChoiceResponseValue(item)).filter(Boolean);
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
function populateForm(saved, options = {}) {
|
|
3391
|
+
const { preserveChoiceNotes = false } = options;
|
|
2477
3392
|
if (!saved) return;
|
|
2478
3393
|
questions.forEach((question) => {
|
|
3394
|
+
const hasSavedValue = Object.prototype.hasOwnProperty.call(saved, question.id);
|
|
2479
3395
|
const value = saved[question.id];
|
|
2480
|
-
if (question.type === "single"
|
|
3396
|
+
if (question.type === "single") {
|
|
2481
3397
|
const radios = formEl.querySelectorAll(
|
|
2482
3398
|
`input[name="${escapeSelector(question.id)}"]`
|
|
2483
3399
|
);
|
|
2484
3400
|
radios.forEach((radio) => {
|
|
2485
3401
|
radio.checked = false;
|
|
2486
3402
|
});
|
|
2487
|
-
if (
|
|
3403
|
+
if (!preserveChoiceNotes) {
|
|
3404
|
+
clearChoiceNotes(question.id);
|
|
3405
|
+
}
|
|
3406
|
+
if (!hasSavedValue) return;
|
|
3407
|
+
const choiceValue = getSavedSingleChoiceValue(value);
|
|
3408
|
+
if (!choiceValue) return;
|
|
3409
|
+
if (choiceValue.option !== "") {
|
|
2488
3410
|
const input = formEl.querySelector(
|
|
2489
|
-
`input[name="${escapeSelector(question.id)}"][value="${escapeSelector(
|
|
3411
|
+
`input[name="${escapeSelector(question.id)}"][value="${escapeSelector(choiceValue.option)}"]`
|
|
2490
3412
|
);
|
|
2491
3413
|
if (input) {
|
|
2492
3414
|
input.checked = true;
|
|
3415
|
+
if (questionCanClarifyOption(question) && choiceValue.note) {
|
|
3416
|
+
setChoiceNote(question.id, choiceValue.option, choiceValue.note);
|
|
3417
|
+
}
|
|
2493
3418
|
} else {
|
|
2494
3419
|
const otherCheck = formEl.querySelector(
|
|
2495
3420
|
`input[name="${escapeSelector(question.id)}"][value="__other__"]`
|
|
@@ -2499,28 +3424,36 @@
|
|
|
2499
3424
|
);
|
|
2500
3425
|
if (otherCheck && otherInput) {
|
|
2501
3426
|
otherCheck.checked = true;
|
|
2502
|
-
otherInput.value =
|
|
3427
|
+
otherInput.value = choiceValue.option;
|
|
2503
3428
|
otherInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2504
3429
|
}
|
|
2505
3430
|
}
|
|
2506
3431
|
}
|
|
2507
3432
|
}
|
|
2508
|
-
if (question.type === "multi"
|
|
3433
|
+
if (question.type === "multi") {
|
|
2509
3434
|
const checkboxes = formEl.querySelectorAll(
|
|
2510
3435
|
`input[name="${escapeSelector(question.id)}"]`
|
|
2511
3436
|
);
|
|
2512
3437
|
checkboxes.forEach((checkbox) => {
|
|
2513
3438
|
checkbox.checked = false;
|
|
2514
3439
|
});
|
|
3440
|
+
if (!preserveChoiceNotes) {
|
|
3441
|
+
clearChoiceNotes(question.id);
|
|
3442
|
+
}
|
|
3443
|
+
if (!hasSavedValue) return;
|
|
3444
|
+
const choiceValues = getSavedMultiChoiceValues(value);
|
|
2515
3445
|
let otherValue = "";
|
|
2516
|
-
|
|
3446
|
+
choiceValues.forEach((choiceValue) => {
|
|
2517
3447
|
const input = formEl.querySelector(
|
|
2518
|
-
`input[name="${escapeSelector(question.id)}"][value="${escapeSelector(
|
|
3448
|
+
`input[name="${escapeSelector(question.id)}"][value="${escapeSelector(choiceValue.option)}"]`
|
|
2519
3449
|
);
|
|
2520
3450
|
if (input) {
|
|
2521
3451
|
input.checked = true;
|
|
2522
|
-
|
|
2523
|
-
|
|
3452
|
+
if (questionCanClarifyOption(question) && choiceValue.note) {
|
|
3453
|
+
setChoiceNote(question.id, choiceValue.option, choiceValue.note);
|
|
3454
|
+
}
|
|
3455
|
+
} else if (choiceValue.option) {
|
|
3456
|
+
otherValue = choiceValue.option;
|
|
2524
3457
|
}
|
|
2525
3458
|
});
|
|
2526
3459
|
if (otherValue) {
|
|
@@ -2556,16 +3489,29 @@
|
|
|
2556
3489
|
}
|
|
2557
3490
|
}
|
|
2558
3491
|
|
|
3492
|
+
function rerenderChoiceQuestions() {
|
|
3493
|
+
questions.forEach((question) => {
|
|
3494
|
+
if (question.type !== "single" && question.type !== "multi") return;
|
|
3495
|
+
replaceQuestionOptionList(question, getQuestionValue(question));
|
|
3496
|
+
});
|
|
3497
|
+
}
|
|
3498
|
+
|
|
2559
3499
|
function loadProgress() {
|
|
2560
3500
|
if (!session.storageKey) return;
|
|
2561
3501
|
let loaded = false;
|
|
2562
3502
|
try {
|
|
2563
3503
|
const saved = localStorage.getItem(session.storageKey);
|
|
2564
3504
|
if (saved) {
|
|
2565
|
-
|
|
3505
|
+
const parsed = JSON.parse(saved);
|
|
3506
|
+
const answers = parsed && typeof parsed === "object" && parsed.answers && typeof parsed.answers === "object"
|
|
3507
|
+
? parsed.answers
|
|
3508
|
+
: parsed;
|
|
3509
|
+
populateForm(answers);
|
|
3510
|
+
restoreSavedOptionInsights(parsed?.savedOptionInsights);
|
|
2566
3511
|
questions.forEach((q) => {
|
|
2567
3512
|
if (q.type === "multi") updateDoneState(q.id);
|
|
2568
3513
|
});
|
|
3514
|
+
rerenderChoiceQuestions();
|
|
2569
3515
|
loaded = true;
|
|
2570
3516
|
}
|
|
2571
3517
|
} catch (_err) {
|
|
@@ -2668,6 +3614,12 @@
|
|
|
2668
3614
|
updateDoneState(q.id);
|
|
2669
3615
|
}
|
|
2670
3616
|
});
|
|
3617
|
+
rerenderChoiceQuestions();
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
function populateFromSavedOptionInsights(savedOptionInsights) {
|
|
3621
|
+
restoreSavedOptionInsights(savedOptionInsights);
|
|
3622
|
+
rerenderChoiceQuestions();
|
|
2671
3623
|
}
|
|
2672
3624
|
|
|
2673
3625
|
function readFileBase64(file) {
|
|
@@ -2735,6 +3687,7 @@
|
|
|
2735
3687
|
token: sessionToken,
|
|
2736
3688
|
responses: payload.responses,
|
|
2737
3689
|
images: payload.images,
|
|
3690
|
+
savedOptionInsights: serializeSavedOptionInsights(),
|
|
2738
3691
|
submitted,
|
|
2739
3692
|
}),
|
|
2740
3693
|
});
|
|
@@ -2840,6 +3793,7 @@
|
|
|
2840
3793
|
function init() {
|
|
2841
3794
|
initTheme();
|
|
2842
3795
|
clearReloadIntent();
|
|
3796
|
+
normalizeOptionKeysFromData();
|
|
2843
3797
|
|
|
2844
3798
|
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
2845
3799
|
const modKey = document.querySelector(".mod-key");
|
|
@@ -2874,6 +3828,9 @@
|
|
|
2874
3828
|
// Pre-populate: savedAnswers takes precedence over localStorage
|
|
2875
3829
|
if (data.savedAnswers && Array.isArray(data.savedAnswers)) {
|
|
2876
3830
|
populateFromSavedAnswers(data.savedAnswers);
|
|
3831
|
+
if (Array.isArray(data.savedOptionInsights)) {
|
|
3832
|
+
populateFromSavedOptionInsights(data.savedOptionInsights);
|
|
3833
|
+
}
|
|
2877
3834
|
initStorageKeyOnly();
|
|
2878
3835
|
} else {
|
|
2879
3836
|
initStorage();
|
|
@@ -2978,8 +3935,6 @@
|
|
|
2978
3935
|
}
|
|
2979
3936
|
}
|
|
2980
3937
|
}, true);
|
|
2981
|
-
document.addEventListener("paste", handlePaste);
|
|
2982
|
-
|
|
2983
3938
|
if (timeout > 0) {
|
|
2984
3939
|
startCountdownDisplay();
|
|
2985
3940
|
timers.expiration = setTimeout(() => {
|