@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/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, { 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 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
- 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,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
- @media (max-width: 600px) {
939
- .model-picker { padding: 10px 12px; }
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
+ }