pi-interview 0.8.6 → 0.8.7
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 +4 -4
- package/form/index.html +1 -1
- package/form/script.js +83 -183
- package/form/styles.css +17 -13
- package/package.json +1 -1
- package/server.ts +0 -101
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ Restart pi to load the extension.
|
|
|
28
28
|
- **Conviction & Weight**: Control recommendation strength (`conviction`) and visual prominence (`weight`)
|
|
29
29
|
- **"Other" Option**: Single/multi select questions support custom text input
|
|
30
30
|
- **Per-Question Attachments**: Attach images to any question via button or drag & drop
|
|
31
|
-
- **Keyboard Navigation**: Full keyboard support with arrow keys, Tab, Enter
|
|
31
|
+
- **Keyboard Navigation**: Full keyboard support with `⌘+←`/`⌘+→` (`Ctrl` off macOS), arrow keys, Tab, Enter
|
|
32
32
|
- **Auto-save**: Responses saved to localStorage, restored on reload
|
|
33
33
|
- **Session Timeout**: Configurable timeout with countdown badge, refreshes on activity
|
|
34
34
|
- **Multi-Agent Support**: Queue detection prevents focus stealing when multiple agents run interviews
|
|
@@ -39,7 +39,7 @@ Restart pi to load the extension.
|
|
|
39
39
|
- **Image Support**: Drag & drop anywhere on question, file picker, or paste a path into the dedicated path field
|
|
40
40
|
- **Path Normalization**: Handles shell-escaped paths (`\ `) and macOS screenshot filenames (narrow no-break space before AM/PM)
|
|
41
41
|
- **Generate & Review Options**: Single/multi-select questions, including rich-option questions with inline content blocks, show "✦ Generate more" (appends new choices) and "↻ Review options" (reviews options and rewrites the question for clarity) buttons powered by an LLM
|
|
42
|
-
- **Ask About an Option**: Single/multi options, including rich options with inline content blocks, can open an inline assistant panel with prompt chips, freeform follow-up questions, provider/model overrides under Advanced, and
|
|
42
|
+
- **Ask About an Option**: Single/multi options, including rich options with inline content blocks, can open an inline assistant panel with prompt chips, freeform follow-up questions, provider/model overrides under Advanced, and auto-saved option analysis
|
|
43
43
|
- **Option Clarifications**: Single/multi options, including rich options with inline content blocks, can reveal a separate inline `Optional clarification...` field when selected, letting users attach a short note to a choice without using `Ask`
|
|
44
44
|
- **Tool Discoverability (pi v0.59+)**: Registers a `promptSnippet` so `interview` remains eligible for inclusion in pi's default `Available tools` prompt section
|
|
45
45
|
- **Themes**: Built-in default + optional light/dark + custom theme CSS
|
|
@@ -287,10 +287,10 @@ All media types support `position`: `"above"` (default), `"below"`, or `"side"`
|
|
|
287
287
|
| Key | Action |
|
|
288
288
|
|-----|--------|
|
|
289
289
|
| `↑` `↓` | Navigate options |
|
|
290
|
-
|
|
|
290
|
+
| `⌘+←` `⌘+→` | Navigate between questions (`Ctrl` off macOS) |
|
|
291
291
|
| `Tab` | Cycle through options |
|
|
292
292
|
| `Enter` / `Space` | Select option |
|
|
293
|
-
| `⌘+V` | Paste
|
|
293
|
+
| `⌘+V` | Paste text in the focused input |
|
|
294
294
|
| `⌘+Enter` | Submit form |
|
|
295
295
|
| `Esc` | Show exit overlay (press twice to quit) |
|
|
296
296
|
| `⌘+Shift+L` | Toggle theme (if enabled; appears in shortcuts bar) |
|
package/form/index.html
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
<span class="shortcut-label">Options</span>
|
|
43
43
|
</div>
|
|
44
44
|
<div class="shortcut">
|
|
45
|
-
<span class="shortcut-keys"><kbd>←</kbd><kbd>→</kbd></span>
|
|
45
|
+
<span class="shortcut-keys"><kbd class="mod-key">⌘</kbd><kbd>←</kbd><kbd>→</kbd></span>
|
|
46
46
|
<span class="shortcut-label">Questions</span>
|
|
47
47
|
</div>
|
|
48
48
|
<div class="shortcut-divider"></div>
|
package/form/script.js
CHANGED
|
@@ -568,25 +568,6 @@
|
|
|
568
568
|
return note ? { option, note } : { option };
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
-
function renameChoiceAnswerValue(question, value, previousOption, nextOption) {
|
|
572
|
-
if (!nextOption || (question.type !== "single" && question.type !== "multi")) {
|
|
573
|
-
return value;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
if (question.type === "single") {
|
|
577
|
-
const choiceValue = normalizeChoiceResponseValue(value);
|
|
578
|
-
if (!choiceValue || choiceValue.option !== previousOption) return value;
|
|
579
|
-
return choiceValue.note ? { option: nextOption, note: choiceValue.note } : { option: nextOption };
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
if (!Array.isArray(value)) return value;
|
|
583
|
-
return value.map((item) => {
|
|
584
|
-
const choiceValue = normalizeChoiceResponseValue(item);
|
|
585
|
-
if (!choiceValue || choiceValue.option !== previousOption) return item;
|
|
586
|
-
return choiceValue.note ? { option: nextOption, note: choiceValue.note } : { option: nextOption };
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
|
|
590
571
|
function preserveChoiceAnswerValue(question, value, validLabels) {
|
|
591
572
|
if (question.type === "single") {
|
|
592
573
|
const choiceValue = normalizeChoiceResponseValue(value);
|
|
@@ -775,6 +756,7 @@
|
|
|
775
756
|
selectedProvider: parsed.provider || getFirstProvider(),
|
|
776
757
|
selectedModel,
|
|
777
758
|
selectedDepth: "standard",
|
|
759
|
+
savedInsightId: null,
|
|
778
760
|
abortController: null,
|
|
779
761
|
};
|
|
780
762
|
}
|
|
@@ -848,15 +830,6 @@
|
|
|
848
830
|
}
|
|
849
831
|
}
|
|
850
832
|
|
|
851
|
-
function updatePinnedInsightOptionText(questionId, optionKey, optionText) {
|
|
852
|
-
const existing = optionInsightState.pinned.get(questionId) || [];
|
|
853
|
-
existing.forEach((insight) => {
|
|
854
|
-
if (insight.optionKey === optionKey) {
|
|
855
|
-
insight.optionText = optionText;
|
|
856
|
-
}
|
|
857
|
-
});
|
|
858
|
-
}
|
|
859
|
-
|
|
860
833
|
function pruneQuestionOptionInsights(questionId) {
|
|
861
834
|
const validKeys = new Set(getOptionKeys(questionId));
|
|
862
835
|
const existing = optionInsightState.pinned.get(questionId) || [];
|
|
@@ -962,55 +935,6 @@
|
|
|
962
935
|
}
|
|
963
936
|
}
|
|
964
937
|
|
|
965
|
-
async function runOptionAction(question, optionKey, action, text) {
|
|
966
|
-
const preservedValue = getQuestionValue(question);
|
|
967
|
-
const previousText = getOptionTextByKey(question.id, optionKey);
|
|
968
|
-
try {
|
|
969
|
-
const response = await fetch("/option-action", {
|
|
970
|
-
method: "POST",
|
|
971
|
-
headers: { "Content-Type": "application/json" },
|
|
972
|
-
body: JSON.stringify({ token: sessionToken, questionId: question.id, optionKey, action, text }),
|
|
973
|
-
});
|
|
974
|
-
const result = await response.json();
|
|
975
|
-
if (!result.ok) throw new Error(result.error || "Option action failed");
|
|
976
|
-
|
|
977
|
-
if (result.question && Array.isArray(result.question.options)) {
|
|
978
|
-
question.options = result.question.options;
|
|
979
|
-
question.recommended = result.question.recommended;
|
|
980
|
-
question.conviction = result.question.conviction;
|
|
981
|
-
}
|
|
982
|
-
if (Array.isArray(result.optionKeys)) {
|
|
983
|
-
setOptionKeys(question.id, result.optionKeys);
|
|
984
|
-
pruneQuestionOptionInsights(question.id);
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
if (action === "replace-text") {
|
|
988
|
-
const nextText = getOptionTextByKey(question.id, optionKey);
|
|
989
|
-
updatePinnedInsightOptionText(question.id, optionKey, nextText);
|
|
990
|
-
if (optionInsightState.active && optionInsightState.active.questionId === question.id && optionInsightState.active.optionKey === optionKey && optionInsightState.active.result) {
|
|
991
|
-
optionInsightState.active.result.suggestedText = nextText;
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
let nextValue = preservedValue;
|
|
996
|
-
if (action === "replace-text" && text) {
|
|
997
|
-
nextValue = renameChoiceAnswerValue(question, preservedValue, previousText, text);
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
replaceQuestionOptionList(question, nextValue, optionKey);
|
|
1001
|
-
debounceSave();
|
|
1002
|
-
refreshCountdown();
|
|
1003
|
-
return true;
|
|
1004
|
-
} catch (err) {
|
|
1005
|
-
const active = getActiveInsight(question.id, optionKey);
|
|
1006
|
-
if (active) {
|
|
1007
|
-
active.error = err instanceof Error ? err.message : "Option action failed";
|
|
1008
|
-
replaceQuestionOptionList(question, preservedValue, optionKey);
|
|
1009
|
-
}
|
|
1010
|
-
return false;
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
938
|
async function submitOptionInsight(question, optionKey) {
|
|
1015
939
|
const active = getActiveInsight(question.id, optionKey);
|
|
1016
940
|
if (!active) return;
|
|
@@ -1028,6 +952,8 @@
|
|
|
1028
952
|
|
|
1029
953
|
active.loading = true;
|
|
1030
954
|
active.error = "";
|
|
955
|
+
active.result = null;
|
|
956
|
+
active.savedInsightId = null;
|
|
1031
957
|
active.abortController = new AbortController();
|
|
1032
958
|
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1033
959
|
|
|
@@ -1056,7 +982,7 @@
|
|
|
1056
982
|
};
|
|
1057
983
|
active.error = "";
|
|
1058
984
|
const optionText = typeof result.optionText === "string" ? result.optionText : getOptionTextByKey(question.id, optionKey);
|
|
1059
|
-
|
|
985
|
+
saveActiveInsight(question, optionKey, optionText);
|
|
1060
986
|
refreshCountdown();
|
|
1061
987
|
} catch (err) {
|
|
1062
988
|
if (!(err instanceof Error && err.name === "AbortError")) {
|
|
@@ -1071,13 +997,13 @@
|
|
|
1071
997
|
}
|
|
1072
998
|
}
|
|
1073
999
|
|
|
1074
|
-
function
|
|
1000
|
+
function saveActiveInsight(question, optionKey, optionText) {
|
|
1075
1001
|
const active = getActiveInsight(question.id, optionKey);
|
|
1076
1002
|
if (!active || !active.result) return;
|
|
1077
|
-
const
|
|
1003
|
+
const insightId = active.savedInsightId || makeClientId("insight");
|
|
1078
1004
|
const questionInsights = optionInsightState.pinned.get(question.id) || [];
|
|
1079
|
-
|
|
1080
|
-
id:
|
|
1005
|
+
const nextInsight = {
|
|
1006
|
+
id: insightId,
|
|
1081
1007
|
questionId: question.id,
|
|
1082
1008
|
optionKey,
|
|
1083
1009
|
optionText,
|
|
@@ -1087,10 +1013,16 @@
|
|
|
1087
1013
|
suggestedText: active.result.suggestedText,
|
|
1088
1014
|
modelUsed: active.result.modelUsed ?? null,
|
|
1089
1015
|
createdAt: new Date().toISOString(),
|
|
1090
|
-
}
|
|
1016
|
+
};
|
|
1017
|
+
const existingIndex = questionInsights.findIndex((insight) => insight.id === insightId);
|
|
1018
|
+
if (existingIndex === -1) {
|
|
1019
|
+
questionInsights.push(nextInsight);
|
|
1020
|
+
} else {
|
|
1021
|
+
questionInsights[existingIndex] = nextInsight;
|
|
1022
|
+
}
|
|
1023
|
+
active.savedInsightId = insightId;
|
|
1091
1024
|
optionInsightState.pinned.set(question.id, questionInsights);
|
|
1092
1025
|
debounceSave();
|
|
1093
|
-
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1094
1026
|
}
|
|
1095
1027
|
|
|
1096
1028
|
function createPinnedInsightCard(question, optionKey, insight) {
|
|
@@ -1105,18 +1037,18 @@
|
|
|
1105
1037
|
prompt.textContent = insight.prompt;
|
|
1106
1038
|
head.appendChild(prompt);
|
|
1107
1039
|
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1040
|
+
const remove = document.createElement("button");
|
|
1041
|
+
remove.type = "button";
|
|
1042
|
+
remove.className = "option-insight-remove";
|
|
1043
|
+
remove.textContent = "Remove";
|
|
1044
|
+
remove.addEventListener("click", (event) => {
|
|
1113
1045
|
event.preventDefault();
|
|
1114
1046
|
event.stopPropagation();
|
|
1115
1047
|
removePinnedInsight(question.id, insight.id);
|
|
1116
1048
|
debounceSave();
|
|
1117
1049
|
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1118
1050
|
});
|
|
1119
|
-
head.appendChild(
|
|
1051
|
+
head.appendChild(remove);
|
|
1120
1052
|
card.appendChild(head);
|
|
1121
1053
|
|
|
1122
1054
|
const summary = document.createElement("p");
|
|
@@ -1367,57 +1299,6 @@
|
|
|
1367
1299
|
result.appendChild(meta);
|
|
1368
1300
|
}
|
|
1369
1301
|
|
|
1370
|
-
const resultActions = document.createElement("div");
|
|
1371
|
-
resultActions.className = "option-insight-result-actions";
|
|
1372
|
-
|
|
1373
|
-
const pinBtn = document.createElement("button");
|
|
1374
|
-
pinBtn.type = "button";
|
|
1375
|
-
pinBtn.className = "option-insight-secondary";
|
|
1376
|
-
pinBtn.textContent = "Pin";
|
|
1377
|
-
pinBtn.addEventListener("click", (event) => {
|
|
1378
|
-
event.preventDefault();
|
|
1379
|
-
event.stopPropagation();
|
|
1380
|
-
pinActiveInsight(question, optionKey);
|
|
1381
|
-
});
|
|
1382
|
-
resultActions.appendChild(pinBtn);
|
|
1383
|
-
|
|
1384
|
-
const moveUpBtn = document.createElement("button");
|
|
1385
|
-
moveUpBtn.type = "button";
|
|
1386
|
-
moveUpBtn.className = "option-insight-secondary";
|
|
1387
|
-
moveUpBtn.textContent = "Move up";
|
|
1388
|
-
moveUpBtn.disabled = getOptionIndexByKey(question.id, optionKey) <= 0;
|
|
1389
|
-
moveUpBtn.addEventListener("click", (event) => {
|
|
1390
|
-
event.preventDefault();
|
|
1391
|
-
event.stopPropagation();
|
|
1392
|
-
runOptionAction(question, optionKey, "move-up");
|
|
1393
|
-
});
|
|
1394
|
-
resultActions.appendChild(moveUpBtn);
|
|
1395
|
-
|
|
1396
|
-
if (active.result.suggestedText) {
|
|
1397
|
-
const replaceBtn = document.createElement("button");
|
|
1398
|
-
replaceBtn.type = "button";
|
|
1399
|
-
replaceBtn.className = "option-insight-primary";
|
|
1400
|
-
replaceBtn.textContent = "Use rewrite";
|
|
1401
|
-
replaceBtn.addEventListener("click", (event) => {
|
|
1402
|
-
event.preventDefault();
|
|
1403
|
-
event.stopPropagation();
|
|
1404
|
-
runOptionAction(question, optionKey, "replace-text", active.result.suggestedText);
|
|
1405
|
-
});
|
|
1406
|
-
resultActions.appendChild(replaceBtn);
|
|
1407
|
-
|
|
1408
|
-
const addBtn = document.createElement("button");
|
|
1409
|
-
addBtn.type = "button";
|
|
1410
|
-
addBtn.className = "option-insight-secondary";
|
|
1411
|
-
addBtn.textContent = "Add rewrite as option";
|
|
1412
|
-
addBtn.addEventListener("click", (event) => {
|
|
1413
|
-
event.preventDefault();
|
|
1414
|
-
event.stopPropagation();
|
|
1415
|
-
runOptionAction(question, optionKey, "add-option", active.result.suggestedText);
|
|
1416
|
-
});
|
|
1417
|
-
resultActions.appendChild(addBtn);
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
result.appendChild(resultActions);
|
|
1421
1302
|
panel.appendChild(result);
|
|
1422
1303
|
}
|
|
1423
1304
|
|
|
@@ -1441,7 +1322,6 @@
|
|
|
1441
1322
|
setChoiceNote(question.id, optionLabel, input.value);
|
|
1442
1323
|
debounceSave();
|
|
1443
1324
|
});
|
|
1444
|
-
setupEdgeNavigation(input);
|
|
1445
1325
|
wrap.appendChild(input);
|
|
1446
1326
|
|
|
1447
1327
|
return wrap;
|
|
@@ -1468,10 +1348,10 @@
|
|
|
1468
1348
|
const main = document.createElement("div");
|
|
1469
1349
|
main.className = "option-row-main";
|
|
1470
1350
|
|
|
1471
|
-
const
|
|
1472
|
-
|
|
1351
|
+
const item = document.createElement("div");
|
|
1352
|
+
item.className = "option-item";
|
|
1473
1353
|
if (optionContent) {
|
|
1474
|
-
|
|
1354
|
+
item.classList.add("has-code");
|
|
1475
1355
|
}
|
|
1476
1356
|
const input = document.createElement("input");
|
|
1477
1357
|
input.type = question.type === "single" ? "radio" : "checkbox";
|
|
@@ -1492,7 +1372,9 @@
|
|
|
1492
1372
|
|
|
1493
1373
|
const text = document.createElement("span");
|
|
1494
1374
|
text.className = "option-item-label";
|
|
1375
|
+
text.id = `${input.id}-label`;
|
|
1495
1376
|
text.textContent = optionLabel;
|
|
1377
|
+
input.setAttribute("aria-labelledby", text.id);
|
|
1496
1378
|
|
|
1497
1379
|
const recommendedList = resolveRecommendedLabels(question.recommended, question.options || []);
|
|
1498
1380
|
const shouldPreselect = recommendedList.length > 0 && question.conviction !== "slight";
|
|
@@ -1516,10 +1398,19 @@
|
|
|
1516
1398
|
}
|
|
1517
1399
|
}
|
|
1518
1400
|
|
|
1519
|
-
|
|
1520
|
-
|
|
1401
|
+
item.appendChild(input);
|
|
1402
|
+
item.appendChild(body);
|
|
1403
|
+
item.addEventListener("click", (event) => {
|
|
1404
|
+
if (!(event.target instanceof Element)) return;
|
|
1405
|
+
if (event.target === input || event.target.closest("button, input, textarea, select, a")) return;
|
|
1406
|
+
const selection = window.getSelection();
|
|
1407
|
+
if (selection && !selection.isCollapsed && item.contains(selection.anchorNode) && item.contains(selection.focusNode)) {
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
input.click();
|
|
1411
|
+
});
|
|
1521
1412
|
|
|
1522
|
-
main.appendChild(
|
|
1413
|
+
main.appendChild(item);
|
|
1523
1414
|
const selectedLabels = new Set(getSelectedOptionLabels(question.id));
|
|
1524
1415
|
const noteInput = createOptionNoteInput(question, optionLabel, input.checked || selectedLabels.has(optionLabel));
|
|
1525
1416
|
|
|
@@ -1545,7 +1436,8 @@
|
|
|
1545
1436
|
if (noteInput) row.appendChild(noteInput);
|
|
1546
1437
|
}
|
|
1547
1438
|
|
|
1548
|
-
const pinnedInsights = getPinnedInsights(question.id, optionKey)
|
|
1439
|
+
const pinnedInsights = getPinnedInsights(question.id, optionKey)
|
|
1440
|
+
.filter((insight) => insight.id !== activeInsight?.savedInsightId);
|
|
1549
1441
|
if (pinnedInsights.length > 0) {
|
|
1550
1442
|
const pinnedWrap = document.createElement("div");
|
|
1551
1443
|
pinnedWrap.className = "option-insight-pinned-list";
|
|
@@ -1614,7 +1506,6 @@
|
|
|
1614
1506
|
if (question.type === "multi") updateDoneState(question.id);
|
|
1615
1507
|
if (otherCheck.checked) otherInput.focus();
|
|
1616
1508
|
});
|
|
1617
|
-
setupEdgeNavigation(otherInput);
|
|
1618
1509
|
otherLabel.appendChild(otherCheck);
|
|
1619
1510
|
otherLabel.appendChild(otherInput);
|
|
1620
1511
|
list.appendChild(otherLabel);
|
|
@@ -1913,6 +1804,37 @@
|
|
|
1913
1804
|
return event.key.length === 1;
|
|
1914
1805
|
}
|
|
1915
1806
|
|
|
1807
|
+
function isQuestionNavShortcut(event, direction) {
|
|
1808
|
+
const key = direction === "prev" ? "ArrowLeft" : "ArrowRight";
|
|
1809
|
+
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
1810
|
+
const modPressed = isMac ? event.metaKey : event.ctrlKey;
|
|
1811
|
+
const otherModPressed = isMac ? event.ctrlKey : event.metaKey;
|
|
1812
|
+
return event.key === key && modPressed && !otherModPressed && !event.altKey && !event.shiftKey;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
function isEditableTextControl(element) {
|
|
1816
|
+
if (element instanceof HTMLTextAreaElement) return true;
|
|
1817
|
+
if (!(element instanceof HTMLInputElement)) return false;
|
|
1818
|
+
return ["password", "search", "tel", "text", "url"].includes(element.type);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function handlePaste(event) {
|
|
1822
|
+
const active = document.activeElement;
|
|
1823
|
+
if (!isEditableTextControl(active)) return;
|
|
1824
|
+
|
|
1825
|
+
const text = event.clipboardData?.getData("text/plain");
|
|
1826
|
+
if (typeof text !== "string" || text.length === 0) return;
|
|
1827
|
+
|
|
1828
|
+
event.preventDefault();
|
|
1829
|
+
event.stopPropagation();
|
|
1830
|
+
const start = active.selectionStart ?? active.value.length;
|
|
1831
|
+
const end = active.selectionEnd ?? start;
|
|
1832
|
+
active.setRangeText(text, start, end, "end");
|
|
1833
|
+
active.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1834
|
+
refreshCountdown();
|
|
1835
|
+
debounceSave();
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1916
1838
|
function maybeStartOtherInput(event) {
|
|
1917
1839
|
const active = document.activeElement;
|
|
1918
1840
|
if (!(active instanceof HTMLInputElement)) return false;
|
|
@@ -2345,21 +2267,6 @@
|
|
|
2345
2267
|
});
|
|
2346
2268
|
}
|
|
2347
2269
|
|
|
2348
|
-
function setupEdgeNavigation(element) {
|
|
2349
|
-
element.addEventListener("keydown", (e) => {
|
|
2350
|
-
if (e.key === "ArrowRight" && element.selectionStart === element.value.length) {
|
|
2351
|
-
e.preventDefault();
|
|
2352
|
-
e.stopPropagation();
|
|
2353
|
-
nextQuestion();
|
|
2354
|
-
}
|
|
2355
|
-
if (e.key === "ArrowLeft" && element.selectionStart === 0) {
|
|
2356
|
-
e.preventDefault();
|
|
2357
|
-
e.stopPropagation();
|
|
2358
|
-
prevQuestion();
|
|
2359
|
-
}
|
|
2360
|
-
});
|
|
2361
|
-
}
|
|
2362
|
-
|
|
2363
2270
|
function highlightOption(card, optionIndex, isKeyboard = true) {
|
|
2364
2271
|
const options = getOptionsForCard(card);
|
|
2365
2272
|
options.forEach((opt, i) => {
|
|
@@ -2538,19 +2445,15 @@
|
|
|
2538
2445
|
|
|
2539
2446
|
if (inAskArea || inOptionNote) return;
|
|
2540
2447
|
|
|
2541
|
-
if (event
|
|
2542
|
-
if (
|
|
2543
|
-
return;
|
|
2544
|
-
}
|
|
2448
|
+
if (isQuestionNavShortcut(event, "prev")) {
|
|
2449
|
+
if (isEditableTextControl(document.activeElement)) return;
|
|
2545
2450
|
event.preventDefault();
|
|
2546
2451
|
prevQuestion();
|
|
2547
2452
|
return;
|
|
2548
2453
|
}
|
|
2549
|
-
|
|
2550
|
-
if (event
|
|
2551
|
-
if (
|
|
2552
|
-
return;
|
|
2553
|
-
}
|
|
2454
|
+
|
|
2455
|
+
if (isQuestionNavShortcut(event, "next")) {
|
|
2456
|
+
if (isEditableTextControl(document.activeElement)) return;
|
|
2554
2457
|
event.preventDefault();
|
|
2555
2458
|
nextQuestion();
|
|
2556
2459
|
return;
|
|
@@ -2685,6 +2588,7 @@
|
|
|
2685
2588
|
input.setAttribute('tabindex', '-1');
|
|
2686
2589
|
});
|
|
2687
2590
|
|
|
2591
|
+
document.addEventListener("paste", handlePaste, true);
|
|
2688
2592
|
document.addEventListener('keydown', handleQuestionKeydown);
|
|
2689
2593
|
|
|
2690
2594
|
if (nav.cards.length > 0) {
|
|
@@ -2944,7 +2848,6 @@
|
|
|
2944
2848
|
textarea.addEventListener("input", () => {
|
|
2945
2849
|
debounceSave();
|
|
2946
2850
|
});
|
|
2947
|
-
setupEdgeNavigation(textarea);
|
|
2948
2851
|
card.appendChild(textarea);
|
|
2949
2852
|
}
|
|
2950
2853
|
|
|
@@ -2995,7 +2898,6 @@
|
|
|
2995
2898
|
pathInput.value = "";
|
|
2996
2899
|
}
|
|
2997
2900
|
});
|
|
2998
|
-
setupEdgeNavigation(pathInput);
|
|
2999
2901
|
|
|
3000
2902
|
const selectedItems = document.createElement("div");
|
|
3001
2903
|
selectedItems.className = "image-selected-items";
|
|
@@ -3015,12 +2917,12 @@
|
|
|
3015
2917
|
input.click();
|
|
3016
2918
|
}
|
|
3017
2919
|
}
|
|
3018
|
-
if (e
|
|
2920
|
+
if (isQuestionNavShortcut(e, "next")) {
|
|
3019
2921
|
e.preventDefault();
|
|
3020
2922
|
e.stopPropagation();
|
|
3021
2923
|
nextQuestion();
|
|
3022
2924
|
}
|
|
3023
|
-
if (e
|
|
2925
|
+
if (isQuestionNavShortcut(e, "prev")) {
|
|
3024
2926
|
e.preventDefault();
|
|
3025
2927
|
e.stopPropagation();
|
|
3026
2928
|
prevQuestion();
|
|
@@ -3131,7 +3033,6 @@
|
|
|
3131
3033
|
attachBtn.focus();
|
|
3132
3034
|
}
|
|
3133
3035
|
});
|
|
3134
|
-
setupEdgeNavigation(attachPath);
|
|
3135
3036
|
|
|
3136
3037
|
attachInline.appendChild(attachFileInput);
|
|
3137
3038
|
attachInline.appendChild(attachDrop);
|
|
@@ -3805,10 +3706,9 @@
|
|
|
3805
3706
|
normalizeOptionKeysFromData();
|
|
3806
3707
|
|
|
3807
3708
|
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
3808
|
-
|
|
3809
|
-
if (modKey) {
|
|
3709
|
+
document.querySelectorAll(".mod-key").forEach((modKey) => {
|
|
3810
3710
|
modKey.textContent = isMac ? "⌘" : "Ctrl";
|
|
3811
|
-
}
|
|
3711
|
+
});
|
|
3812
3712
|
|
|
3813
3713
|
setText(titleEl, data.title || "Interview");
|
|
3814
3714
|
setText(descriptionEl, data.description || "");
|
|
@@ -3902,7 +3802,7 @@
|
|
|
3902
3802
|
true
|
|
3903
3803
|
);
|
|
3904
3804
|
submitBtn.addEventListener("keydown", (e) => {
|
|
3905
|
-
if (e
|
|
3805
|
+
if (isQuestionNavShortcut(e, "prev") || e.key === "ArrowUp") {
|
|
3906
3806
|
e.preventDefault();
|
|
3907
3807
|
e.stopImmediatePropagation();
|
|
3908
3808
|
focusQuestion(nav.cards.length - 1, 'prev');
|
package/form/styles.css
CHANGED
|
@@ -232,7 +232,8 @@ body {
|
|
|
232
232
|
border-radius: 8px;
|
|
233
233
|
background: var(--bg-elevated);
|
|
234
234
|
border: 1px solid var(--border-muted);
|
|
235
|
-
cursor:
|
|
235
|
+
cursor: default;
|
|
236
|
+
user-select: text;
|
|
236
237
|
font-family: var(--font-body);
|
|
237
238
|
font-size: var(--font-size-option);
|
|
238
239
|
transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
|
|
@@ -244,6 +245,14 @@ body {
|
|
|
244
245
|
min-width: 0;
|
|
245
246
|
display: grid;
|
|
246
247
|
gap: 10px;
|
|
248
|
+
cursor: text;
|
|
249
|
+
user-select: text;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.option-item input[type="radio"],
|
|
253
|
+
.option-item input[type="checkbox"] {
|
|
254
|
+
cursor: pointer;
|
|
255
|
+
user-select: none;
|
|
247
256
|
}
|
|
248
257
|
|
|
249
258
|
.option-item-label {
|
|
@@ -253,6 +262,8 @@ body {
|
|
|
253
262
|
min-height: 20px;
|
|
254
263
|
min-width: 0;
|
|
255
264
|
flex-wrap: wrap;
|
|
265
|
+
cursor: text;
|
|
266
|
+
user-select: text;
|
|
256
267
|
}
|
|
257
268
|
|
|
258
269
|
.option-item:hover {
|
|
@@ -2063,11 +2074,9 @@ button {
|
|
|
2063
2074
|
}
|
|
2064
2075
|
|
|
2065
2076
|
.option-insight-chip,
|
|
2066
|
-
.option-insight-secondary,
|
|
2067
|
-
.option-insight-primary,
|
|
2068
2077
|
.option-insight-submit,
|
|
2069
2078
|
.option-insight-advanced-toggle,
|
|
2070
|
-
.option-insight-
|
|
2079
|
+
.option-insight-remove {
|
|
2071
2080
|
border: 1px solid var(--border-muted);
|
|
2072
2081
|
border-radius: 999px;
|
|
2073
2082
|
background: transparent;
|
|
@@ -2078,15 +2087,12 @@ button {
|
|
|
2078
2087
|
}
|
|
2079
2088
|
|
|
2080
2089
|
.option-insight-chip,
|
|
2081
|
-
.option-insight-secondary,
|
|
2082
|
-
.option-insight-primary,
|
|
2083
2090
|
.option-insight-submit,
|
|
2084
|
-
.option-insight-
|
|
2091
|
+
.option-insight-remove {
|
|
2085
2092
|
padding: 6px 10px;
|
|
2086
2093
|
}
|
|
2087
2094
|
|
|
2088
2095
|
.option-insight-chip.active,
|
|
2089
|
-
.option-insight-primary,
|
|
2090
2096
|
.option-insight-submit {
|
|
2091
2097
|
border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 55%, transparent);
|
|
2092
2098
|
color: var(--card-accent, var(--accent));
|
|
@@ -2227,8 +2233,7 @@ button {
|
|
|
2227
2233
|
50% { opacity: 1; }
|
|
2228
2234
|
}
|
|
2229
2235
|
|
|
2230
|
-
.option-insight-actions
|
|
2231
|
-
.option-insight-result-actions {
|
|
2236
|
+
.option-insight-actions {
|
|
2232
2237
|
display: flex;
|
|
2233
2238
|
flex-wrap: wrap;
|
|
2234
2239
|
gap: 8px;
|
|
@@ -2314,7 +2319,7 @@ button {
|
|
|
2314
2319
|
font-family: var(--font-mono);
|
|
2315
2320
|
}
|
|
2316
2321
|
|
|
2317
|
-
.option-insight-
|
|
2322
|
+
.option-insight-remove {
|
|
2318
2323
|
padding: 4px 8px;
|
|
2319
2324
|
}
|
|
2320
2325
|
|
|
@@ -2337,8 +2342,7 @@ button {
|
|
|
2337
2342
|
}
|
|
2338
2343
|
|
|
2339
2344
|
.option-insight-meta-row,
|
|
2340
|
-
.option-insight-actions
|
|
2341
|
-
.option-insight-result-actions {
|
|
2345
|
+
.option-insight-actions {
|
|
2342
2346
|
align-items: flex-start;
|
|
2343
2347
|
flex-direction: column;
|
|
2344
2348
|
}
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -2014,107 +2014,6 @@ export async function startInterviewServer(
|
|
|
2014
2014
|
return;
|
|
2015
2015
|
}
|
|
2016
2016
|
|
|
2017
|
-
if (method === "POST" && url.pathname === "/option-action") {
|
|
2018
|
-
const body = await parseBodyOrRespond();
|
|
2019
|
-
if (!body) return;
|
|
2020
|
-
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
2021
|
-
if (completed) {
|
|
2022
|
-
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
2023
|
-
return;
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
const payload = body as {
|
|
2027
|
-
questionId?: string;
|
|
2028
|
-
optionKey?: string;
|
|
2029
|
-
action?: string;
|
|
2030
|
-
text?: string;
|
|
2031
|
-
};
|
|
2032
|
-
|
|
2033
|
-
if (typeof payload.questionId !== "string") {
|
|
2034
|
-
sendJson(res, 400, { ok: false, error: "Missing questionId" });
|
|
2035
|
-
return;
|
|
2036
|
-
}
|
|
2037
|
-
if (typeof payload.optionKey !== "string") {
|
|
2038
|
-
sendJson(res, 400, { ok: false, error: "Missing optionKey" });
|
|
2039
|
-
return;
|
|
2040
|
-
}
|
|
2041
|
-
touchHeartbeat();
|
|
2042
|
-
|
|
2043
|
-
const questionCheck = ensureQuestionId(payload.questionId, questionById);
|
|
2044
|
-
if (questionCheck.ok === false) {
|
|
2045
|
-
sendJson(res, 400, { ok: false, error: questionCheck.error });
|
|
2046
|
-
return;
|
|
2047
|
-
}
|
|
2048
|
-
const question = questionCheck.question;
|
|
2049
|
-
if ((question.type !== "single" && question.type !== "multi") || !question.options) {
|
|
2050
|
-
sendJson(res, 400, { ok: false, error: "Invalid question for option actions" });
|
|
2051
|
-
return;
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
const optionIndex = getOptionIndexByKey(question, optionKeysByQuestion, payload.optionKey);
|
|
2055
|
-
if (optionIndex === -1) {
|
|
2056
|
-
sendJson(res, 400, { ok: false, error: "Invalid option for action" });
|
|
2057
|
-
return;
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
const currentOptions = [...question.options];
|
|
2061
|
-
const currentKeys = [...(optionKeysByQuestion[question.id] ?? [])];
|
|
2062
|
-
const action = payload.action;
|
|
2063
|
-
const rawText = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
2064
|
-
|
|
2065
|
-
if (action === "move-up") {
|
|
2066
|
-
if (optionIndex === 0) {
|
|
2067
|
-
sendJson(res, 400, { ok: false, error: "Option is already at the top" });
|
|
2068
|
-
return;
|
|
2069
|
-
}
|
|
2070
|
-
[currentOptions[optionIndex - 1], currentOptions[optionIndex]] = [currentOptions[optionIndex], currentOptions[optionIndex - 1]];
|
|
2071
|
-
[currentKeys[optionIndex - 1], currentKeys[optionIndex]] = [currentKeys[optionIndex], currentKeys[optionIndex - 1]];
|
|
2072
|
-
} else if (action === "replace-text") {
|
|
2073
|
-
if (!rawText) {
|
|
2074
|
-
sendJson(res, 400, { ok: false, error: "Replacement text is required" });
|
|
2075
|
-
return;
|
|
2076
|
-
}
|
|
2077
|
-
const duplicateIndex = currentOptions.findIndex((option, index) => index !== optionIndex && getOptionLabel(option).trim().toLowerCase() === rawText.toLowerCase());
|
|
2078
|
-
if (duplicateIndex !== -1) {
|
|
2079
|
-
sendJson(res, 400, { ok: false, error: "An option with that text already exists" });
|
|
2080
|
-
return;
|
|
2081
|
-
}
|
|
2082
|
-
const previousText = getOptionLabel(currentOptions[optionIndex]);
|
|
2083
|
-
currentOptions[optionIndex] = setOptionLabel(currentOptions[optionIndex], rawText);
|
|
2084
|
-
if (question.type === "single" && question.recommended === previousText) {
|
|
2085
|
-
question.recommended = rawText;
|
|
2086
|
-
} else if (question.type === "multi" && Array.isArray(question.recommended)) {
|
|
2087
|
-
question.recommended = question.recommended.map((option) => option === previousText ? rawText : option);
|
|
2088
|
-
}
|
|
2089
|
-
} else if (action === "add-option") {
|
|
2090
|
-
if (!rawText) {
|
|
2091
|
-
sendJson(res, 400, { ok: false, error: "New option text is required" });
|
|
2092
|
-
return;
|
|
2093
|
-
}
|
|
2094
|
-
if (currentOptions.some((option) => getOptionLabel(option).trim().toLowerCase() === rawText.toLowerCase())) {
|
|
2095
|
-
sendJson(res, 400, { ok: false, error: "An option with that text already exists" });
|
|
2096
|
-
return;
|
|
2097
|
-
}
|
|
2098
|
-
const newOptionKey = makeOptionKey();
|
|
2099
|
-
currentOptions.splice(optionIndex + 1, 0, rawText);
|
|
2100
|
-
currentKeys.splice(optionIndex + 1, 0, newOptionKey);
|
|
2101
|
-
} else {
|
|
2102
|
-
sendJson(res, 400, { ok: false, error: "Unsupported option action" });
|
|
2103
|
-
return;
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
|
-
question.options = currentOptions;
|
|
2107
|
-
optionKeysByQuestion[question.id] = currentKeys;
|
|
2108
|
-
syncRecommendations(question, currentOptions);
|
|
2109
|
-
|
|
2110
|
-
sendJson(res, 200, {
|
|
2111
|
-
ok: true,
|
|
2112
|
-
question,
|
|
2113
|
-
optionKeys: currentKeys,
|
|
2114
|
-
});
|
|
2115
|
-
return;
|
|
2116
|
-
}
|
|
2117
|
-
|
|
2118
2017
|
sendText(res, 404, "Not found");
|
|
2119
2018
|
} catch (err) {
|
|
2120
2019
|
const message = err instanceof Error ? err.message : "Server error";
|