@xingjisoft/neutrino 0.1.5 → 0.1.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 +20 -4
- package/dist/app.js +358 -28
- package/dist/screens/chat.js +222 -7
- package/dist/selection.js +60 -0
- package/dist/terminal/input.js +20 -2
- package/dist/terminal/renderer.js +29 -8
- package/dist/terminal/restore.js +3 -2
- package/package.json +2 -2
- package/web/README.md +3 -1
- package/web/app.js +118 -5
- package/web/index.html +4 -1
- package/web/style.css +168 -16
- package/web/turns.js +66 -0
package/web/app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createSettingsUI } from "./settings.js";
|
|
2
|
+
import { turnAnswerIndex, turnFolds, turnPreview, turnRanges } from "./turns.js";
|
|
2
3
|
|
|
3
4
|
// Neutrino web UI — a dependency-free single page app.
|
|
4
5
|
//
|
|
@@ -105,6 +106,8 @@ function showGate(message = "") {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
const el = {
|
|
109
|
+
app: document.getElementById("app"),
|
|
110
|
+
back: document.getElementById("back-to-menu"),
|
|
108
111
|
sessions: document.getElementById("sessions"),
|
|
109
112
|
status: document.getElementById("status"),
|
|
110
113
|
refresh: document.getElementById("refresh"),
|
|
@@ -128,6 +131,8 @@ const state = {
|
|
|
128
131
|
collapsed: new Set(),
|
|
129
132
|
expanded: new Set(),
|
|
130
133
|
openTools: new Set(),
|
|
134
|
+
// Turns the reader expanded; a finished turn is folded unless listed here.
|
|
135
|
+
openTurns: new Set(),
|
|
131
136
|
openContext: null,
|
|
132
137
|
};
|
|
133
138
|
|
|
@@ -418,12 +423,23 @@ function renderSession(session) {
|
|
|
418
423
|
item.addEventListener("click", () => {
|
|
419
424
|
if (session.liveKey) selectLive(session.liveKey);
|
|
420
425
|
else if (session.id) selectFile(session.id);
|
|
426
|
+
else return;
|
|
427
|
+
setConversationView(true);
|
|
421
428
|
});
|
|
422
429
|
return item;
|
|
423
430
|
}
|
|
424
431
|
|
|
425
432
|
// ------------------------------------------------------------------- select
|
|
426
433
|
|
|
434
|
+
// Only explicit navigation changes the mobile pane. Background refreshes and
|
|
435
|
+
// live-to-saved transitions must not take the user away from the menu.
|
|
436
|
+
function setConversationView(open) {
|
|
437
|
+
el.app.classList.toggle("conversation-open", open);
|
|
438
|
+
if (!window.matchMedia("(max-width: 760px)").matches) return;
|
|
439
|
+
if (open) el.back.focus();
|
|
440
|
+
else (el.sessions.querySelector(".session.selected") || el.refresh).focus();
|
|
441
|
+
}
|
|
442
|
+
|
|
427
443
|
function resetMessagePane(text) {
|
|
428
444
|
el.messages.innerHTML = "";
|
|
429
445
|
const empty = document.createElement("div");
|
|
@@ -439,6 +455,7 @@ async function selectLive(key) {
|
|
|
439
455
|
state.snapshot = null;
|
|
440
456
|
state.lastSessionId = null;
|
|
441
457
|
state.openTools.clear();
|
|
458
|
+
state.openTurns.clear();
|
|
442
459
|
state.openContext = null;
|
|
443
460
|
renderSidebar();
|
|
444
461
|
el.controls.innerHTML = "";
|
|
@@ -495,6 +512,7 @@ async function selectFile(id) {
|
|
|
495
512
|
state.selection = { kind: "file", id };
|
|
496
513
|
state.snapshot = null;
|
|
497
514
|
state.openTools.clear();
|
|
515
|
+
state.openTurns.clear();
|
|
498
516
|
state.openContext = null;
|
|
499
517
|
renderSidebar();
|
|
500
518
|
el.controls.innerHTML = "";
|
|
@@ -521,7 +539,7 @@ function renderFile(data) {
|
|
|
521
539
|
el.title.textContent = firstUserMessage(messages) || "Conversation";
|
|
522
540
|
el.sub.textContent = [data.cwd, data.model, data.provider].filter(Boolean).join(" · ");
|
|
523
541
|
preferences.showConversation(data, `file:${data.id}`);
|
|
524
|
-
renderMessageList(messages, null, { readOnly: true, context: data.context });
|
|
542
|
+
renderMessageList(messages, null, { readOnly: true, context: data.context, active: false });
|
|
525
543
|
renderFileControls(data);
|
|
526
544
|
}
|
|
527
545
|
|
|
@@ -728,7 +746,13 @@ function renderLive() {
|
|
|
728
746
|
preferences.showConversation(snapshot, `live:${snapshot.key}`);
|
|
729
747
|
renderControls(snapshot);
|
|
730
748
|
renderApproval(snapshot);
|
|
731
|
-
renderMessageList(snapshot.messages ?? [], snapshot.streamingContent, {
|
|
749
|
+
renderMessageList(snapshot.messages ?? [], snapshot.streamingContent, {
|
|
750
|
+
readOnly: false,
|
|
751
|
+
context: snapshot.context,
|
|
752
|
+
// The turn in flight must stay readable: only a finished turn folds. A
|
|
753
|
+
// paused turn is unfinished by the user's own choice, so it stays open too.
|
|
754
|
+
active: Boolean(snapshot.busy || snapshot.paused),
|
|
755
|
+
});
|
|
732
756
|
}
|
|
733
757
|
|
|
734
758
|
async function changeApprovalMode(key, mode) {
|
|
@@ -862,9 +886,24 @@ function renderMessageList(messages, streaming, options) {
|
|
|
862
886
|
});
|
|
863
887
|
el.messages.appendChild(details);
|
|
864
888
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
889
|
+
// Render the tail one turn at a time: a finished turn folds its work behind a
|
|
890
|
+
// summary line and keeps its reply; a running one stays fully open. The turn in
|
|
891
|
+
// flight is the last one, so `active` decides its state; a turn cut in half by
|
|
892
|
+
// the compressed prefix is rendered as plain messages instead.
|
|
893
|
+
const turns = turnRanges(original);
|
|
894
|
+
const running = options?.active && turns.length > 0 ? turns[turns.length - 1].start : -1;
|
|
895
|
+
for (const turn of turns) {
|
|
896
|
+
const from = Math.max(turn.start, end);
|
|
897
|
+
if (from >= turn.end) continue;
|
|
898
|
+
const body = original.slice(turn.start, turn.end);
|
|
899
|
+
if (from === turn.start && turn.start !== running && turnFolds(body)) {
|
|
900
|
+
el.messages.appendChild(renderTurnFold(turn.start, body));
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
for (let index = from; index < turn.end; index++) {
|
|
904
|
+
el.messages.appendChild(renderMessage(original[index], index));
|
|
905
|
+
}
|
|
906
|
+
}
|
|
868
907
|
|
|
869
908
|
if (streaming !== null && streaming !== undefined && streaming !== "") {
|
|
870
909
|
const block = renderMessage({ role: "assistant", content: streaming }, -1);
|
|
@@ -918,6 +957,79 @@ function renderMessage(message, index) {
|
|
|
918
957
|
return article;
|
|
919
958
|
}
|
|
920
959
|
|
|
960
|
+
/**
|
|
961
|
+
* A finished turn: its work folded behind one control line, its reply always on
|
|
962
|
+
* screen below it. The folded messages are rendered on first open and dropped
|
|
963
|
+
* again on close, so a long transcript costs nothing until the reader asks for
|
|
964
|
+
* it. Nothing is lost either way: this is presentation only, and the saved
|
|
965
|
+
* transcript always holds every message.
|
|
966
|
+
*/
|
|
967
|
+
function renderTurnFold(start, messages) {
|
|
968
|
+
// The reply is never folded away, so a turn renders as a group: the fold (the
|
|
969
|
+
// prompt line plus the work it hides) and, right below it, the answer itself.
|
|
970
|
+
const reply = turnAnswerIndex(messages);
|
|
971
|
+
const group = document.createElement("div");
|
|
972
|
+
group.className = "turn-group";
|
|
973
|
+
|
|
974
|
+
const details = document.createElement("details");
|
|
975
|
+
details.className = "turn";
|
|
976
|
+
details.open = state.openTurns.has(start);
|
|
977
|
+
|
|
978
|
+
const hidden = reply < 0 ? messages.length : messages.length - 1;
|
|
979
|
+
const summary = document.createElement("summary");
|
|
980
|
+
const chevron = document.createElement("span");
|
|
981
|
+
chevron.className = "turn-chevron";
|
|
982
|
+
chevron.textContent = "▸";
|
|
983
|
+
const you = document.createElement("span");
|
|
984
|
+
you.className = "turn-you";
|
|
985
|
+
you.textContent = `You: ${truncate(turnPreview(messages, "user"), 110)}`;
|
|
986
|
+
const meta = document.createElement("span");
|
|
987
|
+
meta.className = "turn-meta";
|
|
988
|
+
meta.textContent = `${hidden} of ${messages.length} message${messages.length === 1 ? "" : "s"} folded`;
|
|
989
|
+
// The hint cannot live in the static text: it says the opposite once open.
|
|
990
|
+
const hint = document.createElement("span");
|
|
991
|
+
hint.className = "turn-hint";
|
|
992
|
+
meta.appendChild(hint);
|
|
993
|
+
summary.appendChild(chevron);
|
|
994
|
+
summary.appendChild(you);
|
|
995
|
+
summary.appendChild(meta);
|
|
996
|
+
details.appendChild(summary);
|
|
997
|
+
group.appendChild(details);
|
|
998
|
+
|
|
999
|
+
const syncHint = () => {
|
|
1000
|
+
hint.textContent = details.open ? " — click to collapse" : " — click to expand";
|
|
1001
|
+
};
|
|
1002
|
+
syncHint();
|
|
1003
|
+
|
|
1004
|
+
const body = document.createElement("div");
|
|
1005
|
+
body.className = "turn-messages";
|
|
1006
|
+
details.appendChild(body);
|
|
1007
|
+
const populate = () => {
|
|
1008
|
+
if (body.childNodes.length) return;
|
|
1009
|
+
messages.forEach((message, offset) => {
|
|
1010
|
+
if (offset === reply) return; // the answer is never inside the fold
|
|
1011
|
+
body.appendChild(renderMessage(message, start + offset));
|
|
1012
|
+
});
|
|
1013
|
+
};
|
|
1014
|
+
if (details.open) populate();
|
|
1015
|
+
details.addEventListener("toggle", () => {
|
|
1016
|
+
// Detached disclosures can emit a delayed toggle during a stream repaint.
|
|
1017
|
+
if (!details.isConnected) return;
|
|
1018
|
+
if (details.open) {
|
|
1019
|
+
state.openTurns.add(start);
|
|
1020
|
+
populate();
|
|
1021
|
+
} else {
|
|
1022
|
+
state.openTurns.delete(start);
|
|
1023
|
+
body.replaceChildren();
|
|
1024
|
+
}
|
|
1025
|
+
syncHint();
|
|
1026
|
+
});
|
|
1027
|
+
// The answer comes with the fold, so a finished exchange ends on its reply
|
|
1028
|
+
// even while the work that produced it stays hidden.
|
|
1029
|
+
if (reply >= 0) group.appendChild(renderMessage(messages[reply], start + reply));
|
|
1030
|
+
return group;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
921
1033
|
function firstUserMessage(messages) {
|
|
922
1034
|
for (const message of messages) {
|
|
923
1035
|
if (message.role === "user" && message.content) {
|
|
@@ -983,6 +1095,7 @@ function autosize() {
|
|
|
983
1095
|
// --------------------------------------------------------------------- init
|
|
984
1096
|
|
|
985
1097
|
el.refresh.addEventListener("click", () => loadOverview());
|
|
1098
|
+
el.back.addEventListener("click", () => setConversationView(false));
|
|
986
1099
|
el.send.addEventListener("click", sendMessage);
|
|
987
1100
|
el.input.addEventListener("input", autosize);
|
|
988
1101
|
el.input.addEventListener("keydown", (event) => {
|
package/web/index.html
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
6
6
|
<meta name="color-scheme" content="dark" />
|
|
7
7
|
<title>Neutrino</title>
|
|
8
8
|
<link rel="stylesheet" href="style.css" />
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
|
|
25
25
|
<main id="main">
|
|
26
26
|
<header id="main-header" class="main-header">
|
|
27
|
+
<button id="back-to-menu" class="icon-btn back-to-menu" type="button" title="Back to conversations" aria-label="Back to conversations" aria-controls="sidebar">
|
|
28
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 5-7 7 7 7M5 12h14" /></svg>
|
|
29
|
+
</button>
|
|
27
30
|
<div class="main-title">
|
|
28
31
|
<h1 id="session-title">Select a conversation</h1>
|
|
29
32
|
<div id="session-sub" class="sub"></div>
|
package/web/style.css
CHANGED
|
@@ -51,7 +51,8 @@ button {
|
|
|
51
51
|
#app {
|
|
52
52
|
display: flex;
|
|
53
53
|
height: 100vh;
|
|
54
|
-
|
|
54
|
+
height: 100dvh;
|
|
55
|
+
width: 100%;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/* ---------------------------------------------------------------- sidebar */
|
|
@@ -68,6 +69,7 @@ button {
|
|
|
68
69
|
|
|
69
70
|
.brand {
|
|
70
71
|
display: flex;
|
|
72
|
+
flex-shrink: 0;
|
|
71
73
|
align-items: center;
|
|
72
74
|
gap: 10px;
|
|
73
75
|
padding: 14px 14px 12px;
|
|
@@ -137,6 +139,7 @@ button {
|
|
|
137
139
|
|
|
138
140
|
.sessions {
|
|
139
141
|
flex: 1;
|
|
142
|
+
min-height: 0;
|
|
140
143
|
overflow-y: auto;
|
|
141
144
|
padding: 8px 8px 24px;
|
|
142
145
|
}
|
|
@@ -239,6 +242,7 @@ button {
|
|
|
239
242
|
|
|
240
243
|
.session-meta {
|
|
241
244
|
display: flex;
|
|
245
|
+
flex-wrap: wrap;
|
|
242
246
|
gap: 10px;
|
|
243
247
|
margin-top: 2px;
|
|
244
248
|
font-size: 10px;
|
|
@@ -285,6 +289,7 @@ button {
|
|
|
285
289
|
display: flex;
|
|
286
290
|
flex-direction: column;
|
|
287
291
|
min-width: 0;
|
|
292
|
+
min-height: 0;
|
|
288
293
|
background:
|
|
289
294
|
radial-gradient(1200px 500px at 80% -10%, rgba(92, 200, 255, 0.05), transparent 60%),
|
|
290
295
|
var(--bg);
|
|
@@ -292,6 +297,7 @@ button {
|
|
|
292
297
|
|
|
293
298
|
.main-header {
|
|
294
299
|
display: flex;
|
|
300
|
+
flex-shrink: 0;
|
|
295
301
|
align-items: flex-start;
|
|
296
302
|
gap: 16px;
|
|
297
303
|
padding: 14px 22px 12px;
|
|
@@ -305,6 +311,10 @@ button {
|
|
|
305
311
|
flex: 1;
|
|
306
312
|
}
|
|
307
313
|
|
|
314
|
+
.back-to-menu {
|
|
315
|
+
display: none;
|
|
316
|
+
}
|
|
317
|
+
|
|
308
318
|
.main-title h1 {
|
|
309
319
|
margin: 0;
|
|
310
320
|
font-size: 14px;
|
|
@@ -410,6 +420,7 @@ button {
|
|
|
410
420
|
|
|
411
421
|
.messages {
|
|
412
422
|
flex: 1;
|
|
423
|
+
min-height: 0;
|
|
413
424
|
overflow-y: auto;
|
|
414
425
|
padding: 22px clamp(16px, 6vw, 120px) 28px;
|
|
415
426
|
}
|
|
@@ -586,10 +597,84 @@ button {
|
|
|
586
597
|
margin-bottom: 18px;
|
|
587
598
|
}
|
|
588
599
|
|
|
600
|
+
/* A finished turn: its work behind one control line, its reply always below. */
|
|
601
|
+
.turn-group {
|
|
602
|
+
display: flex;
|
|
603
|
+
flex-direction: column;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
.turn {
|
|
607
|
+
border: 1px dashed var(--border-soft);
|
|
608
|
+
border-radius: var(--radius);
|
|
609
|
+
padding: 8px 12px;
|
|
610
|
+
margin-bottom: 14px;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
.turn > summary {
|
|
614
|
+
cursor: pointer;
|
|
615
|
+
list-style: none;
|
|
616
|
+
display: flex;
|
|
617
|
+
align-items: baseline;
|
|
618
|
+
gap: 8px;
|
|
619
|
+
overflow: hidden;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.turn > summary::-webkit-details-marker {
|
|
623
|
+
display: none;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.turn-chevron {
|
|
627
|
+
flex: none;
|
|
628
|
+
display: inline-block;
|
|
629
|
+
color: var(--yellow);
|
|
630
|
+
transition: transform 0.15s;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
.turn-you {
|
|
634
|
+
flex: 1 1 auto;
|
|
635
|
+
min-width: 0;
|
|
636
|
+
overflow: hidden;
|
|
637
|
+
text-overflow: ellipsis;
|
|
638
|
+
white-space: nowrap;
|
|
639
|
+
color: var(--user);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.turn-meta {
|
|
643
|
+
flex: 0 1 auto;
|
|
644
|
+
min-width: 0;
|
|
645
|
+
overflow: hidden;
|
|
646
|
+
text-overflow: ellipsis;
|
|
647
|
+
white-space: nowrap;
|
|
648
|
+
color: var(--muted-2);
|
|
649
|
+
font-size: 11px;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.turn-hint {
|
|
653
|
+
color: var(--muted);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
.turn[open] > summary {
|
|
657
|
+
margin-bottom: 14px;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
.turn[open] .turn-chevron {
|
|
661
|
+
transform: rotate(90deg);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.turn-messages {
|
|
665
|
+
display: flex;
|
|
666
|
+
flex-direction: column;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
.turn-messages > .message:last-child {
|
|
670
|
+
margin-bottom: 0;
|
|
671
|
+
}
|
|
672
|
+
|
|
589
673
|
/* --------------------------------------------------------------- composer */
|
|
590
674
|
|
|
591
675
|
.composer {
|
|
592
676
|
display: flex;
|
|
677
|
+
flex-shrink: 0;
|
|
593
678
|
gap: 10px;
|
|
594
679
|
align-items: flex-end;
|
|
595
680
|
padding: 12px clamp(16px, 6vw, 120px) 16px;
|
|
@@ -599,6 +684,7 @@ button {
|
|
|
599
684
|
|
|
600
685
|
.composer textarea {
|
|
601
686
|
flex: 1;
|
|
687
|
+
min-width: 0;
|
|
602
688
|
resize: none;
|
|
603
689
|
min-height: 42px;
|
|
604
690
|
max-height: 220px;
|
|
@@ -683,19 +769,6 @@ button {
|
|
|
683
769
|
}
|
|
684
770
|
}
|
|
685
771
|
|
|
686
|
-
@media (max-width: 760px) {
|
|
687
|
-
#sidebar {
|
|
688
|
-
width: 44vw;
|
|
689
|
-
min-width: 200px;
|
|
690
|
-
}
|
|
691
|
-
.messages,
|
|
692
|
-
.composer {
|
|
693
|
-
padding-left: 16px;
|
|
694
|
-
padding-right: 16px;
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
|
|
699
772
|
/* ---- approval banner ------------------------------------------------- */
|
|
700
773
|
/* Orange, like the TUI: an approval is a stop-the-line event. */
|
|
701
774
|
.approval {
|
|
@@ -879,6 +952,7 @@ button {
|
|
|
879
952
|
/* ----------------------------------------------------- model and settings */
|
|
880
953
|
.model-picker {
|
|
881
954
|
display: flex;
|
|
955
|
+
flex-shrink: 0;
|
|
882
956
|
flex-wrap: wrap;
|
|
883
957
|
align-items: end;
|
|
884
958
|
gap: 10px;
|
|
@@ -920,6 +994,7 @@ select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
|
920
994
|
.settings-dialog {
|
|
921
995
|
width: min(620px, calc(100vw - 24px));
|
|
922
996
|
max-height: calc(100vh - 24px);
|
|
997
|
+
max-height: calc(100dvh - 24px);
|
|
923
998
|
padding: 0;
|
|
924
999
|
border: 1px solid var(--border);
|
|
925
1000
|
border-radius: var(--radius);
|
|
@@ -935,7 +1010,84 @@ select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
|
935
1010
|
.settings-note, .settings-fields .form-feedback { grid-column: 1 / -1; margin: 0; font-size: 11px; color: var(--muted); }
|
|
936
1011
|
.settings-fields .form-feedback.error { color: var(--red); }
|
|
937
1012
|
.settings-actions { display: flex; justify-content: end; gap: 10px; padding: 14px 22px; border-top: 1px solid var(--border); }
|
|
938
|
-
|
|
939
|
-
|
|
1013
|
+
/* Phones use one full-width pane: the conversation list opens first. */
|
|
1014
|
+
@media (max-width: 760px) {
|
|
1015
|
+
#sidebar {
|
|
1016
|
+
width: 100%;
|
|
1017
|
+
min-width: 0;
|
|
1018
|
+
max-width: none;
|
|
1019
|
+
border-right: 0;
|
|
1020
|
+
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
#main { display: none; }
|
|
1024
|
+
#app.conversation-open #sidebar { display: none; }
|
|
1025
|
+
#app.conversation-open #main { display: flex; }
|
|
1026
|
+
|
|
1027
|
+
#main {
|
|
1028
|
+
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
.brand { padding: 12px; }
|
|
1032
|
+
.icon-btn { width: 44px; height: 44px; flex: none; }
|
|
1033
|
+
.session { padding: 12px 9px; min-height: 56px; }
|
|
1034
|
+
.session-title { font-size: 13px; }
|
|
1035
|
+
.group-header, .more { min-height: 44px; }
|
|
1036
|
+
|
|
1037
|
+
.main-header {
|
|
1038
|
+
display: grid;
|
|
1039
|
+
grid-template-columns: 44px minmax(0, 1fr);
|
|
1040
|
+
align-items: center;
|
|
1041
|
+
gap: 8px 10px;
|
|
1042
|
+
padding: 8px 12px;
|
|
1043
|
+
}
|
|
1044
|
+
.back-to-menu { display: grid; }
|
|
1045
|
+
.back-to-menu:hover { transform: none; }
|
|
1046
|
+
.back-to-menu:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
1047
|
+
.controls {
|
|
1048
|
+
grid-column: 1 / -1;
|
|
1049
|
+
justify-content: flex-start;
|
|
1050
|
+
max-height: 20vh;
|
|
1051
|
+
max-height: 20dvh;
|
|
1052
|
+
overflow: auto;
|
|
1053
|
+
}
|
|
1054
|
+
.controls:empty { display: none; }
|
|
1055
|
+
.controls .control { min-height: 36px; }
|
|
1056
|
+
.controls label { min-width: 0; max-width: 100%; }
|
|
1057
|
+
|
|
1058
|
+
.model-picker {
|
|
1059
|
+
gap: 8px;
|
|
1060
|
+
padding: 10px 12px;
|
|
1061
|
+
max-height: 30vh;
|
|
1062
|
+
max-height: 30dvh;
|
|
1063
|
+
overflow: auto;
|
|
1064
|
+
}
|
|
1065
|
+
.model-picker label { flex: 1 1 calc(50% - 8px); }
|
|
1066
|
+
.model-picker .control { flex: 1 1 calc(50% - 8px); min-height: 44px; }
|
|
1067
|
+
|
|
1068
|
+
.messages { padding: 16px 12px; }
|
|
1069
|
+
.composer { padding: 10px 12px; gap: 8px; }
|
|
1070
|
+
.composer textarea { max-height: min(220px, 25dvh); }
|
|
1071
|
+
.composer .primary { min-height: 44px; padding: 11px 12px; }
|
|
1072
|
+
.approval { margin: 0 12px 8px; max-height: 35dvh; padding: 10px; }
|
|
1073
|
+
.approval-actions { flex-wrap: wrap; }
|
|
1074
|
+
.approval-actions button { min-height: 44px; }
|
|
1075
|
+
|
|
1076
|
+
/* Keep inputs at 16px to avoid automatic zoom when focused on iOS. */
|
|
1077
|
+
.composer textarea, .gate-card input, .settings-fields input, select { font-size: 16px; }
|
|
1078
|
+
.settings-header, .settings-fields, .settings-actions { padding: 14px 16px; }
|
|
940
1079
|
.settings-fields { grid-template-columns: 1fr; }
|
|
1080
|
+
.settings-fields label, .settings-note { overflow-wrap: anywhere; }
|
|
1081
|
+
.settings-actions button { min-height: 44px; }
|
|
1082
|
+
.gate { padding: 16px; overflow-y: auto; }
|
|
1083
|
+
.gate-card { margin: auto; }
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/* Short landscape screens and the onscreen keyboard leave less vertical room. */
|
|
1087
|
+
@media (max-width: 760px) and (max-height: 600px) {
|
|
1088
|
+
.main-header { flex-shrink: 1; min-height: 60px; overflow-y: auto; }
|
|
1089
|
+
.model-picker { flex-shrink: 1; min-height: 44px; padding-top: 6px; padding-bottom: 6px; }
|
|
1090
|
+
.messages { min-height: 40px; padding-top: 8px; padding-bottom: 8px; }
|
|
1091
|
+
.approval { max-height: 30dvh; overflow-y: auto; }
|
|
1092
|
+
.approval-details { min-height: 64px; }
|
|
941
1093
|
}
|
package/web/turns.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Turn folding for the web UI.
|
|
2
|
+
//
|
|
3
|
+
// A conversation is a sequence of turns: each one starts at a user message and
|
|
4
|
+
// runs until the next one. Once the agent has finished a turn, the browser folds
|
|
5
|
+
// away the work it took — the prompt body, the thinking and the tool calls — and
|
|
6
|
+
// keeps the final reply on screen, so a finished exchange always ends on its
|
|
7
|
+
// answer. The complete round is still one click away.
|
|
8
|
+
//
|
|
9
|
+
// The thresholds mirror TURN_FOLD_MIN_BLOCKS / TURN_FOLD_MIN_CHARS in
|
|
10
|
+
// src/screens/chat.ts, so the terminal and the browser fold the same turns.
|
|
11
|
+
// Short exchanges are left open: hiding two lines behind a click helps nobody.
|
|
12
|
+
|
|
13
|
+
export const TURN_FOLD_MIN_BLOCKS = 4;
|
|
14
|
+
export const TURN_FOLD_MIN_CHARS = 600;
|
|
15
|
+
|
|
16
|
+
/** Split a transcript into turns: `{ start, end }` message index ranges. */
|
|
17
|
+
export function turnRanges(messages) {
|
|
18
|
+
const turns = [];
|
|
19
|
+
messages.forEach((message, index) => {
|
|
20
|
+
if (message.role === "user") turns.push({ start: index, end: messages.length });
|
|
21
|
+
});
|
|
22
|
+
for (let i = 0; i < turns.length - 1; i++) turns[i].end = turns[i + 1].start;
|
|
23
|
+
return turns;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Index of the turn's reply — its last assistant message (-1 when it has none). */
|
|
27
|
+
export function turnAnswerIndex(messages) {
|
|
28
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
29
|
+
if (messages[i].role === "assistant") return i;
|
|
30
|
+
}
|
|
31
|
+
return -1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True when folding the work behind this turn saves more room than it costs.
|
|
36
|
+
* The reply is never folded away, so it is not counted: a long answer on its own
|
|
37
|
+
* must never collapse the turn that carries it.
|
|
38
|
+
*/
|
|
39
|
+
export function turnFolds(messages) {
|
|
40
|
+
const reply = turnAnswerIndex(messages);
|
|
41
|
+
let blocks = 0;
|
|
42
|
+
let chars = 0;
|
|
43
|
+
messages.forEach((message, index) => {
|
|
44
|
+
if (index === reply) return;
|
|
45
|
+
if (message.role !== "user" && message.role !== "assistant") return;
|
|
46
|
+
blocks++;
|
|
47
|
+
chars += String(message.content ?? "").length;
|
|
48
|
+
});
|
|
49
|
+
return blocks >= TURN_FOLD_MIN_BLOCKS || chars >= TURN_FOLD_MIN_CHARS;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The first line of the first message in `role`. One line only: the header is a
|
|
54
|
+
* signpost, and the full text is one click away.
|
|
55
|
+
*/
|
|
56
|
+
export function turnPreview(messages, role) {
|
|
57
|
+
for (const message of messages) {
|
|
58
|
+
if (message.role !== role) continue;
|
|
59
|
+
const line = String(message.content ?? "")
|
|
60
|
+
.split(/\r?\n/)
|
|
61
|
+
.map((part) => part.trim())
|
|
62
|
+
.find((part) => part.length > 0);
|
|
63
|
+
if (line) return line;
|
|
64
|
+
}
|
|
65
|
+
return "";
|
|
66
|
+
}
|