@xingjisoft/neutrino 0.1.5 → 0.1.6
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 +19 -4
- package/dist/app.js +358 -28
- package/dist/screens/chat.js +179 -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 +106 -5
- package/web/index.html +4 -1
- package/web/style.css +163 -16
- package/web/turns.js +52 -0
package/web/app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createSettingsUI } from "./settings.js";
|
|
2
|
+
import { 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 turn the agent has finished folds into
|
|
890
|
+
// a summary line, a running one stays open. The turn in flight is the last one,
|
|
891
|
+
// so `active` decides its state; a turn cut in half by the compressed prefix is
|
|
892
|
+
// 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,67 @@ function renderMessage(message, index) {
|
|
|
918
957
|
return article;
|
|
919
958
|
}
|
|
920
959
|
|
|
960
|
+
/**
|
|
961
|
+
* A finished turn, folded to one summary line. The messages are rendered on
|
|
962
|
+
* first open and dropped again on close, so a long transcript costs nothing
|
|
963
|
+
* until the reader asks for it. Nothing is lost either way: this is presentation
|
|
964
|
+
* only, and the saved transcript always holds every message.
|
|
965
|
+
*/
|
|
966
|
+
function renderTurnFold(start, messages) {
|
|
967
|
+
const details = document.createElement("details");
|
|
968
|
+
details.className = "turn";
|
|
969
|
+
details.open = state.openTurns.has(start);
|
|
970
|
+
|
|
971
|
+
const count = messages.length;
|
|
972
|
+
const summary = document.createElement("summary");
|
|
973
|
+
const chevron = document.createElement("span");
|
|
974
|
+
chevron.className = "turn-chevron";
|
|
975
|
+
chevron.textContent = "▸";
|
|
976
|
+
const you = document.createElement("span");
|
|
977
|
+
you.className = "turn-you";
|
|
978
|
+
you.textContent = `You: ${truncate(turnPreview(messages, "user"), 110)}`;
|
|
979
|
+
const meta = document.createElement("span");
|
|
980
|
+
meta.className = "turn-meta";
|
|
981
|
+
meta.textContent =
|
|
982
|
+
`${count} message${count === 1 ? "" : "s"} · ` +
|
|
983
|
+
truncate(turnPreview(messages, "assistant", true), 90);
|
|
984
|
+
// The hint cannot live in the static text: it says the opposite once open.
|
|
985
|
+
const hint = document.createElement("span");
|
|
986
|
+
hint.className = "turn-hint";
|
|
987
|
+
meta.appendChild(hint);
|
|
988
|
+
summary.appendChild(chevron);
|
|
989
|
+
summary.appendChild(you);
|
|
990
|
+
summary.appendChild(meta);
|
|
991
|
+
details.appendChild(summary);
|
|
992
|
+
|
|
993
|
+
const syncHint = () => {
|
|
994
|
+
hint.textContent = details.open ? " — click to collapse" : " — click to expand";
|
|
995
|
+
};
|
|
996
|
+
syncHint();
|
|
997
|
+
|
|
998
|
+
const body = document.createElement("div");
|
|
999
|
+
body.className = "turn-messages";
|
|
1000
|
+
details.appendChild(body);
|
|
1001
|
+
const populate = () => {
|
|
1002
|
+
if (body.childNodes.length) return;
|
|
1003
|
+
messages.forEach((message, offset) => body.appendChild(renderMessage(message, start + offset)));
|
|
1004
|
+
};
|
|
1005
|
+
if (details.open) populate();
|
|
1006
|
+
details.addEventListener("toggle", () => {
|
|
1007
|
+
// Detached disclosures can emit a delayed toggle during a stream repaint.
|
|
1008
|
+
if (!details.isConnected) return;
|
|
1009
|
+
if (details.open) {
|
|
1010
|
+
state.openTurns.add(start);
|
|
1011
|
+
populate();
|
|
1012
|
+
} else {
|
|
1013
|
+
state.openTurns.delete(start);
|
|
1014
|
+
body.replaceChildren();
|
|
1015
|
+
}
|
|
1016
|
+
syncHint();
|
|
1017
|
+
});
|
|
1018
|
+
return details;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
921
1021
|
function firstUserMessage(messages) {
|
|
922
1022
|
for (const message of messages) {
|
|
923
1023
|
if (message.role === "user" && message.content) {
|
|
@@ -983,6 +1083,7 @@ function autosize() {
|
|
|
983
1083
|
// --------------------------------------------------------------------- init
|
|
984
1084
|
|
|
985
1085
|
el.refresh.addEventListener("click", () => loadOverview());
|
|
1086
|
+
el.back.addEventListener("click", () => setConversationView(false));
|
|
986
1087
|
el.send.addEventListener("click", sendMessage);
|
|
987
1088
|
el.input.addEventListener("input", autosize);
|
|
988
1089
|
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,79 @@ button {
|
|
|
586
597
|
margin-bottom: 18px;
|
|
587
598
|
}
|
|
588
599
|
|
|
600
|
+
/* A finished turn: one summary line, the whole round one click away. */
|
|
601
|
+
.turn {
|
|
602
|
+
border: 1px dashed var(--border-soft);
|
|
603
|
+
border-radius: var(--radius);
|
|
604
|
+
padding: 8px 12px;
|
|
605
|
+
margin-bottom: 18px;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.turn > summary {
|
|
609
|
+
cursor: pointer;
|
|
610
|
+
list-style: none;
|
|
611
|
+
display: flex;
|
|
612
|
+
align-items: baseline;
|
|
613
|
+
gap: 8px;
|
|
614
|
+
overflow: hidden;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
.turn > summary::-webkit-details-marker {
|
|
618
|
+
display: none;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
.turn-chevron {
|
|
622
|
+
flex: none;
|
|
623
|
+
display: inline-block;
|
|
624
|
+
color: var(--yellow);
|
|
625
|
+
transition: transform 0.15s;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.turn-you {
|
|
629
|
+
flex: 1 1 auto;
|
|
630
|
+
min-width: 0;
|
|
631
|
+
overflow: hidden;
|
|
632
|
+
text-overflow: ellipsis;
|
|
633
|
+
white-space: nowrap;
|
|
634
|
+
color: var(--user);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
.turn-meta {
|
|
638
|
+
flex: 0 1 auto;
|
|
639
|
+
min-width: 0;
|
|
640
|
+
overflow: hidden;
|
|
641
|
+
text-overflow: ellipsis;
|
|
642
|
+
white-space: nowrap;
|
|
643
|
+
color: var(--muted-2);
|
|
644
|
+
font-size: 11px;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
.turn-hint {
|
|
648
|
+
color: var(--muted);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.turn[open] > summary {
|
|
652
|
+
margin-bottom: 14px;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.turn[open] .turn-chevron {
|
|
656
|
+
transform: rotate(90deg);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.turn-messages {
|
|
660
|
+
display: flex;
|
|
661
|
+
flex-direction: column;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.turn-messages > .message:last-child {
|
|
665
|
+
margin-bottom: 0;
|
|
666
|
+
}
|
|
667
|
+
|
|
589
668
|
/* --------------------------------------------------------------- composer */
|
|
590
669
|
|
|
591
670
|
.composer {
|
|
592
671
|
display: flex;
|
|
672
|
+
flex-shrink: 0;
|
|
593
673
|
gap: 10px;
|
|
594
674
|
align-items: flex-end;
|
|
595
675
|
padding: 12px clamp(16px, 6vw, 120px) 16px;
|
|
@@ -599,6 +679,7 @@ button {
|
|
|
599
679
|
|
|
600
680
|
.composer textarea {
|
|
601
681
|
flex: 1;
|
|
682
|
+
min-width: 0;
|
|
602
683
|
resize: none;
|
|
603
684
|
min-height: 42px;
|
|
604
685
|
max-height: 220px;
|
|
@@ -683,19 +764,6 @@ button {
|
|
|
683
764
|
}
|
|
684
765
|
}
|
|
685
766
|
|
|
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
767
|
/* ---- approval banner ------------------------------------------------- */
|
|
700
768
|
/* Orange, like the TUI: an approval is a stop-the-line event. */
|
|
701
769
|
.approval {
|
|
@@ -879,6 +947,7 @@ button {
|
|
|
879
947
|
/* ----------------------------------------------------- model and settings */
|
|
880
948
|
.model-picker {
|
|
881
949
|
display: flex;
|
|
950
|
+
flex-shrink: 0;
|
|
882
951
|
flex-wrap: wrap;
|
|
883
952
|
align-items: end;
|
|
884
953
|
gap: 10px;
|
|
@@ -920,6 +989,7 @@ select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
|
920
989
|
.settings-dialog {
|
|
921
990
|
width: min(620px, calc(100vw - 24px));
|
|
922
991
|
max-height: calc(100vh - 24px);
|
|
992
|
+
max-height: calc(100dvh - 24px);
|
|
923
993
|
padding: 0;
|
|
924
994
|
border: 1px solid var(--border);
|
|
925
995
|
border-radius: var(--radius);
|
|
@@ -935,7 +1005,84 @@ select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
|
935
1005
|
.settings-note, .settings-fields .form-feedback { grid-column: 1 / -1; margin: 0; font-size: 11px; color: var(--muted); }
|
|
936
1006
|
.settings-fields .form-feedback.error { color: var(--red); }
|
|
937
1007
|
.settings-actions { display: flex; justify-content: end; gap: 10px; padding: 14px 22px; border-top: 1px solid var(--border); }
|
|
938
|
-
|
|
939
|
-
|
|
1008
|
+
/* Phones use one full-width pane: the conversation list opens first. */
|
|
1009
|
+
@media (max-width: 760px) {
|
|
1010
|
+
#sidebar {
|
|
1011
|
+
width: 100%;
|
|
1012
|
+
min-width: 0;
|
|
1013
|
+
max-width: none;
|
|
1014
|
+
border-right: 0;
|
|
1015
|
+
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
#main { display: none; }
|
|
1019
|
+
#app.conversation-open #sidebar { display: none; }
|
|
1020
|
+
#app.conversation-open #main { display: flex; }
|
|
1021
|
+
|
|
1022
|
+
#main {
|
|
1023
|
+
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
.brand { padding: 12px; }
|
|
1027
|
+
.icon-btn { width: 44px; height: 44px; flex: none; }
|
|
1028
|
+
.session { padding: 12px 9px; min-height: 56px; }
|
|
1029
|
+
.session-title { font-size: 13px; }
|
|
1030
|
+
.group-header, .more { min-height: 44px; }
|
|
1031
|
+
|
|
1032
|
+
.main-header {
|
|
1033
|
+
display: grid;
|
|
1034
|
+
grid-template-columns: 44px minmax(0, 1fr);
|
|
1035
|
+
align-items: center;
|
|
1036
|
+
gap: 8px 10px;
|
|
1037
|
+
padding: 8px 12px;
|
|
1038
|
+
}
|
|
1039
|
+
.back-to-menu { display: grid; }
|
|
1040
|
+
.back-to-menu:hover { transform: none; }
|
|
1041
|
+
.back-to-menu:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
1042
|
+
.controls {
|
|
1043
|
+
grid-column: 1 / -1;
|
|
1044
|
+
justify-content: flex-start;
|
|
1045
|
+
max-height: 20vh;
|
|
1046
|
+
max-height: 20dvh;
|
|
1047
|
+
overflow: auto;
|
|
1048
|
+
}
|
|
1049
|
+
.controls:empty { display: none; }
|
|
1050
|
+
.controls .control { min-height: 36px; }
|
|
1051
|
+
.controls label { min-width: 0; max-width: 100%; }
|
|
1052
|
+
|
|
1053
|
+
.model-picker {
|
|
1054
|
+
gap: 8px;
|
|
1055
|
+
padding: 10px 12px;
|
|
1056
|
+
max-height: 30vh;
|
|
1057
|
+
max-height: 30dvh;
|
|
1058
|
+
overflow: auto;
|
|
1059
|
+
}
|
|
1060
|
+
.model-picker label { flex: 1 1 calc(50% - 8px); }
|
|
1061
|
+
.model-picker .control { flex: 1 1 calc(50% - 8px); min-height: 44px; }
|
|
1062
|
+
|
|
1063
|
+
.messages { padding: 16px 12px; }
|
|
1064
|
+
.composer { padding: 10px 12px; gap: 8px; }
|
|
1065
|
+
.composer textarea { max-height: min(220px, 25dvh); }
|
|
1066
|
+
.composer .primary { min-height: 44px; padding: 11px 12px; }
|
|
1067
|
+
.approval { margin: 0 12px 8px; max-height: 35dvh; padding: 10px; }
|
|
1068
|
+
.approval-actions { flex-wrap: wrap; }
|
|
1069
|
+
.approval-actions button { min-height: 44px; }
|
|
1070
|
+
|
|
1071
|
+
/* Keep inputs at 16px to avoid automatic zoom when focused on iOS. */
|
|
1072
|
+
.composer textarea, .gate-card input, .settings-fields input, select { font-size: 16px; }
|
|
1073
|
+
.settings-header, .settings-fields, .settings-actions { padding: 14px 16px; }
|
|
940
1074
|
.settings-fields { grid-template-columns: 1fr; }
|
|
1075
|
+
.settings-fields label, .settings-note { overflow-wrap: anywhere; }
|
|
1076
|
+
.settings-actions button { min-height: 44px; }
|
|
1077
|
+
.gate { padding: 16px; overflow-y: auto; }
|
|
1078
|
+
.gate-card { margin: auto; }
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/* Short landscape screens and the onscreen keyboard leave less vertical room. */
|
|
1082
|
+
@media (max-width: 760px) and (max-height: 600px) {
|
|
1083
|
+
.main-header { flex-shrink: 1; min-height: 60px; overflow-y: auto; }
|
|
1084
|
+
.model-picker { flex-shrink: 1; min-height: 44px; padding-top: 6px; padding-bottom: 6px; }
|
|
1085
|
+
.messages { min-height: 40px; padding-top: 8px; padding-bottom: 8px; }
|
|
1086
|
+
.approval { max-height: 30dvh; overflow-y: auto; }
|
|
1087
|
+
.approval-details { min-height: 64px; }
|
|
941
1088
|
}
|
package/web/turns.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
// it into a single summary line so scrolling back stays manageable, while the
|
|
6
|
+
// complete transcript is still rendered on demand (click the summary).
|
|
7
|
+
//
|
|
8
|
+
// The thresholds mirror TURN_FOLD_MIN_BLOCKS / TURN_FOLD_MIN_CHARS in
|
|
9
|
+
// src/screens/chat.ts, so the terminal and the browser fold the same turns.
|
|
10
|
+
// Short exchanges are left open: hiding two lines behind a click helps nobody.
|
|
11
|
+
|
|
12
|
+
export const TURN_FOLD_MIN_BLOCKS = 4;
|
|
13
|
+
export const TURN_FOLD_MIN_CHARS = 600;
|
|
14
|
+
|
|
15
|
+
/** Split a transcript into turns: `{ start, end }` message index ranges. */
|
|
16
|
+
export function turnRanges(messages) {
|
|
17
|
+
const turns = [];
|
|
18
|
+
messages.forEach((message, index) => {
|
|
19
|
+
if (message.role === "user") turns.push({ start: index, end: messages.length });
|
|
20
|
+
});
|
|
21
|
+
for (let i = 0; i < turns.length - 1; i++) turns[i].end = turns[i + 1].start;
|
|
22
|
+
return turns;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when folding this turn saves more room than it costs. */
|
|
26
|
+
export function turnFolds(messages) {
|
|
27
|
+
let blocks = 0;
|
|
28
|
+
let chars = 0;
|
|
29
|
+
for (const message of messages) {
|
|
30
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
31
|
+
blocks++;
|
|
32
|
+
chars += String(message.content ?? "").length;
|
|
33
|
+
}
|
|
34
|
+
return blocks >= TURN_FOLD_MIN_BLOCKS || chars >= TURN_FOLD_MIN_CHARS;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The first line of the first (or, with `last`, the last) message in `role`.
|
|
39
|
+
* One line only: the preview is a signpost, the full text is a click away.
|
|
40
|
+
*/
|
|
41
|
+
export function turnPreview(messages, role, last = false) {
|
|
42
|
+
const ordered = last ? [...messages].reverse() : messages;
|
|
43
|
+
for (const message of ordered) {
|
|
44
|
+
if (message.role !== role) continue;
|
|
45
|
+
const line = String(message.content ?? "")
|
|
46
|
+
.split(/\r?\n/)
|
|
47
|
+
.map((part) => part.trim())
|
|
48
|
+
.find((part) => part.length > 0);
|
|
49
|
+
if (line) return line;
|
|
50
|
+
}
|
|
51
|
+
return "";
|
|
52
|
+
}
|