@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/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, { readOnly: false, context: snapshot.context });
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
- original.forEach((message, index) => {
866
- if (index >= end) el.messages.appendChild(renderMessage(message, index));
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
- width: 100vw;
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
- @media (max-width: 600px) {
939
- .model-picker { padding: 10px 12px; }
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
+ }