@typhons/sandbox-tools 0.6.3 → 0.6.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/package.json +4 -2
- package/relay-client.js +8 -8
- package/sandbox-start-all.sh +9 -0
- package/start-webchat.sh +61 -0
- package/webchat-index.html +1591 -0
- package/webchat-server.cjs +34 -0
- package/package.json~ +0 -19
|
@@ -0,0 +1,1591 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
|
|
6
|
+
<!-- "Add to Home Screen" launches full-screen with no Safari URL bar — reclaims the gap -->
|
|
7
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
8
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
9
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
10
|
+
<meta name="apple-mobile-web-app-title" content="claude">
|
|
11
|
+
<meta name="theme-color" content="#1f1e1c" media="(prefers-color-scheme: dark)">
|
|
12
|
+
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
|
13
|
+
<title>claude · web chat</title>
|
|
14
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
|
15
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.10.0/styles/github.min.css" media="(prefers-color-scheme: light)">
|
|
16
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.10.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">
|
|
17
|
+
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
|
|
18
|
+
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
|
|
19
|
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
|
|
20
|
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
|
|
21
|
+
<script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.10.0/highlight.min.js"></script>
|
|
22
|
+
<style>
|
|
23
|
+
:root {
|
|
24
|
+
--bg: #ffffff;
|
|
25
|
+
--fg: #1a1a1a;
|
|
26
|
+
--fg-soft: #6b6b6b;
|
|
27
|
+
--bubble-user: #0b57d0;
|
|
28
|
+
--bubble-user-fg: #ffffff;
|
|
29
|
+
--chip-bg: #f2f2f0;
|
|
30
|
+
--chip-border: #e3e3e0;
|
|
31
|
+
--code-bg: #f6f6f4;
|
|
32
|
+
--accent: #2563eb;
|
|
33
|
+
--add-fg: #1a7f37;
|
|
34
|
+
--del-fg: #cf222e;
|
|
35
|
+
--composer-bg: #ffffff;
|
|
36
|
+
--composer-border: #d9d9d5;
|
|
37
|
+
--shadow: 0 1px 4px rgba(0,0,0,.07), 0 4px 16px rgba(0,0,0,.05);
|
|
38
|
+
}
|
|
39
|
+
@media (prefers-color-scheme: dark) {
|
|
40
|
+
:root {
|
|
41
|
+
--bg: #1f1e1c;
|
|
42
|
+
--fg: #e8e6e1;
|
|
43
|
+
--fg-soft: #9a988f;
|
|
44
|
+
--bubble-user: #2f6fdb;
|
|
45
|
+
--bubble-user-fg: #ffffff;
|
|
46
|
+
--chip-bg: #2a2926;
|
|
47
|
+
--chip-border: #3a3934;
|
|
48
|
+
--code-bg: #262522;
|
|
49
|
+
--accent: #5c8dff;
|
|
50
|
+
--add-fg: #3fb950;
|
|
51
|
+
--del-fg: #f85149;
|
|
52
|
+
--composer-bg: #262522;
|
|
53
|
+
--composer-border: #3a3934;
|
|
54
|
+
--shadow: 0 1px 4px rgba(0,0,0,.4);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
* { box-sizing: border-box; }
|
|
58
|
+
/* App shell: the page itself never scrolls or rubber-bands — only #scroll
|
|
59
|
+
does. Body is pinned and sized to the *visible* viewport (--app-h is kept
|
|
60
|
+
in sync with visualViewport by JS), so the mobile keyboard shrinks the
|
|
61
|
+
shell instead of making header+chat+composer pan behind it. */
|
|
62
|
+
html { height: 100%; overflow: hidden; overscroll-behavior: none; }
|
|
63
|
+
/* stop mobile Safari/Chrome "font boosting" from inflating text in landscape */
|
|
64
|
+
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
|
65
|
+
body {
|
|
66
|
+
margin: 0;
|
|
67
|
+
position: fixed; top: 0; left: 0; right: 0;
|
|
68
|
+
height: var(--app-h, 100%);
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
background: var(--bg);
|
|
71
|
+
color: var(--fg);
|
|
72
|
+
font: 16px/1.65 ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ---------- header ---------- */
|
|
78
|
+
header {
|
|
79
|
+
display: flex; align-items: center; gap: 10px;
|
|
80
|
+
padding: 10px 16px;
|
|
81
|
+
border-bottom: 1px solid var(--chip-border);
|
|
82
|
+
font-size: 13px; color: var(--fg-soft);
|
|
83
|
+
flex: none;
|
|
84
|
+
}
|
|
85
|
+
header .dot { width: 8px; height: 8px; border-radius: 50%; background: #b3b3ae; flex: none; }
|
|
86
|
+
header .dot.ok { background: #34a853; }
|
|
87
|
+
header .dot.busy { background: var(--accent); animation: pulse 1.4s ease-in-out infinite; }
|
|
88
|
+
@keyframes pulse { 50% { opacity: .35; } }
|
|
89
|
+
header .sess { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
90
|
+
|
|
91
|
+
/* ---------- transcript ---------- */
|
|
92
|
+
/* overflow-anchor:none — stop Chrome from re-anchoring (and firing scroll
|
|
93
|
+
events) when late images decode above the viewport, which otherwise
|
|
94
|
+
un-sticks a chat that had reached the bottom. */
|
|
95
|
+
#scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; overflow-anchor: none; }
|
|
96
|
+
#chat {
|
|
97
|
+
max-width: 780px; margin: 0 auto;
|
|
98
|
+
padding: 20px 20px 8px;
|
|
99
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
100
|
+
}
|
|
101
|
+
/* Hidden while a connection's backlog replays, so you never see it scroll
|
|
102
|
+
through history — revealed already pinned to the bottom at backlog_done. */
|
|
103
|
+
#chat.loading { opacity: 0; }
|
|
104
|
+
|
|
105
|
+
.msg { min-width: 0; }
|
|
106
|
+
|
|
107
|
+
/* user: right-aligned bubble, plain text */
|
|
108
|
+
.msg.user { align-self: flex-end; max-width: 78%; margin: 14px 0 6px; }
|
|
109
|
+
.msg.user .bubble {
|
|
110
|
+
background: var(--bubble-user); color: var(--bubble-user-fg);
|
|
111
|
+
padding: 9px 14px; border-radius: 18px; border-bottom-right-radius: 5px;
|
|
112
|
+
white-space: pre-wrap; overflow-wrap: anywhere;
|
|
113
|
+
font-size: 15px;
|
|
114
|
+
}
|
|
115
|
+
.msg.user.pending .bubble { opacity: .55; }
|
|
116
|
+
.msg.user img { max-width: 100%; border-radius: 12px; display: block; margin-top: 6px; }
|
|
117
|
+
|
|
118
|
+
/* assistant: main content, no bubble */
|
|
119
|
+
.msg.assistant { align-self: stretch; margin: 6px 0; }
|
|
120
|
+
.md { overflow-wrap: anywhere; }
|
|
121
|
+
.md > :first-child { margin-top: 0; }
|
|
122
|
+
.md > :last-child { margin-bottom: 0; }
|
|
123
|
+
.md h1, .md h2, .md h3 { line-height: 1.3; margin: 1.2em 0 .5em; }
|
|
124
|
+
.md h1 { font-size: 1.45em; } .md h2 { font-size: 1.25em; } .md h3 { font-size: 1.1em; }
|
|
125
|
+
.md p, .md ul, .md ol { margin: .55em 0; }
|
|
126
|
+
.md ul, .md ol { padding-left: 1.5em; }
|
|
127
|
+
.md li { margin: .2em 0; }
|
|
128
|
+
.md blockquote { margin: .6em 0; padding: .1em 1em; border-left: 3px solid var(--chip-border); color: var(--fg-soft); }
|
|
129
|
+
.md a { color: var(--bubble-user); }
|
|
130
|
+
.md img { max-width: 100%; border-radius: 10px; }
|
|
131
|
+
.md table { border-collapse: collapse; display: block; overflow-x: auto; max-width: 100%; margin: .7em 0; font-size: .93em; }
|
|
132
|
+
.md th, .md td { border: 1px solid var(--chip-border); padding: 6px 12px; text-align: left; overflow-wrap: normal; word-break: normal; }
|
|
133
|
+
.md th[align="center"], .md td[align="center"] { text-align: center; }
|
|
134
|
+
.md th[align="right"], .md td[align="right"] { text-align: right; }
|
|
135
|
+
.md th { background: var(--chip-bg); }
|
|
136
|
+
.md code { background: var(--code-bg); border: 1px solid var(--chip-border); border-radius: 5px; padding: .1em .35em; font-size: .875em; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
|
|
137
|
+
.md pre { position: relative; background: var(--code-bg); border: 1px solid var(--chip-border); border-radius: 10px; padding: 12px 14px; overflow-x: auto; margin: .7em 0; }
|
|
138
|
+
.md pre code { background: none; border: none; padding: 0; font-size: 13px; line-height: 1.55; display: block; }
|
|
139
|
+
.md pre .copy {
|
|
140
|
+
position: absolute; top: 6px; right: 6px;
|
|
141
|
+
border: 1px solid var(--chip-border); background: var(--bg); color: var(--fg-soft);
|
|
142
|
+
border-radius: 6px; font-size: 11px; padding: 3px 8px; cursor: pointer; opacity: 0;
|
|
143
|
+
transition: opacity .15s;
|
|
144
|
+
}
|
|
145
|
+
.md pre:hover .copy { opacity: 1; }
|
|
146
|
+
.md .katex-display { overflow-x: auto; overflow-y: hidden; padding: 2px 0; }
|
|
147
|
+
|
|
148
|
+
/* thinking + tools: quiet collapsibles */
|
|
149
|
+
details.aux { margin: 4px 0; font-size: 13px; }
|
|
150
|
+
details.aux > summary {
|
|
151
|
+
cursor: pointer; user-select: none; list-style: none;
|
|
152
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
153
|
+
color: var(--fg-soft);
|
|
154
|
+
background: var(--chip-bg); border: 1px solid var(--chip-border);
|
|
155
|
+
border-radius: 999px; padding: 3px 12px;
|
|
156
|
+
max-width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
|
157
|
+
}
|
|
158
|
+
details.aux > summary::-webkit-details-marker { display: none; }
|
|
159
|
+
details.aux > summary::before { content: "▸"; font-size: 10px; transition: transform .12s; }
|
|
160
|
+
details.aux[open] > summary::before { transform: rotate(90deg); }
|
|
161
|
+
details.aux .aux-body {
|
|
162
|
+
margin: 6px 0 4px; padding: 10px 12px;
|
|
163
|
+
background: var(--code-bg); border: 1px solid var(--chip-border); border-radius: 10px;
|
|
164
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5;
|
|
165
|
+
white-space: pre-wrap; overflow-wrap: anywhere;
|
|
166
|
+
max-height: 340px; overflow-y: auto;
|
|
167
|
+
}
|
|
168
|
+
details.aux .aux-body.think { font-family: inherit; font-style: italic; color: var(--fg-soft); font-size: 13px; }
|
|
169
|
+
/* inline images are small thumbnails (Claude-console sized); click to expand */
|
|
170
|
+
img.thumb { display: block; height: auto; width: auto; max-height: 240px; max-width: 320px;
|
|
171
|
+
object-fit: contain; border-radius: 8px; margin: 6px 0; cursor: zoom-in; font-style: normal; }
|
|
172
|
+
img.thumb:hover { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
173
|
+
/* SendUserFile is user-facing output: don't clip caption+thumb behind a scrollbar */
|
|
174
|
+
details.aux[data-tool="SendUserFile"] .aux-body { max-height: none; }
|
|
175
|
+
details.aux summary .err { color: #d93025; }
|
|
176
|
+
details.aux summary .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
177
|
+
details.aux summary .dstat { font-size: 11px; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; flex: none; }
|
|
178
|
+
details.aux summary .dstat .plus { color: var(--add-fg); }
|
|
179
|
+
details.aux summary .dstat .minus { color: var(--del-fg); margin-left: 4px; }
|
|
180
|
+
details.aux .aux-body .t-desc, details.aux .aux-body .t-k { color: var(--fg-soft); }
|
|
181
|
+
.diff { margin-top: 6px; border: 1px solid var(--chip-border); border-radius: 8px; overflow: hidden; }
|
|
182
|
+
.diff .dl { display: block; padding: 0 8px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
183
|
+
.diff .del { background: rgba(220, 60, 50, .13); }
|
|
184
|
+
.diff .add { background: rgba(30, 150, 70, .15); }
|
|
185
|
+
.diff .ctx { color: var(--fg-soft); }
|
|
186
|
+
.t-code { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 4px; }
|
|
187
|
+
details.aux .aux-body .ln { color: var(--fg-soft); opacity: .55; user-select: none; }
|
|
188
|
+
.t-file { color: var(--accent); text-decoration: none; cursor: pointer; }
|
|
189
|
+
.t-file:hover { text-decoration: underline; }
|
|
190
|
+
details.aux .aux-body .t-opt { margin: 4px 0; padding: 6px 10px; background: var(--bg); border: 1px solid var(--chip-border); border-radius: 8px; }
|
|
191
|
+
details.aux .aux-body .t-opt-label { font-weight: 600; }
|
|
192
|
+
details.aux[data-tool="ExitPlanMode"] .aux-body { font-family: inherit; font-size: 14px; }
|
|
193
|
+
details.aux[data-tool="ExitPlanMode"] .aux-body .md, details.aux[data-tool="AskUserQuestion"] .aux-body { white-space: normal; }
|
|
194
|
+
|
|
195
|
+
/* full-file viewer modal */
|
|
196
|
+
#fileview { position: fixed; inset: 0; background: rgba(0, 0, 0, .45); z-index: 60; display: flex; align-items: center; justify-content: center;
|
|
197
|
+
padding: max(14px, env(safe-area-inset-top)) max(10px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left)); }
|
|
198
|
+
#fileview[hidden] { display: none; }
|
|
199
|
+
/* 100% of the padded overlay, not 92vh: vh includes the area behind mobile browser chrome and clipped the header */
|
|
200
|
+
#fileview .fv-card { background: var(--bg); border: 1px solid var(--chip-border); border-radius: 12px; width: min(980px, 100%); max-height: 100%; display: flex; flex-direction: column; overflow: hidden; }
|
|
201
|
+
#fileview .fv-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--chip-border); }
|
|
202
|
+
#fileview .fv-name { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 8px; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; }
|
|
203
|
+
#fileview .fv-name b { flex: none; color: var(--fg); font-weight: 600; }
|
|
204
|
+
#fileview .fv-name i { font-style: normal; color: var(--fg-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
205
|
+
#fileview .fv-close { background: none; border: 1px solid var(--chip-border); border-radius: 6px; color: var(--fg-soft); cursor: pointer; padding: 2px 9px; font-size: 13px; }
|
|
206
|
+
#fileview .fv-close:hover { background: var(--chip-bg); }
|
|
207
|
+
#fileview .fv-body { flex: 1; margin: 0; padding: 10px 0; overflow: auto; background: var(--code-bg); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; line-height: 1.55; counter-reset: fvln; }
|
|
208
|
+
#fileview .fv-body .fl { display: block; white-space: pre; padding-right: 14px; }
|
|
209
|
+
#fileview .fv-body .fl::before { counter-increment: fvln; content: counter(fvln); display: inline-block; width: 3.4em; margin-right: 14px; text-align: right; color: var(--fg-soft); opacity: .5; user-select: none; }
|
|
210
|
+
#fileview .fv-body .fv-img { display: block; max-width: 100%; margin: 0 auto; padding: 14px; box-sizing: border-box; }
|
|
211
|
+
details.aux summary .spin {
|
|
212
|
+
width: 10px; height: 10px; border: 2px solid var(--chip-border); border-top-color: var(--accent);
|
|
213
|
+
border-radius: 50%; animation: rot .8s linear infinite; flex: none;
|
|
214
|
+
}
|
|
215
|
+
@keyframes rot { to { transform: rotate(360deg); } }
|
|
216
|
+
|
|
217
|
+
/* touch devices: chip summaries, file links, dialog keypads need ≥40px targets */
|
|
218
|
+
@media (pointer: coarse) {
|
|
219
|
+
details.aux > summary { padding: 8px 14px; }
|
|
220
|
+
.t-file { display: inline-block; padding: 5px 0; }
|
|
221
|
+
#dialog .kbtn { padding: 8px 12px; }
|
|
222
|
+
#dialog .d-free-send { padding: 10px 16px; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.notice { align-self: center; font-size: 12px; color: var(--fg-soft); margin: 10px 0; text-align: center; }
|
|
226
|
+
.notice code { background: var(--chip-bg); border: 1px solid var(--chip-border); border-radius: 5px; padding: 1px 6px; font-size: 11px; }
|
|
227
|
+
.notice pre {
|
|
228
|
+
text-align: left; margin: 0 auto; max-width: 640px; max-height: 220px; overflow: auto;
|
|
229
|
+
background: var(--chip-bg); border: 1px solid var(--chip-border); border-radius: 8px; padding: 8px 10px;
|
|
230
|
+
font-size: 11px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere;
|
|
231
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#typing { align-self: stretch; margin: 10px 0 4px; display: none; }
|
|
235
|
+
#typing.on { display: flex; gap: 5px; align-items: center; }
|
|
236
|
+
#typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--fg-soft); display: block; animation: blink 1.3s infinite; }
|
|
237
|
+
#typing i:nth-child(2) { animation-delay: .18s; } #typing i:nth-child(3) { animation-delay: .36s; }
|
|
238
|
+
@keyframes blink { 0%, 70%, 100% { opacity: .25; } 35% { opacity: .9; } }
|
|
239
|
+
|
|
240
|
+
/* ---------- composer ---------- */
|
|
241
|
+
#composer-wrap { flex: none; padding: 8px 16px calc(14px + env(safe-area-inset-bottom)); }
|
|
242
|
+
/* keyboard up: the home-indicator inset is covered by the keyboard — reclaim it */
|
|
243
|
+
body.kb #composer-wrap { padding-top: 5px; padding-bottom: 5px; }
|
|
244
|
+
#composer {
|
|
245
|
+
max-width: 780px; margin: 0 auto; position: relative;
|
|
246
|
+
background: var(--composer-bg); border: 1px solid var(--composer-border);
|
|
247
|
+
border-radius: 22px; box-shadow: var(--shadow);
|
|
248
|
+
padding: 8px 8px 8px 14px;
|
|
249
|
+
display: flex; align-items: flex-end; gap: 6px;
|
|
250
|
+
}
|
|
251
|
+
#composer.drag { border-color: var(--accent); }
|
|
252
|
+
#attachments { max-width: 780px; margin: 0 auto 6px; display: none; flex-wrap: wrap; gap: 6px; }
|
|
253
|
+
#attachments.on { display: flex; }
|
|
254
|
+
#attachments .pill {
|
|
255
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
256
|
+
background: var(--chip-bg); border: 1px solid var(--chip-border); border-radius: 999px;
|
|
257
|
+
font-size: 12px; padding: 3px 6px 3px 12px; color: var(--fg-soft);
|
|
258
|
+
max-width: 260px;
|
|
259
|
+
}
|
|
260
|
+
#attachments .pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
261
|
+
#attachments .pill button { border: none; background: none; cursor: pointer; color: var(--fg-soft); font-size: 13px; padding: 0 5px; }
|
|
262
|
+
#input {
|
|
263
|
+
flex: 1; border: none; outline: none; background: none; resize: none;
|
|
264
|
+
color: var(--fg); font: inherit; font-size: 15px; line-height: 1.5;
|
|
265
|
+
max-height: 220px; padding: 5px 0;
|
|
266
|
+
}
|
|
267
|
+
/* iOS zooms the whole page when focusing an input under 16px — never go smaller on touch */
|
|
268
|
+
@media (pointer: coarse) {
|
|
269
|
+
#input, #dialog .d-free-input { font-size: 16px; }
|
|
270
|
+
}
|
|
271
|
+
.cbtn {
|
|
272
|
+
flex: none; width: 36px; height: 36px; border-radius: 50%;
|
|
273
|
+
border: none; cursor: pointer; display: grid; place-items: center;
|
|
274
|
+
background: none; color: var(--fg-soft); font-size: 18px;
|
|
275
|
+
}
|
|
276
|
+
.cbtn:hover { background: var(--chip-bg); }
|
|
277
|
+
#send { background: var(--accent); color: #fff; }
|
|
278
|
+
#send:hover { background: var(--accent); filter: brightness(1.08); }
|
|
279
|
+
#send:disabled { background: var(--chip-bg); color: var(--fg-soft); cursor: default; filter: none; }
|
|
280
|
+
#send svg, #stop svg { display: block; }
|
|
281
|
+
#stop { display: none; background: var(--fg); color: var(--bg); }
|
|
282
|
+
body.working #stop { display: grid; }
|
|
283
|
+
body.working #send { display: none; }
|
|
284
|
+
|
|
285
|
+
/* ---------- TUI dialog card ---------- */
|
|
286
|
+
#dialog {
|
|
287
|
+
max-width: 780px; margin: 0 auto 8px; display: none;
|
|
288
|
+
background: var(--composer-bg); border: 1px solid var(--accent);
|
|
289
|
+
border-radius: 14px; box-shadow: var(--shadow); padding: 12px 14px;
|
|
290
|
+
/* A tall dialog (many options + free-text + keypad) must scroll INSIDE
|
|
291
|
+
itself, never push the composer below the fold. Cap against the actual
|
|
292
|
+
shell height (--app-h tracks the keyboard-adjusted visible viewport;
|
|
293
|
+
falls back to dvh), reserving room for the composer. */
|
|
294
|
+
max-height: calc(var(--app-h, 100dvh) - 140px);
|
|
295
|
+
overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
|
|
296
|
+
}
|
|
297
|
+
#dialog.on { display: block; }
|
|
298
|
+
#dialog .d-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; }
|
|
299
|
+
#dialog .d-body { font-size: 12.5px; color: var(--fg-soft); margin-bottom: 8px; }
|
|
300
|
+
#dialog .d-opts { display: flex; flex-direction: column; gap: 6px; }
|
|
301
|
+
#dialog .d-opt {
|
|
302
|
+
text-align: left; cursor: pointer; font: inherit; font-size: 13.5px;
|
|
303
|
+
background: var(--chip-bg); border: 1px solid var(--chip-border); color: var(--fg);
|
|
304
|
+
border-radius: 9px; padding: 7px 12px;
|
|
305
|
+
display: flex; gap: 8px; align-items: baseline;
|
|
306
|
+
}
|
|
307
|
+
#dialog .d-opt:hover { border-color: var(--accent); }
|
|
308
|
+
#dialog .d-opt .n { color: var(--fg-soft); font-size: 11.5px; flex: none; }
|
|
309
|
+
#dialog .d-opt .dsc { color: var(--fg-soft); font-size: 12px; }
|
|
310
|
+
#dialog .d-opt.sel { border-color: var(--accent); }
|
|
311
|
+
#dialog .d-opt.alt { opacity: .72; font-size: 12.5px; }
|
|
312
|
+
#dialog .d-raw {
|
|
313
|
+
display: none; margin: 4px 0 8px; padding: 10px 12px;
|
|
314
|
+
background: var(--code-bg); border: 1px solid var(--chip-border); border-radius: 10px;
|
|
315
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5;
|
|
316
|
+
white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow-y: auto;
|
|
317
|
+
}
|
|
318
|
+
#dialog .d-raw.on { display: block; }
|
|
319
|
+
#dialog .d-opts.hidden { display: none; }
|
|
320
|
+
#dialog .d-slider { display: none; margin: 4px 0 8px; }
|
|
321
|
+
#dialog .d-slider.on { display: block; }
|
|
322
|
+
#dialog .d-slider .sl-title { font-weight: 600; font-size: 14px; margin-bottom: 8px; }
|
|
323
|
+
#dialog .d-slider .sl-seg {
|
|
324
|
+
display: flex; border: 1px solid var(--chip-border); border-radius: 10px; overflow: hidden;
|
|
325
|
+
}
|
|
326
|
+
#dialog .d-slider .sl-opt {
|
|
327
|
+
flex: 1; cursor: pointer; font: inherit; font-size: 13px; text-align: center;
|
|
328
|
+
background: var(--chip-bg); color: var(--fg); border: none; padding: 10px 6px;
|
|
329
|
+
border-left: 1px solid var(--chip-border); white-space: nowrap; transition: background .1s;
|
|
330
|
+
}
|
|
331
|
+
#dialog .d-slider .sl-opt:first-child { border-left: none; }
|
|
332
|
+
#dialog .d-slider .sl-opt:hover { background: var(--chip-border); }
|
|
333
|
+
#dialog .d-slider .sl-opt.cur { background: var(--accent); color: #fff; font-weight: 600; }
|
|
334
|
+
#dialog .d-slider .sl-scale { display: flex; justify-content: space-between; font-size: 11px; color: var(--fg-soft); margin-top: 5px; }
|
|
335
|
+
#dialog .d-genkeys { display: none; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
|
|
336
|
+
#dialog .d-genkeys.on { display: flex; }
|
|
337
|
+
#dialog .d-genkeys .gk {
|
|
338
|
+
cursor: pointer; font: inherit; font-size: 12.5px; color: var(--fg);
|
|
339
|
+
background: var(--chip-bg); border: 1px solid var(--chip-border); border-radius: 8px;
|
|
340
|
+
padding: 5px 11px; display: inline-flex; align-items: baseline; gap: 6px;
|
|
341
|
+
}
|
|
342
|
+
#dialog .d-genkeys .gk:hover { border-color: var(--accent); }
|
|
343
|
+
#dialog .d-genkeys .gk .kk {
|
|
344
|
+
font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: var(--fg-soft);
|
|
345
|
+
border: 1px solid var(--chip-border); border-radius: 4px; padding: 0 5px;
|
|
346
|
+
}
|
|
347
|
+
#dialog .d-free { display: none; gap: 6px; margin-top: 8px; }
|
|
348
|
+
#dialog .d-free.on { display: flex; }
|
|
349
|
+
#dialog .d-free-input {
|
|
350
|
+
flex: 1; min-width: 0; font: inherit; font-size: 13.5px;
|
|
351
|
+
background: var(--bg); color: var(--fg);
|
|
352
|
+
border: 1px solid var(--chip-border); border-radius: 9px; padding: 7px 12px; outline: none;
|
|
353
|
+
}
|
|
354
|
+
#dialog .d-free-input:focus { border-color: var(--accent); }
|
|
355
|
+
#dialog .d-free-send {
|
|
356
|
+
flex: none; cursor: pointer; font: inherit; font-size: 13px;
|
|
357
|
+
background: var(--accent); color: #fff; border: none; border-radius: 9px; padding: 7px 14px;
|
|
358
|
+
}
|
|
359
|
+
#dialog .d-free-send:hover { filter: brightness(1.08); }
|
|
360
|
+
#dialog .d-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
|
|
361
|
+
#dialog .d-hint { font-size: 11.5px; color: var(--fg-soft); margin-right: auto; }
|
|
362
|
+
#dialog .kbtn {
|
|
363
|
+
cursor: pointer; font-size: 11.5px; color: var(--fg-soft);
|
|
364
|
+
background: none; border: 1px solid var(--chip-border); border-radius: 6px; padding: 3px 9px;
|
|
365
|
+
}
|
|
366
|
+
#dialog .kbtn:hover { border-color: var(--accent); color: var(--fg); }
|
|
367
|
+
|
|
368
|
+
#jump {
|
|
369
|
+
position: fixed; right: 24px; bottom: 96px; z-index: 5;
|
|
370
|
+
width: 38px; height: 38px; border-radius: 50%;
|
|
371
|
+
border: 1px solid var(--chip-border); background: var(--composer-bg); color: var(--fg-soft);
|
|
372
|
+
box-shadow: var(--shadow); cursor: pointer; display: none; place-items: center; font-size: 17px;
|
|
373
|
+
}
|
|
374
|
+
#jump.on { display: grid; }
|
|
375
|
+
|
|
376
|
+
#offline {
|
|
377
|
+
display: none; align-self: center; position: sticky; top: 8px; z-index: 6;
|
|
378
|
+
font-size: 12px; background: #fce8e6; color: #c5221f; border: 1px solid #f6c9c5;
|
|
379
|
+
border-radius: 999px; padding: 3px 14px; margin: 4px auto;
|
|
380
|
+
}
|
|
381
|
+
#offline.on { display: block; }
|
|
382
|
+
</style>
|
|
383
|
+
</head>
|
|
384
|
+
<body>
|
|
385
|
+
<header>
|
|
386
|
+
<span class="dot" id="dot"></span>
|
|
387
|
+
<span class="sess" id="sess">connecting…</span>
|
|
388
|
+
</header>
|
|
389
|
+
|
|
390
|
+
<div id="scroll">
|
|
391
|
+
<div id="offline">reconnecting…</div>
|
|
392
|
+
<div id="chat">
|
|
393
|
+
<div id="typing"><i></i><i></i><i></i></div>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<button id="jump" title="Jump to latest">↓</button>
|
|
398
|
+
|
|
399
|
+
<div id="composer-wrap">
|
|
400
|
+
<div id="dialog">
|
|
401
|
+
<div class="d-title"></div>
|
|
402
|
+
<div class="d-body"></div>
|
|
403
|
+
<pre class="d-raw"></pre>
|
|
404
|
+
<div class="d-slider"></div>
|
|
405
|
+
<div class="d-genkeys"></div>
|
|
406
|
+
<div class="d-opts"></div>
|
|
407
|
+
<div class="d-free">
|
|
408
|
+
<input class="d-free-input" type="text" placeholder="Type your own answer…">
|
|
409
|
+
<button class="d-free-send">Submit</button>
|
|
410
|
+
</div>
|
|
411
|
+
<div class="d-foot">
|
|
412
|
+
<span class="d-hint"></span>
|
|
413
|
+
<button class="kbtn" data-key="Up">↑</button>
|
|
414
|
+
<button class="kbtn" data-key="Down">↓</button>
|
|
415
|
+
<button class="kbtn" data-key="Tab">Tab</button>
|
|
416
|
+
<button class="kbtn" data-key="Space">Space</button>
|
|
417
|
+
<button class="kbtn" data-key="Enter">Enter</button>
|
|
418
|
+
<button class="kbtn" data-key="Escape">Esc</button>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
<div id="attachments"></div>
|
|
422
|
+
<div id="composer">
|
|
423
|
+
<button class="cbtn" id="attach" title="Attach files">
|
|
424
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21.4 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.2-9.19a4 4 0 015.65 5.66l-9.2 9.19a2 2 0 01-2.82-2.83l8.49-8.48"/></svg>
|
|
425
|
+
</button>
|
|
426
|
+
<textarea id="input" rows="1" placeholder="Message claude… (Enter to send, Esc to interrupt)"></textarea>
|
|
427
|
+
<button class="cbtn" id="send" title="Send">
|
|
428
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg>
|
|
429
|
+
</button>
|
|
430
|
+
<button class="cbtn" id="stop" title="Interrupt (Esc)">
|
|
431
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
<input type="file" id="file" multiple hidden>
|
|
437
|
+
|
|
438
|
+
<div id="fileview" hidden>
|
|
439
|
+
<div class="fv-card">
|
|
440
|
+
<div class="fv-head">
|
|
441
|
+
<span class="fv-name"></span>
|
|
442
|
+
<button class="fv-close" title="Close (Esc)">✕</button>
|
|
443
|
+
</div>
|
|
444
|
+
<pre class="fv-body"></pre>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
<script>
|
|
449
|
+
(() => {
|
|
450
|
+
'use strict';
|
|
451
|
+
const $ = (id) => document.getElementById(id);
|
|
452
|
+
const scroller = $('scroll'), chat = $('chat'), typing = $('typing');
|
|
453
|
+
const input = $('input'), sendBtn = $('send'), stopBtn = $('stop');
|
|
454
|
+
const attachBtn = $('attach'), fileInput = $('file'), attachBar = $('attachments');
|
|
455
|
+
const dot = $('dot'), sess = $('sess'), jump = $('jump'), offline = $('offline');
|
|
456
|
+
// Narrow screens: the keyboard-hint placeholder clips mid-sentence.
|
|
457
|
+
if (matchMedia('(max-width: 480px)').matches) input.placeholder = 'Message claude…';
|
|
458
|
+
const DEFAULT_PLACEHOLDER = input.placeholder;
|
|
459
|
+
|
|
460
|
+
// Strip ?token= from the visible URL (it's now in the cookie).
|
|
461
|
+
if (location.search.includes('token=')) {
|
|
462
|
+
try { history.replaceState(null, '', location.pathname); } catch {}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Integrated mode: the main app serves this UI and reverse-proxies /api/* to
|
|
466
|
+
// the box. window.WEBCHAT_API_BASE prefixes every call ('' when standalone);
|
|
467
|
+
// window.WEBCHAT_API_QS appends extra params (e.g. 's=<tmux>' to address a
|
|
468
|
+
// specific tmux session on a multi-session box).
|
|
469
|
+
const api = (p) => {
|
|
470
|
+
const base = window.WEBCHAT_API_BASE || '';
|
|
471
|
+
const qs = window.WEBCHAT_API_QS || '';
|
|
472
|
+
return base + p + (qs ? (p.includes('?') ? '&' : '?') + qs : '');
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// ---------- markdown / math / highlight ----------
|
|
476
|
+
marked.setOptions({ gfm: true, breaks: false });
|
|
477
|
+
|
|
478
|
+
// Same delimiter rule as remark-math/Pandoc (open $ non-space right, close $
|
|
479
|
+
// non-space left and not followed by a digit) PLUS a content check: the span
|
|
480
|
+
// must contain a math char (\ ^ _ = {} () + - * / or Greek) or be a single
|
|
481
|
+
// variable. Keeps $x$, $a+b$, $f(x)$ as math while $foo$, $5 and $10 stay literal.
|
|
482
|
+
const looksMath = (s) => /[\\^_={}()+\-*\/]|[\u03b1-\u03c9\u0391-\u03a9]/.test(s) || /^[A-Za-z]['\u2032]?$/.test(s.trim());
|
|
483
|
+
const inlineMath = []; // tex captured this render pass, keyed by placeholder id
|
|
484
|
+
// Private-use sentinels around the id survive markdown untouched (unlike
|
|
485
|
+
// \\(\u2026\\), whose backslashes marked eats as escaped parens).
|
|
486
|
+
const MATH_L = String.fromCharCode(0xE000), MATH_R = String.fromCharCode(0xE001);
|
|
487
|
+
const MATH_RE = new RegExp(MATH_L + '(\\d+)' + MATH_R, 'g');
|
|
488
|
+
// Replace math-like $\u2026$ with a placeholder; leave currency/plain-$ and
|
|
489
|
+
// code spans/blocks alone.
|
|
490
|
+
function protectInlineMath(md) {
|
|
491
|
+
const parts = String(md).split(/(```[\s\S]*?```|`[^`\n]*`)/); // odd indices are code
|
|
492
|
+
for (let k = 0; k < parts.length; k += 2) {
|
|
493
|
+
parts[k] = parts[k].replace(/(?<![\\$])\$(?!\s)([^$\n]+?)(?<!\s)\$(?!\d)/g,
|
|
494
|
+
(m, inner) => (looksMath(inner) ? MATH_L + (inlineMath.push(inner) - 1) + MATH_R : m));
|
|
495
|
+
}
|
|
496
|
+
return parts.join('');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function renderMarkdown(el, md) {
|
|
500
|
+
inlineMath.length = 0;
|
|
501
|
+
let html = DOMPurify.sanitize(marked.parse(protectInlineMath(md)), { ADD_ATTR: ['align'] });
|
|
502
|
+
// Restore inline math as KaTeX \u2014 after marked (its \\(\u2026\\) escaping would
|
|
503
|
+
// eat the delimiters) and after sanitize (KaTeX output is ours to trust).
|
|
504
|
+
if (inlineMath.length && typeof katex !== 'undefined') {
|
|
505
|
+
html = html.replace(MATH_RE, (m, id) => {
|
|
506
|
+
try { return katex.renderToString(inlineMath[+id], { throwOnError: false, displayMode: false }); }
|
|
507
|
+
catch { return '$' + esc(inlineMath[+id]) + '$'; }
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
el.innerHTML = html;
|
|
511
|
+
el.querySelectorAll('pre code').forEach((c) => { try { hljs.highlightElement(c); } catch {} });
|
|
512
|
+
el.querySelectorAll('pre').forEach((pre) => {
|
|
513
|
+
const b = document.createElement('button');
|
|
514
|
+
b.className = 'copy'; b.textContent = 'Copy';
|
|
515
|
+
b.onclick = () => {
|
|
516
|
+
navigator.clipboard.writeText(pre.querySelector('code')?.innerText ?? pre.innerText);
|
|
517
|
+
b.textContent = 'Copied'; setTimeout(() => (b.textContent = 'Copy'), 1200);
|
|
518
|
+
};
|
|
519
|
+
pre.appendChild(b);
|
|
520
|
+
});
|
|
521
|
+
try {
|
|
522
|
+
renderMathInElement(el, {
|
|
523
|
+
delimiters: [
|
|
524
|
+
{ left: '$$', right: '$$', display: true },
|
|
525
|
+
{ left: '\\[', right: '\\]', display: true },
|
|
526
|
+
{ left: '\\(', right: '\\)', display: false }, // math-like $…$ was pre-converted to this
|
|
527
|
+
],
|
|
528
|
+
throwOnError: false,
|
|
529
|
+
});
|
|
530
|
+
} catch {}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---------- stick to bottom ----------
|
|
534
|
+
// One mechanism (modelled on use-stick-to-bottom): track whether the view is
|
|
535
|
+
// "stuck" to the bottom. A ResizeObserver re-pins to the bottom whenever the
|
|
536
|
+
// transcript grows/shrinks while stuck — one code path for backlog fill,
|
|
537
|
+
// images decoding, and streaming replies. We un-stick ONLY on a real upward
|
|
538
|
+
// user scroll (not on distance-from-bottom), so a late image growing the
|
|
539
|
+
// content can never knock us loose — that was the whole source of the earlier
|
|
540
|
+
// jank. No flags/timers/debounce. overflow-anchor:none (CSS) keeps the browser
|
|
541
|
+
// from re-anchoring on content growth. #chat is hidden during the initial
|
|
542
|
+
// backlog replay (beginBacklog/endBacklog) so the fill isn't a visible
|
|
543
|
+
// scroll-through; it's revealed already at the bottom.
|
|
544
|
+
const NEAR_BOTTOM = 40;
|
|
545
|
+
let stuck = true;
|
|
546
|
+
let lastTop = 0;
|
|
547
|
+
let reconnectAnchor = null; // preserves scroll intent across a reconnect rebuild
|
|
548
|
+
|
|
549
|
+
function pin() { scroller.scrollTop = scroller.scrollHeight; }
|
|
550
|
+
|
|
551
|
+
scroller.addEventListener('scroll', () => {
|
|
552
|
+
const top = scroller.scrollTop;
|
|
553
|
+
const dist = scroller.scrollHeight - top - scroller.clientHeight;
|
|
554
|
+
if (top < lastTop - 2 && dist > NEAR_BOTTOM) stuck = false; // dragged up, away from bottom
|
|
555
|
+
else if (dist <= NEAR_BOTTOM) stuck = true; // at/near bottom → (re)stick
|
|
556
|
+
lastTop = top;
|
|
557
|
+
jump.classList.toggle('on', !stuck);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
function autoscroll(force) {
|
|
561
|
+
if (force) { stuck = true; jump.classList.remove('on'); }
|
|
562
|
+
if (stuck) pin();
|
|
563
|
+
}
|
|
564
|
+
jump.onclick = () => autoscroll(true);
|
|
565
|
+
|
|
566
|
+
if (window.ResizeObserver) {
|
|
567
|
+
new ResizeObserver(() => { if (stuck) pin(); }).observe(chat);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Initial backlog: hide the transcript while it replays, then reveal it
|
|
571
|
+
// already pinned to the bottom (the ResizeObserver keeps it there as images
|
|
572
|
+
// settle). endBacklog also runs if the stream errors first, so we never stay
|
|
573
|
+
// blank.
|
|
574
|
+
// Before a reconnect wipes+rebuilds the transcript, remember the reader's
|
|
575
|
+
// intent. If they were scrolled up reading history, save their distance from
|
|
576
|
+
// the bottom (stable across the rebuild — the backlog replays the same content
|
|
577
|
+
// at the same heights) so endBacklog can restore it instead of yanking them
|
|
578
|
+
// down. Only meaningful when a transcript is already on screen (a reconnect,
|
|
579
|
+
// not the first load).
|
|
580
|
+
function captureReconnectAnchor() {
|
|
581
|
+
if (!chat.querySelector('.msg, .notice, details.aux')) { reconnectAnchor = null; return; }
|
|
582
|
+
reconnectAnchor = stuck ? { stuck: true }
|
|
583
|
+
: { stuck: false, fromBottom: scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight };
|
|
584
|
+
}
|
|
585
|
+
function beginBacklog() { stuck = true; chat.classList.add('loading'); }
|
|
586
|
+
function endBacklog() {
|
|
587
|
+
jump.classList.remove('on');
|
|
588
|
+
const a = reconnectAnchor; reconnectAnchor = null;
|
|
589
|
+
if (a && !a.stuck) {
|
|
590
|
+
// Restore the scrolled-up reader rather than pinning to the bottom.
|
|
591
|
+
stuck = false;
|
|
592
|
+
chat.classList.remove('loading');
|
|
593
|
+
scroller.scrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight - a.fromBottom);
|
|
594
|
+
lastTop = scroller.scrollTop;
|
|
595
|
+
jump.classList.toggle('on', !stuck);
|
|
596
|
+
} else {
|
|
597
|
+
stuck = true;
|
|
598
|
+
pin();
|
|
599
|
+
requestAnimationFrame(() => { pin(); chat.classList.remove('loading'); });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Keep the app shell sized to the VISIBLE viewport. When the mobile keyboard
|
|
604
|
+
// opens, iOS shrinks only the visual viewport and pans the page to reveal
|
|
605
|
+
// the input — header scrolls away and everything feels loose. Instead we
|
|
606
|
+
// shrink body to visualViewport.height and pin the page at 0,0, so the
|
|
607
|
+
// keyboard squeezes the shell and only #scroll scrolls. (Pinch-zoom is left
|
|
608
|
+
// alone: while zoomed, panning is exactly what the user wants.)
|
|
609
|
+
if (window.visualViewport) {
|
|
610
|
+
const vv = window.visualViewport;
|
|
611
|
+
let raf = 0;
|
|
612
|
+
const sync = () => {
|
|
613
|
+
cancelAnimationFrame(raf);
|
|
614
|
+
raf = requestAnimationFrame(() => {
|
|
615
|
+
if (Math.abs(vv.scale - 1) < 0.02) {
|
|
616
|
+
document.documentElement.style.setProperty('--app-h', Math.round(vv.height) + 'px');
|
|
617
|
+
// Keyboard up when the visual viewport is much shorter than the layout
|
|
618
|
+
// viewport. Drop the home-indicator padding then (the keyboard covers
|
|
619
|
+
// it) to reclaim the dead space above the keyboard.
|
|
620
|
+
const kb = (window.innerHeight - vv.height) > 120;
|
|
621
|
+
document.body.classList.toggle('kb', kb);
|
|
622
|
+
if (window.scrollY || vv.offsetTop) window.scrollTo(0, 0);
|
|
623
|
+
autoscroll();
|
|
624
|
+
} else {
|
|
625
|
+
document.documentElement.style.removeProperty('--app-h');
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
};
|
|
629
|
+
vv.addEventListener('resize', sync);
|
|
630
|
+
vv.addEventListener('scroll', sync);
|
|
631
|
+
window.addEventListener('resize', sync);
|
|
632
|
+
// Rotation: iOS reports stale/intermediate viewport dimensions during the
|
|
633
|
+
// transition and its visualViewport 'resize' may not fire a final correct
|
|
634
|
+
// event — leaving --app-h sized to the OLD orientation (the huge margin).
|
|
635
|
+
// Re-sync in a burst so a fresh, settled measurement always wins.
|
|
636
|
+
window.addEventListener('orientationchange', () => { for (const d of [0, 150, 350, 600]) setTimeout(sync, d); });
|
|
637
|
+
// A stray window scroll (iOS focusing an input inside the fixed shell) must
|
|
638
|
+
// snap back — otherwise the whole page ends up nudged and "barely scrollable".
|
|
639
|
+
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
|
|
640
|
+
sync();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ---------- transcript rendering ----------
|
|
644
|
+
const toolChips = new Map(); // tool_use id -> {details, body, summary, spin}
|
|
645
|
+
let lastUserText = '';
|
|
646
|
+
|
|
647
|
+
function node(html) {
|
|
648
|
+
const t = document.createElement('template');
|
|
649
|
+
t.innerHTML = html.trim();
|
|
650
|
+
return t.content.firstChild;
|
|
651
|
+
}
|
|
652
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
653
|
+
|
|
654
|
+
function addBefore(el) { chat.insertBefore(el, typing); autoscroll(); }
|
|
655
|
+
|
|
656
|
+
function clearPending() {
|
|
657
|
+
chat.querySelectorAll('.msg.user.pending').forEach((e) => e.remove());
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ---------- per-tool input renderers (fallback: pretty JSON) ----------
|
|
661
|
+
const short = (s, n = 80) => { s = String(s ?? ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
662
|
+
const baseName = (p) => String(p || '').split('/').pop();
|
|
663
|
+
|
|
664
|
+
// Map a file path to a highlight.js language (only ones the common bundle has).
|
|
665
|
+
const EXT_LANG = {
|
|
666
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
|
667
|
+
ts: 'typescript', tsx: 'typescript', py: 'python', rb: 'ruby', go: 'go',
|
|
668
|
+
rs: 'rust', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', java: 'java',
|
|
669
|
+
kt: 'kotlin', swift: 'swift', sh: 'bash', bash: 'bash', zsh: 'bash',
|
|
670
|
+
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'ini', ini: 'ini',
|
|
671
|
+
html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml', vue: 'xml', svelte: 'xml',
|
|
672
|
+
css: 'css', scss: 'scss', less: 'less', md: 'markdown', sql: 'sql',
|
|
673
|
+
php: 'php', cs: 'csharp', pl: 'perl', lua: 'lua', r: 'r',
|
|
674
|
+
};
|
|
675
|
+
// hljs comes from a CDN — degrade to plain text if it failed to load.
|
|
676
|
+
const HL = () => (typeof hljs !== 'undefined' ? hljs : null);
|
|
677
|
+
function langOf(p) {
|
|
678
|
+
const h = HL();
|
|
679
|
+
if (!h) return null;
|
|
680
|
+
const name = baseName(p).toLowerCase();
|
|
681
|
+
const lang = name === 'dockerfile' ? 'dockerfile' : name === 'makefile' ? 'makefile'
|
|
682
|
+
: EXT_LANG[name.includes('.') ? name.split('.').pop() : ''];
|
|
683
|
+
return lang && h.getLanguage(lang) ? lang : null;
|
|
684
|
+
}
|
|
685
|
+
// Language for a mid-file EXCERPT (Read result, Edit hunk). An excerpt of an
|
|
686
|
+
// .html file is usually inside <script>/<style> with no opening tag in view,
|
|
687
|
+
// so the xml grammar renders it plain — sniff the actual content instead.
|
|
688
|
+
function snippetLang(filePath, text) {
|
|
689
|
+
const lang = langOf(filePath);
|
|
690
|
+
const h = HL();
|
|
691
|
+
if (lang === 'xml' && h && text) {
|
|
692
|
+
try {
|
|
693
|
+
const r = h.highlightAuto(String(text).slice(0, 20000), ['xml', 'javascript', 'css']);
|
|
694
|
+
if (r.language) return r.language;
|
|
695
|
+
} catch {}
|
|
696
|
+
}
|
|
697
|
+
return lang;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Highlight one line/snippet; returns safe HTML (hljs escapes, else we do).
|
|
701
|
+
function hlHtml(code, lang) {
|
|
702
|
+
const h = HL();
|
|
703
|
+
try { if (h && lang && h.getLanguage(lang)) return h.highlight(code, { language: lang, ignoreIllegals: true }).value; } catch {}
|
|
704
|
+
return esc(code);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Highlight a whole multi-line text at once, then split into per-line HTML,
|
|
708
|
+
// re-opening spans that straddle newlines. Whole-text highlighting keeps
|
|
709
|
+
// block constructs correct (comments, template literals — and hljs's native
|
|
710
|
+
// sub-highlighting of <script>/<style> inside HTML).
|
|
711
|
+
function hlLines(code, lang) {
|
|
712
|
+
const html = hlHtml(String(code ?? ''), lang);
|
|
713
|
+
const out = [];
|
|
714
|
+
const open = [];
|
|
715
|
+
let cur = '';
|
|
716
|
+
const re = /(<span[^>]*>)|(<\/span>)|(\n)|([^<\n]+|<)/g;
|
|
717
|
+
let m;
|
|
718
|
+
while ((m = re.exec(html))) {
|
|
719
|
+
if (m[1]) { open.push(m[1]); cur += m[1]; }
|
|
720
|
+
else if (m[2]) { open.pop(); cur += m[2]; }
|
|
721
|
+
else if (m[3]) { out.push(cur + '</span>'.repeat(open.length)); cur = open.join(''); }
|
|
722
|
+
else cur += m[4];
|
|
723
|
+
}
|
|
724
|
+
out.push(cur + '</span>'.repeat(open.length));
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Bash commands with heredocs: hljs's bash grammar handles <<EOF but not
|
|
729
|
+
// <<'EOF'. Split the heredoc body out ourselves and render it as a string —
|
|
730
|
+
// or as real code when the line pipes into an obvious interpreter.
|
|
731
|
+
function bashCommandHtml(cmd) {
|
|
732
|
+
cmd = String(cmd ?? '');
|
|
733
|
+
const m = cmd.match(/<<-?\s*(["']?)(\w+)\1/);
|
|
734
|
+
if (m) {
|
|
735
|
+
const lines = cmd.split('\n');
|
|
736
|
+
const s = lines.findIndex((l) => l.includes(m[0]));
|
|
737
|
+
const e = lines.findIndex((l, i) => i > s && l.trim() === m[2]);
|
|
738
|
+
if (s >= 0 && e > s) {
|
|
739
|
+
const bodyLang = /\bpython\d?\b/.test(lines[s]) ? 'python' : /\bnode\b/.test(lines[s]) ? 'javascript' : null;
|
|
740
|
+
const body = lines.slice(s + 1, e).join('\n');
|
|
741
|
+
const bodyHtml = bodyLang ? hlHtml(body, bodyLang) : `<span class="hljs-string">${esc(body)}</span>`;
|
|
742
|
+
return hlHtml(lines.slice(0, s + 1).join('\n'), 'bash') + '\n' + bodyHtml + '\n' +
|
|
743
|
+
bashCommandHtml(lines.slice(e).join('\n'));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return hlHtml(cmd, 'bash');
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ---------- full-file viewer ----------
|
|
750
|
+
const fileview = $('fileview');
|
|
751
|
+
const fvName = fileview.querySelector('.fv-name');
|
|
752
|
+
const fvBody = fileview.querySelector('.fv-body');
|
|
753
|
+
fileview.querySelector('.fv-close').onclick = () => { fileview.hidden = true; };
|
|
754
|
+
fileview.addEventListener('click', (e) => { if (e.target === fileview) fileview.hidden = true; });
|
|
755
|
+
|
|
756
|
+
const IMG_EXT = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i;
|
|
757
|
+
const rawFileUrl = (p) => api('/api/file?raw=1&p=' + encodeURIComponent(p));
|
|
758
|
+
|
|
759
|
+
// Show any image source (raw file URL or data: URI) full-size in the modal.
|
|
760
|
+
function showImageModal(src, nameHtml) {
|
|
761
|
+
fvName.innerHTML = nameHtml;
|
|
762
|
+
fvBody.innerHTML = '';
|
|
763
|
+
const img = new Image();
|
|
764
|
+
img.className = 'fv-img';
|
|
765
|
+
img.src = src;
|
|
766
|
+
img.onerror = () => { fileview.hidden = true; alert('Could not load image'); };
|
|
767
|
+
fvBody.appendChild(img);
|
|
768
|
+
fileview.hidden = false;
|
|
769
|
+
fvBody.scrollTop = 0;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// A small inline thumbnail (Claude-console sized) that expands on click.
|
|
773
|
+
function thumbImage(src, nameHtml) {
|
|
774
|
+
const img = new Image();
|
|
775
|
+
img.className = 'thumb';
|
|
776
|
+
img.src = src;
|
|
777
|
+
img.onclick = () => showImageModal(src, nameHtml || '<i>image</i>');
|
|
778
|
+
return img;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function openFile(p) {
|
|
782
|
+
const nameHtml = `<b>${esc(baseName(p))}</b><i>${esc(p)}</i>`;
|
|
783
|
+
if (IMG_EXT.test(p)) { showImageModal(rawFileUrl(p), nameHtml); return; }
|
|
784
|
+
fvName.innerHTML = nameHtml;
|
|
785
|
+
try {
|
|
786
|
+
const r = await fetch(api('/api/file?p=' + encodeURIComponent(p)));
|
|
787
|
+
const j = await r.json();
|
|
788
|
+
if (!r.ok) throw new Error(j.error || r.status);
|
|
789
|
+
fvBody.innerHTML = hlLines(j.content, langOf(p))
|
|
790
|
+
.map((h) => `<span class="fl">${h}\n</span>`).join('');
|
|
791
|
+
fileview.hidden = false;
|
|
792
|
+
fvBody.scrollTop = 0;
|
|
793
|
+
} catch (e) { alert('Could not open file: ' + (e.message || e)); }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Read tool result: " 123→code" lines → dim line numbers + code
|
|
797
|
+
// highlighted per the file's language (whole-text, so blocks stay correct).
|
|
798
|
+
function readResultEl(text, filePath) {
|
|
799
|
+
const clean = String(text).replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').replace(/\s+$/, '');
|
|
800
|
+
const lines = clean.split('\n');
|
|
801
|
+
const parts = lines.map((l) => {
|
|
802
|
+
const m = l.match(/^(\s*\d+(?:→|\t))([\s\S]*)$/);
|
|
803
|
+
return m ? { num: m[1], code: m[2] } : { num: '', code: l };
|
|
804
|
+
});
|
|
805
|
+
const code = parts.map((p) => p.code).join('\n');
|
|
806
|
+
const codeHtml = hlLines(code, snippetLang(filePath, code));
|
|
807
|
+
const d = document.createElement('div');
|
|
808
|
+
d.innerHTML = parts.map((p, i) => `<span class="ln">${esc(p.num)}</span>${codeHtml[i] ?? ''}\n`).join('');
|
|
809
|
+
return d;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// A clickable file path line (opens the full file in the viewer).
|
|
813
|
+
function fileLink(body, p) {
|
|
814
|
+
if (!p) return;
|
|
815
|
+
const d = document.createElement('div');
|
|
816
|
+
d.className = 't-desc';
|
|
817
|
+
const a = document.createElement('a');
|
|
818
|
+
a.className = 't-file';
|
|
819
|
+
a.href = '#';
|
|
820
|
+
a.textContent = p;
|
|
821
|
+
a.onclick = (e) => { e.preventDefault(); openFile(p); };
|
|
822
|
+
d.appendChild(a);
|
|
823
|
+
body.appendChild(d);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Line diff with common prefix/suffix trimmed to ±2 context lines.
|
|
827
|
+
function diffRows(oldStr, newStr) {
|
|
828
|
+
const a = String(oldStr ?? '') === '' ? [] : String(oldStr).split('\n');
|
|
829
|
+
const b = String(newStr ?? '') === '' ? [] : String(newStr).split('\n');
|
|
830
|
+
let pre = 0;
|
|
831
|
+
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
|
|
832
|
+
let post = 0;
|
|
833
|
+
while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++;
|
|
834
|
+
const rows = [];
|
|
835
|
+
for (const l of a.slice(Math.max(0, pre - 2), pre)) rows.push(['ctx', l]);
|
|
836
|
+
for (const l of a.slice(pre, a.length - post)) rows.push(['del', l]);
|
|
837
|
+
for (const l of b.slice(pre, b.length - post)) rows.push(['add', l]);
|
|
838
|
+
for (const l of a.slice(a.length - post, a.length - post + 2)) rows.push(['ctx', l]);
|
|
839
|
+
return rows;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function diffEl(rows, lang, cap = 240) {
|
|
843
|
+
const d = document.createElement('div');
|
|
844
|
+
d.className = 'diff';
|
|
845
|
+
for (const [t, l] of rows.slice(0, cap)) {
|
|
846
|
+
const s = document.createElement('span');
|
|
847
|
+
s.className = 'dl ' + t;
|
|
848
|
+
s.innerHTML = esc(t === 'del' ? '- ' : t === 'add' ? '+ ' : ' ') + hlHtml(l, lang);
|
|
849
|
+
d.appendChild(s);
|
|
850
|
+
}
|
|
851
|
+
if (rows.length > cap) {
|
|
852
|
+
const s = document.createElement('span');
|
|
853
|
+
s.className = 'dl ctx';
|
|
854
|
+
s.textContent = ` … ${rows.length - cap} more lines`;
|
|
855
|
+
d.appendChild(s);
|
|
856
|
+
}
|
|
857
|
+
return d;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function kv(body, label, val, soft) {
|
|
861
|
+
if (val == null || val === '' || val === false) return;
|
|
862
|
+
const d = document.createElement('div');
|
|
863
|
+
if (soft) d.className = 't-desc';
|
|
864
|
+
if (label) {
|
|
865
|
+
const k = document.createElement('span');
|
|
866
|
+
k.className = 't-k';
|
|
867
|
+
k.textContent = label + ' ';
|
|
868
|
+
d.appendChild(k);
|
|
869
|
+
}
|
|
870
|
+
d.appendChild(document.createTextNode(String(val)));
|
|
871
|
+
body.appendChild(d);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const TODO_ICON = { completed: '☑', in_progress: '◐', pending: '☐' };
|
|
875
|
+
|
|
876
|
+
const TOOL_RENDERERS = {
|
|
877
|
+
Bash: {
|
|
878
|
+
summary: (i) => 'Bash' + (i.run_in_background ? ' (background)' : '') + ' · ' + short(i.description || i.command),
|
|
879
|
+
body: (i, body) => {
|
|
880
|
+
kv(body, '', i.description, true);
|
|
881
|
+
const c = document.createElement('div');
|
|
882
|
+
c.className = 't-code';
|
|
883
|
+
c.innerHTML = bashCommandHtml(i.command);
|
|
884
|
+
body.appendChild(c);
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
Edit: {
|
|
888
|
+
summary: (i) => 'Edit · ' + baseName(i.file_path),
|
|
889
|
+
body: (i, body) => {
|
|
890
|
+
fileLink(body, i.file_path);
|
|
891
|
+
kv(body, '', i.replace_all ? 'replace all occurrences' : '', true);
|
|
892
|
+
const rows = diffRows(i.old_string, i.new_string);
|
|
893
|
+
body.appendChild(diffEl(rows, snippetLang(i.file_path, rows.map((r) => r[1]).join('\n'))));
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
Write: {
|
|
897
|
+
summary: (i) => 'Write · ' + baseName(i.file_path),
|
|
898
|
+
body: (i, body) => {
|
|
899
|
+
fileLink(body, i.file_path);
|
|
900
|
+
const content = String(i.content ?? '');
|
|
901
|
+
body.appendChild(diffEl(content.split('\n').map((l) => ['add', l]), snippetLang(i.file_path, content)));
|
|
902
|
+
},
|
|
903
|
+
},
|
|
904
|
+
Read: {
|
|
905
|
+
summary: (i) => 'Read · ' + baseName(i.file_path),
|
|
906
|
+
body: (i, body) => { fileLink(body, i.file_path); },
|
|
907
|
+
},
|
|
908
|
+
Grep: {
|
|
909
|
+
summary: (i) => 'Grep · ' + short(i.pattern, 60),
|
|
910
|
+
body: (i, body) => {
|
|
911
|
+
kv(body, 'pattern', i.pattern);
|
|
912
|
+
kv(body, 'path', i.path);
|
|
913
|
+
kv(body, 'glob', i.glob);
|
|
914
|
+
kv(body, 'type', i.type);
|
|
915
|
+
kv(body, 'mode', i.output_mode);
|
|
916
|
+
},
|
|
917
|
+
},
|
|
918
|
+
Glob: {
|
|
919
|
+
summary: (i) => 'Glob · ' + short(i.pattern, 60),
|
|
920
|
+
body: (i, body) => { kv(body, 'pattern', i.pattern); kv(body, 'path', i.path); },
|
|
921
|
+
},
|
|
922
|
+
WebFetch: {
|
|
923
|
+
summary: (i) => 'Fetch · ' + short(i.url, 70),
|
|
924
|
+
body: (i, body) => { kv(body, 'url', i.url); kv(body, '', i.prompt, true); },
|
|
925
|
+
},
|
|
926
|
+
WebSearch: {
|
|
927
|
+
summary: (i) => 'Search · ' + short(i.query, 70),
|
|
928
|
+
body: (i, body) => { kv(body, '', i.query); },
|
|
929
|
+
},
|
|
930
|
+
TodoWrite: {
|
|
931
|
+
summary: (i) => `Todos (${(i.todos || []).length})`,
|
|
932
|
+
body: (i, body) => { for (const t of i.todos || []) kv(body, TODO_ICON[t.status] || '☐', t.content); },
|
|
933
|
+
},
|
|
934
|
+
Task: {
|
|
935
|
+
summary: (i) => 'Agent · ' + short(i.description, 70),
|
|
936
|
+
body: (i, body) => {
|
|
937
|
+
kv(body, 'type', i.subagent_type);
|
|
938
|
+
kv(body, '', i.prompt);
|
|
939
|
+
},
|
|
940
|
+
},
|
|
941
|
+
SendUserFile: {
|
|
942
|
+
open: true, // it's output aimed at the user — show it, don't hide it behind a chip
|
|
943
|
+
summary: (i) => 'Send file · ' + short((i.files || []).map(baseName).join(', '), 70),
|
|
944
|
+
body: (i, body) => {
|
|
945
|
+
kv(body, '', i.caption, true);
|
|
946
|
+
for (const f of i.files || []) {
|
|
947
|
+
fileLink(body, f);
|
|
948
|
+
if (IMG_EXT.test(f)) body.appendChild(thumbImage(rawFileUrl(f), `<b>${esc(baseName(f))}</b><i>${esc(f)}</i>`));
|
|
949
|
+
}
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
ExitPlanMode: {
|
|
953
|
+
open: true, // the plan is presented to the user to approve
|
|
954
|
+
summary: () => 'Plan proposed',
|
|
955
|
+
body: (i, body) => {
|
|
956
|
+
const md = document.createElement('div');
|
|
957
|
+
md.className = 'md';
|
|
958
|
+
renderMarkdown(md, i.plan || '');
|
|
959
|
+
body.appendChild(md);
|
|
960
|
+
},
|
|
961
|
+
},
|
|
962
|
+
AskUserQuestion: {
|
|
963
|
+
open: true, // a question posed to the user
|
|
964
|
+
summary: (i) => 'Question · ' + short(((i.questions || [])[0] || {}).header || 'asked', 70),
|
|
965
|
+
body: (i, body) => {
|
|
966
|
+
for (const q of i.questions || []) {
|
|
967
|
+
kv(body, '', q.question || q.header);
|
|
968
|
+
for (const o of q.options || []) {
|
|
969
|
+
const d = document.createElement('div');
|
|
970
|
+
d.className = 't-opt';
|
|
971
|
+
d.innerHTML = `<span class="t-opt-label">${esc(o.label)}</span>` +
|
|
972
|
+
(o.description ? `<span class="t-desc"> — ${esc(o.description)}</span>` : '');
|
|
973
|
+
body.appendChild(d);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
TOOL_RENDERERS.Agent = TOOL_RENDERERS.Task;
|
|
980
|
+
TOOL_RENDERERS.MultiEdit = TOOL_RENDERERS.Edit;
|
|
981
|
+
TOOL_RENDERERS.NotebookEdit = {
|
|
982
|
+
summary: (i) => 'NotebookEdit · ' + baseName(i.notebook_path),
|
|
983
|
+
body: (i, body) => {
|
|
984
|
+
fileLink(body, i.notebook_path);
|
|
985
|
+
const mode = i.edit_mode || 'replace';
|
|
986
|
+
kv(body, 'cell', (i.cell_id || '(new)') + ' · ' + mode, true);
|
|
987
|
+
// The input only carries the NEW source — the old cell content isn't in
|
|
988
|
+
// the transcript, so show the new state as code, not a fake all-add diff.
|
|
989
|
+
if (mode !== 'delete' && i.new_source != null) {
|
|
990
|
+
const c = document.createElement('div');
|
|
991
|
+
c.className = 't-code';
|
|
992
|
+
c.innerHTML = hlHtml(String(i.new_source), i.cell_type === 'markdown' ? 'markdown' : 'python');
|
|
993
|
+
body.appendChild(c);
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
function toolSummaryText(name, inp) {
|
|
999
|
+
const r = inp && TOOL_RENDERERS[name];
|
|
1000
|
+
if (r) { try { return short(r.summary(inp), 100); } catch {} }
|
|
1001
|
+
const arg = inp && (inp.file_path || inp.path || inp.command || inp.pattern || inp.url || inp.query ||
|
|
1002
|
+
inp.description || (typeof inp.prompt === 'string' && inp.prompt.slice(0, 60)) || '');
|
|
1003
|
+
return arg ? `${name} · ${String(arg).slice(0, 90)}` : name;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Lines added/removed for the collapsed-chip badge (+10 −5).
|
|
1007
|
+
function diffStat(name, inp) {
|
|
1008
|
+
if (!inp) return null;
|
|
1009
|
+
try {
|
|
1010
|
+
if (/^(Edit|MultiEdit)$/.test(name) && inp.old_string != null) {
|
|
1011
|
+
const rows = diffRows(inp.old_string, inp.new_string);
|
|
1012
|
+
return { add: rows.filter((r) => r[0] === 'add').length, del: rows.filter((r) => r[0] === 'del').length };
|
|
1013
|
+
}
|
|
1014
|
+
if (name === 'Write') return { add: String(inp.content ?? '').split('\n').length, del: 0 };
|
|
1015
|
+
} catch {}
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function renderToolInput(name, inp, body) {
|
|
1020
|
+
const r = inp && TOOL_RENDERERS[name];
|
|
1021
|
+
if (r) {
|
|
1022
|
+
try { r.body(inp, body); } catch { body.textContent = ''; }
|
|
1023
|
+
if (body.childNodes.length) return;
|
|
1024
|
+
}
|
|
1025
|
+
body.textContent = inp && Object.keys(inp).length ? JSON.stringify(inp, null, 2) : '(no input)';
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function renderItem(item) {
|
|
1029
|
+
switch (item.kind) {
|
|
1030
|
+
case 'user': {
|
|
1031
|
+
clearPending();
|
|
1032
|
+
lastUserText = item.text || lastUserText;
|
|
1033
|
+
const el = node(`<div class="msg user"><div class="bubble"></div></div>`);
|
|
1034
|
+
el.querySelector('.bubble').textContent = item.text || '';
|
|
1035
|
+
for (const src of item.images || []) el.appendChild(thumbImage(src));
|
|
1036
|
+
addBefore(el);
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
1039
|
+
case 'assistant': {
|
|
1040
|
+
const el = node(`<div class="msg assistant"><div class="md"></div></div>`);
|
|
1041
|
+
renderMarkdown(el.querySelector('.md'), item.md || '');
|
|
1042
|
+
addBefore(el);
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
1045
|
+
case 'thinking': {
|
|
1046
|
+
const el = node(`<details class="aux"><summary>Thinking</summary><div class="aux-body think"></div></details>`);
|
|
1047
|
+
el.querySelector('.aux-body').textContent = item.text || '';
|
|
1048
|
+
addBefore(el);
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
case 'tool_use': {
|
|
1052
|
+
const el = node(`<details class="aux"><summary><span class="spin"></span><span class="label"></span></summary><div class="aux-body"></div></details>`);
|
|
1053
|
+
el.querySelector('.label').textContent = toolSummaryText(item.name, item.input);
|
|
1054
|
+
const stat = diffStat(item.name, item.input);
|
|
1055
|
+
if (stat && (stat.add || stat.del)) {
|
|
1056
|
+
const s = document.createElement('span');
|
|
1057
|
+
s.className = 'dstat';
|
|
1058
|
+
if (stat.add) s.innerHTML += `<span class="plus">+${stat.add}</span>`;
|
|
1059
|
+
if (stat.del) s.innerHTML += `<span class="minus">−${stat.del}</span>`;
|
|
1060
|
+
el.querySelector('summary').appendChild(s);
|
|
1061
|
+
}
|
|
1062
|
+
const body = el.querySelector('.aux-body');
|
|
1063
|
+
renderToolInput(item.name, item.input, body);
|
|
1064
|
+
el.dataset.tool = item.name || '';
|
|
1065
|
+
el.dataset.file = (item.input && item.input.file_path) || '';
|
|
1066
|
+
if (TOOL_RENDERERS[item.name]?.open) el.open = true;
|
|
1067
|
+
if (item.id) toolChips.set(item.id, el);
|
|
1068
|
+
addBefore(el);
|
|
1069
|
+
break;
|
|
1070
|
+
}
|
|
1071
|
+
case 'tool_result': {
|
|
1072
|
+
const chip = item.id && toolChips.get(item.id);
|
|
1073
|
+
if (chip) {
|
|
1074
|
+
chip.querySelector('.spin')?.remove();
|
|
1075
|
+
if (item.isError) {
|
|
1076
|
+
const l = chip.querySelector('.label'); l.classList.add('err'); l.textContent += ' · error';
|
|
1077
|
+
} else if (item.declined) {
|
|
1078
|
+
chip.querySelector('.label').textContent += ' · declined';
|
|
1079
|
+
}
|
|
1080
|
+
const body = chip.querySelector('.aux-body');
|
|
1081
|
+
// Edit/Write success confirmations ("The file … has been updated…")
|
|
1082
|
+
// are noise — the diff already says it. Agent/Task launch results are
|
|
1083
|
+
// internal metadata (agent id, output file); the completion arrives
|
|
1084
|
+
// later as a task-notification notice. Keep errors and declines.
|
|
1085
|
+
const quiet = /^(Edit|Write|MultiEdit|NotebookEdit|Agent|Task)$/.test(chip.dataset.tool || '') &&
|
|
1086
|
+
!item.isError && !item.declined;
|
|
1087
|
+
if (item.text && !quiet) {
|
|
1088
|
+
const hr = document.createElement('div');
|
|
1089
|
+
hr.style.cssText = 'border-top:1px dashed var(--chip-border);margin:8px 0;';
|
|
1090
|
+
body.appendChild(hr);
|
|
1091
|
+
if (chip.dataset.tool === 'Read' && !item.isError) body.appendChild(readResultEl(item.text, chip.dataset.file));
|
|
1092
|
+
else body.appendChild(document.createTextNode(item.text));
|
|
1093
|
+
}
|
|
1094
|
+
for (const src of item.images || []) body.appendChild(thumbImage(src));
|
|
1095
|
+
} else if (item.text || (item.images || []).length) {
|
|
1096
|
+
const label = item.isError ? '<span class="err">tool error</span>' : (item.declined ? 'declined' : 'tool result');
|
|
1097
|
+
const el = node(`<details class="aux"><summary><span class="label">${label}</span></summary><div class="aux-body"></div></details>`);
|
|
1098
|
+
const body = el.querySelector('.aux-body');
|
|
1099
|
+
body.textContent = item.text || '';
|
|
1100
|
+
for (const src of item.images || []) body.appendChild(thumbImage(src));
|
|
1101
|
+
addBefore(el);
|
|
1102
|
+
}
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
case 'notice': {
|
|
1106
|
+
const el = node(`<div class="notice"></div>`);
|
|
1107
|
+
if (item.notice === 'interrupted') el.textContent = '⏹ interrupted';
|
|
1108
|
+
else if (item.pre) {
|
|
1109
|
+
const p = document.createElement('pre');
|
|
1110
|
+
p.textContent = item.text || '';
|
|
1111
|
+
el.appendChild(p);
|
|
1112
|
+
} else el.innerHTML = `<code>${esc(item.text || '')}</code>`;
|
|
1113
|
+
addBefore(el);
|
|
1114
|
+
break;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ---------- SSE ----------
|
|
1120
|
+
let es = null;
|
|
1121
|
+
let working = false;
|
|
1122
|
+
let dialogOpen = false; // a structured (numbered-option) dialog is open
|
|
1123
|
+
let waiting = false; // session blocked on any interactive view
|
|
1124
|
+
let genericWaiting = false; // waiting on an unrecognized view (show raw + forward keys)
|
|
1125
|
+
let freeTextOpt = null; // the "Type something" option of the open dialog, if any
|
|
1126
|
+
let chatAboutOpt = null; // the "Chat about this" option, if any
|
|
1127
|
+
|
|
1128
|
+
const dlg = $('dialog');
|
|
1129
|
+
const freeInput = dlg.querySelector('.d-free-input');
|
|
1130
|
+
const freeBox = dlg.querySelector('.d-free');
|
|
1131
|
+
async function sendKeysApi(keys) {
|
|
1132
|
+
try { await fetch(api('/api/keys'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys }) }); }
|
|
1133
|
+
catch {}
|
|
1134
|
+
}
|
|
1135
|
+
dlg.querySelectorAll('.kbtn').forEach((b) => { b.onclick = () => sendKeysApi([b.dataset.key]); });
|
|
1136
|
+
|
|
1137
|
+
const isFreeText = (label) => /type something/i.test(label);
|
|
1138
|
+
const isChatOut = (label) => /chat about/i.test(label);
|
|
1139
|
+
|
|
1140
|
+
// Submit the dedicated free-text ("Type something") answer. The server decides
|
|
1141
|
+
// whether to press the option number based on a fresh pane read.
|
|
1142
|
+
async function submitFreeText() {
|
|
1143
|
+
const t = freeInput.value.trim();
|
|
1144
|
+
if (!t) return;
|
|
1145
|
+
freeInput.value = '';
|
|
1146
|
+
try {
|
|
1147
|
+
const r = await fetch(api('/api/answer'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: t }) });
|
|
1148
|
+
if (!r.ok) { const jr = await r.json().catch(() => ({})); freeInput.value = t; alert('Answer failed: ' + (jr.error || r.status)); }
|
|
1149
|
+
} catch (e) { freeInput.value = t; alert('Answer failed: ' + e.message); }
|
|
1150
|
+
}
|
|
1151
|
+
dlg.querySelector('.d-free-send').onclick = submitFreeText;
|
|
1152
|
+
freeInput.addEventListener('keydown', (e) => {
|
|
1153
|
+
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); submitFreeText(); }
|
|
1154
|
+
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); sendKeysApi(['Escape']); }
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
// A TUI dialog is open (model picker, permission prompt, AskUserQuestion…).
|
|
1158
|
+
// Options come from the pane parse; the option number is the key that selects
|
|
1159
|
+
// it (pressing it also confirms). "Type something" is special: it opens an
|
|
1160
|
+
// inline text field in the TUI, so we drive that from the composer instead of
|
|
1161
|
+
// treating it as a one-click choice.
|
|
1162
|
+
const rawBox = dlg.querySelector('.d-raw');
|
|
1163
|
+
const genKeys = dlg.querySelector('.d-genkeys');
|
|
1164
|
+
const sliderBox = dlg.querySelector('.d-slider');
|
|
1165
|
+
|
|
1166
|
+
// Render a horizontal picker (e.g. /effort) as a tappable segmented control.
|
|
1167
|
+
// Selecting index i is deterministic without detecting the current position:
|
|
1168
|
+
// step to the left end (Left×N clamps at 0), then Right×i, then confirm.
|
|
1169
|
+
function renderSlider(sl) {
|
|
1170
|
+
sliderBox.classList.toggle('on', !!sl);
|
|
1171
|
+
if (!sl) return;
|
|
1172
|
+
sliderBox.innerHTML = `<div class="sl-title"></div><div class="sl-seg"></div>`;
|
|
1173
|
+
sliderBox.querySelector('.sl-title').textContent = sl.title || 'Choose';
|
|
1174
|
+
const seg = sliderBox.querySelector('.sl-seg');
|
|
1175
|
+
sl.options.forEach((label, i) => {
|
|
1176
|
+
const b = document.createElement('button');
|
|
1177
|
+
b.className = 'sl-opt' + (i === sl.current ? ' cur' : '');
|
|
1178
|
+
b.textContent = label;
|
|
1179
|
+
b.onclick = () => {
|
|
1180
|
+
const keys = [];
|
|
1181
|
+
for (let k = 0; k < sl.options.length; k++) keys.push('Left');
|
|
1182
|
+
for (let k = 0; k < i; k++) keys.push('Right');
|
|
1183
|
+
keys.push('Enter');
|
|
1184
|
+
sendKeysApi(keys);
|
|
1185
|
+
};
|
|
1186
|
+
seg.appendChild(b);
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function renderGenKeys(keys) {
|
|
1191
|
+
genKeys.innerHTML = '';
|
|
1192
|
+
for (const k of keys || []) {
|
|
1193
|
+
const b = document.createElement('button');
|
|
1194
|
+
b.className = 'gk';
|
|
1195
|
+
b.innerHTML = `<span class="kk"></span><span class="gl"></span>`;
|
|
1196
|
+
b.querySelector('.kk').textContent = k.face;
|
|
1197
|
+
b.querySelector('.gl').textContent = k.label;
|
|
1198
|
+
b.onclick = () => sendKeysApi([k.key]);
|
|
1199
|
+
genKeys.appendChild(b);
|
|
1200
|
+
}
|
|
1201
|
+
genKeys.classList.toggle('on', !!(keys && keys.length));
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function renderDialog(st) {
|
|
1205
|
+
const d = st && st.dialog;
|
|
1206
|
+
const ask = st && st.ask;
|
|
1207
|
+
const raw = st && st.raw;
|
|
1208
|
+
const wasStructured = dialogOpen;
|
|
1209
|
+
// The session is blocked on SOME interactive view (waiting). "structured" =
|
|
1210
|
+
// we parsed numbered options; otherwise it's an unrecognized view (/cost,
|
|
1211
|
+
// /doctor, a login flow…) that we show verbatim and drive with raw keys.
|
|
1212
|
+
waiting = !!(st && st.state === 'waiting');
|
|
1213
|
+
const structured = waiting && !!(d || ask);
|
|
1214
|
+
dialogOpen = structured;
|
|
1215
|
+
genericWaiting = waiting && !structured;
|
|
1216
|
+
dlg.classList.toggle('on', waiting);
|
|
1217
|
+
document.body.classList.toggle('dialog', waiting);
|
|
1218
|
+
|
|
1219
|
+
// generic (unstructured) waiting view: show the isolated panel plus buttons
|
|
1220
|
+
// derived from its footer legend (e.g. ← → Enter Esc for /effort).
|
|
1221
|
+
rawBox.classList.toggle('on', genericWaiting);
|
|
1222
|
+
dlg.querySelector('.d-opts').classList.toggle('hidden', genericWaiting);
|
|
1223
|
+
if (genericWaiting) {
|
|
1224
|
+
freeTextOpt = chatAboutOpt = null;
|
|
1225
|
+
freeBox.classList.remove('on');
|
|
1226
|
+
const slider = raw && raw.slider;
|
|
1227
|
+
dlg.querySelector('.d-title').textContent = slider ? '' : 'Claude needs input in the terminal';
|
|
1228
|
+
dlg.querySelector('.d-body').textContent = '';
|
|
1229
|
+
renderSlider(slider);
|
|
1230
|
+
// A slider gets a native segmented control; hide the raw ASCII version.
|
|
1231
|
+
rawBox.classList.toggle('on', !slider);
|
|
1232
|
+
rawBox.textContent = slider ? '' : ((raw && raw.text) || '');
|
|
1233
|
+
renderGenKeys(slider ? [] : (raw && raw.keys));
|
|
1234
|
+
dlg.querySelector('.d-hint').textContent = slider
|
|
1235
|
+
? 'Tap an option, or use ←/→ and Enter · Esc to cancel'
|
|
1236
|
+
: (raw && raw.filter)
|
|
1237
|
+
? 'Type to filter, click a key, or use your keyboard · Esc to exit'
|
|
1238
|
+
: 'Click a key below or use your keyboard · Esc to exit';
|
|
1239
|
+
input.placeholder = (raw && raw.filter) ? 'Type to filter…' : 'Type a key to interact · Esc to exit';
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
genKeys.classList.remove('on');
|
|
1244
|
+
sliderBox.classList.remove('on');
|
|
1245
|
+
if (!waiting) {
|
|
1246
|
+
freeTextOpt = chatAboutOpt = null;
|
|
1247
|
+
freeBox.classList.remove('on');
|
|
1248
|
+
input.placeholder = DEFAULT_PLACEHOLDER;
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
const wasOpen = wasStructured;
|
|
1252
|
+
const askQ = ask && ask.questions && ask.questions[0];
|
|
1253
|
+
dlg.querySelector('.d-title').textContent = (askQ && askQ.question) || (d && d.title) || 'Claude is asking:';
|
|
1254
|
+
dlg.querySelector('.d-body').textContent = (d && d.body) || '';
|
|
1255
|
+
// Panel content the dialog is ABOUT (e.g. the plan in a plan-approval
|
|
1256
|
+
// prompt) — without it you'd be approving something you can't read.
|
|
1257
|
+
const dCtx = (!askQ && d && d.context) || '';
|
|
1258
|
+
rawBox.classList.toggle('on', !!dCtx);
|
|
1259
|
+
rawBox.textContent = dCtx;
|
|
1260
|
+
const opts = dlg.querySelector('.d-opts');
|
|
1261
|
+
opts.innerHTML = '';
|
|
1262
|
+
const list = (d && d.options) ||
|
|
1263
|
+
((askQ && askQ.options) || []).map((o, i) => ({ n: i + 1, label: o.label || String(o), desc: o.description || '', selected: false }));
|
|
1264
|
+
freeTextOpt = list.find((o) => isFreeText(o.label)) || null;
|
|
1265
|
+
chatAboutOpt = list.find((o) => isChatOut(o.label)) || null;
|
|
1266
|
+
// Preset choices (and "Chat about this") are clickable; "Type something" is
|
|
1267
|
+
// driven by the dedicated text box below instead of an option button.
|
|
1268
|
+
for (const o of list) {
|
|
1269
|
+
if (isFreeText(o.label)) continue;
|
|
1270
|
+
const askOpt = askQ && (askQ.options || []).find((a) => (a.label || '') === o.label);
|
|
1271
|
+
const b = document.createElement('button');
|
|
1272
|
+
b.className = 'd-opt' + (o.selected ? ' sel' : '') + (isChatOut(o.label) ? ' alt' : '');
|
|
1273
|
+
b.innerHTML = `<span class="n"></span><span class="lbl"></span><span class="dsc"></span>`;
|
|
1274
|
+
b.querySelector('.n').textContent = o.n;
|
|
1275
|
+
b.querySelector('.lbl').textContent = o.label;
|
|
1276
|
+
b.querySelector('.dsc').textContent = (askOpt && askOpt.description) || o.desc || '';
|
|
1277
|
+
b.onclick = () => sendKeysApi([...String(o.n)]); // digit(s) select + confirm
|
|
1278
|
+
opts.appendChild(b);
|
|
1279
|
+
}
|
|
1280
|
+
freeBox.classList.toggle('on', !!freeTextOpt);
|
|
1281
|
+
dlg.querySelector('.d-hint').textContent = (d && d.hint) || 'Pick an option, or use the keys';
|
|
1282
|
+
input.placeholder = chatAboutOpt
|
|
1283
|
+
? 'Type here to respond in chat instead…'
|
|
1284
|
+
: 'Pick an option above (Esc to cancel)';
|
|
1285
|
+
if (!wasOpen && freeTextOpt) freeInput.focus();
|
|
1286
|
+
// On touch, a selection dialog (no free-text field) shouldn't pop the
|
|
1287
|
+
// keyboard — the composer often still holds focus from typing the command.
|
|
1288
|
+
// Blur it so /model etc. don't get squeezed by the on-screen keyboard.
|
|
1289
|
+
else if (!wasOpen && !freeTextOpt && matchMedia('(pointer: coarse)').matches) {
|
|
1290
|
+
if (document.activeElement === input) input.blur();
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function setStatus(st) {
|
|
1295
|
+
working = !!(st && st.working);
|
|
1296
|
+
document.body.classList.toggle('working', working);
|
|
1297
|
+
typing.classList.toggle('on', working);
|
|
1298
|
+
if (working) autoscroll();
|
|
1299
|
+
renderDialog(st);
|
|
1300
|
+
const s = st && st.session;
|
|
1301
|
+
if (s && s.ok) {
|
|
1302
|
+
dot.className = 'dot ' + (working ? 'busy' : 'ok');
|
|
1303
|
+
const extra = working ? ' · working…' : (st.state === 'waiting' ? ' · waiting for input' : '');
|
|
1304
|
+
sess.textContent = `${s.cwd || s.tmux || ''} · ${String(s.sessionId || '').slice(0, 8)}${extra}`;
|
|
1305
|
+
} else {
|
|
1306
|
+
dot.className = 'dot';
|
|
1307
|
+
sess.textContent = (s && s.reason) || 'no session';
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function resetTranscript() {
|
|
1312
|
+
chat.querySelectorAll('.msg, details.aux, .notice').forEach((e) => e.remove());
|
|
1313
|
+
toolChips.clear();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
let reconnectTimer = null;
|
|
1317
|
+
let lastEventAt = 0;
|
|
1318
|
+
// Integrated mode: the app's proxy answers the events stream of a PAUSED
|
|
1319
|
+
// machine with a single {"type":"sleeping"} and ends. We stop reconnecting
|
|
1320
|
+
// (so an idle open tab never wakes the box) until an explicit wake — a
|
|
1321
|
+
// successful send, or the page's wake POST firing 'webchat:reconnect'.
|
|
1322
|
+
let sleeping = false;
|
|
1323
|
+
function connect() {
|
|
1324
|
+
clearTimeout(reconnectTimer);
|
|
1325
|
+
if (es) es.close();
|
|
1326
|
+
es = new EventSource(api('/api/events'));
|
|
1327
|
+
lastEventAt = Date.now();
|
|
1328
|
+
let firstOfConnection = true;
|
|
1329
|
+
es.onopen = () => { offline.classList.remove('on'); lastEventAt = Date.now(); };
|
|
1330
|
+
es.onmessage = (ev) => {
|
|
1331
|
+
lastEventAt = Date.now();
|
|
1332
|
+
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
|
|
1333
|
+
if (msg.type === 'hb') return; // server heartbeat, only feeds the watchdog
|
|
1334
|
+
if (msg.type === 'sleeping') {
|
|
1335
|
+
sleeping = true;
|
|
1336
|
+
es.close();
|
|
1337
|
+
clearTimeout(reconnectTimer);
|
|
1338
|
+
// state: 'paused' (wakeable), 'gone' (sandbox deleted — nothing will
|
|
1339
|
+
// wake it, don't pretend otherwise), or 'unknown'.
|
|
1340
|
+
const gone = msg.state === 'gone';
|
|
1341
|
+
offline.textContent = gone
|
|
1342
|
+
? 'this machine no longer exists'
|
|
1343
|
+
: wakePending
|
|
1344
|
+
? 'waking machine…'
|
|
1345
|
+
: 'machine is paused — sending a message will wake it';
|
|
1346
|
+
offline.classList.add('on');
|
|
1347
|
+
dot.className = 'dot';
|
|
1348
|
+
sess.textContent = gone ? 'machine deleted' : wakePending ? 'waking…' : 'paused';
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
if (firstOfConnection) { captureReconnectAnchor(); resetTranscript(); beginBacklog(); firstOfConnection = false; }
|
|
1352
|
+
if (msg.type === 'status') setStatus(msg.status);
|
|
1353
|
+
else if (msg.type === 'reset') { resetTranscript(); setStatus({ working: false, session: msg.session }); }
|
|
1354
|
+
else if (msg.type === 'item') renderItem(msg.item);
|
|
1355
|
+
else if (msg.type === 'backlog_done') { endBacklog(); }
|
|
1356
|
+
};
|
|
1357
|
+
// EventSource only auto-retries on network drops; an HTTP error response
|
|
1358
|
+
// (proxy 502 during a server restart, suspended mobile tab…) closes it
|
|
1359
|
+
// PERMANENTLY. Recreate it ourselves or the page silently goes stale.
|
|
1360
|
+
// Each server response replays the backlog, so wipe once per connection.
|
|
1361
|
+
es.onerror = () => {
|
|
1362
|
+
offline.classList.add('on');
|
|
1363
|
+
firstOfConnection = true;
|
|
1364
|
+
// Don't leave the transcript hidden if the stream broke mid-backlog
|
|
1365
|
+
// (backlog_done never arrived to reveal it).
|
|
1366
|
+
chat.classList.remove('loading');
|
|
1367
|
+
if (!sleeping && es.readyState === EventSource.CLOSED) {
|
|
1368
|
+
clearTimeout(reconnectTimer);
|
|
1369
|
+
reconnectTimer = setTimeout(connect, 2500);
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
function wakeReconnect() {
|
|
1374
|
+
sleeping = false;
|
|
1375
|
+
wakePending = false;
|
|
1376
|
+
offline.textContent = 'reconnecting…';
|
|
1377
|
+
if (!es || es.readyState === EventSource.CLOSED) connect();
|
|
1378
|
+
}
|
|
1379
|
+
// Integrated-page wake lifecycle (resume-on-open): 'waking' flips the paused
|
|
1380
|
+
// banner to an in-progress one, 'reconnect' reattaches after the resume,
|
|
1381
|
+
// 'wake-failed' restores an actionable message instead of hanging forever.
|
|
1382
|
+
let wakePending = !!window.WEBCHAT_WAKING; // set by the integrated page pre-boot
|
|
1383
|
+
window.addEventListener('webchat:waking', () => {
|
|
1384
|
+
wakePending = true;
|
|
1385
|
+
if (sleeping) { offline.textContent = 'waking machine…'; sess.textContent = 'waking…'; }
|
|
1386
|
+
});
|
|
1387
|
+
window.addEventListener('webchat:wake-failed', (e) => {
|
|
1388
|
+
wakePending = false;
|
|
1389
|
+
const det = e.detail && typeof e.detail === 'object' ? e.detail : { message: e.detail };
|
|
1390
|
+
if (sleeping) {
|
|
1391
|
+
offline.textContent = det.code === 'gone'
|
|
1392
|
+
? 'this machine no longer exists'
|
|
1393
|
+
: (det.message ? det.message + ' — ' : '') + 'sending a message will retry';
|
|
1394
|
+
sess.textContent = det.code === 'gone' ? 'machine deleted' : 'paused';
|
|
1395
|
+
}
|
|
1396
|
+
});
|
|
1397
|
+
window.addEventListener('webchat:reconnect', wakeReconnect);
|
|
1398
|
+
// Suspended tabs (esp. mobile) kill the stream; reattach on return.
|
|
1399
|
+
document.addEventListener('visibilitychange', () => {
|
|
1400
|
+
if (!document.hidden && !sleeping && (!es || es.readyState === EventSource.CLOSED)) connect();
|
|
1401
|
+
});
|
|
1402
|
+
window.addEventListener('online', () => {
|
|
1403
|
+
if (!sleeping && (!es || es.readyState === EventSource.CLOSED)) connect();
|
|
1404
|
+
});
|
|
1405
|
+
// Staleness watchdog: a proxy can keep our side of the stream open after the
|
|
1406
|
+
// backend dies — readyState stays OPEN and no error ever fires. The server
|
|
1407
|
+
// heartbeats every 15s, so >45s of silence means the stream is dead.
|
|
1408
|
+
setInterval(() => {
|
|
1409
|
+
if (!sleeping && lastEventAt && Date.now() - lastEventAt > 45000 && !document.hidden) {
|
|
1410
|
+
offline.classList.add('on');
|
|
1411
|
+
connect();
|
|
1412
|
+
}
|
|
1413
|
+
}, 10000);
|
|
1414
|
+
|
|
1415
|
+
// ---------- sending ----------
|
|
1416
|
+
const attachments = []; // {name, path}
|
|
1417
|
+
|
|
1418
|
+
function refreshAttachBar() {
|
|
1419
|
+
attachBar.classList.toggle('on', attachments.length > 0);
|
|
1420
|
+
attachBar.innerHTML = '';
|
|
1421
|
+
attachments.forEach((a, i) => {
|
|
1422
|
+
const pill = node(`<span class="pill"><span></span><button title="Remove">×</button></span>`);
|
|
1423
|
+
pill.querySelector('span').textContent = a.name;
|
|
1424
|
+
pill.querySelector('button').onclick = () => { attachments.splice(i, 1); refreshAttachBar(); };
|
|
1425
|
+
attachBar.appendChild(pill);
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
async function uploadFile(file) {
|
|
1430
|
+
const data = await new Promise((res, rej) => {
|
|
1431
|
+
const r = new FileReader();
|
|
1432
|
+
r.onload = () => res(String(r.result).split(',')[1] || '');
|
|
1433
|
+
r.onerror = rej;
|
|
1434
|
+
r.readAsDataURL(file);
|
|
1435
|
+
});
|
|
1436
|
+
const resp = await fetch(api('/api/upload'), {
|
|
1437
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1438
|
+
body: JSON.stringify({ name: file.name || 'pasted-image.png', data }),
|
|
1439
|
+
});
|
|
1440
|
+
const j = await resp.json();
|
|
1441
|
+
if (!resp.ok) throw new Error(j.error || 'upload failed');
|
|
1442
|
+
attachments.push({ name: file.name || 'pasted image', path: j.path });
|
|
1443
|
+
refreshAttachBar();
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
async function handleFiles(list) {
|
|
1447
|
+
for (const f of list) {
|
|
1448
|
+
try { await uploadFile(f); }
|
|
1449
|
+
catch (e) { alert('Upload failed: ' + e.message); }
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// With a dialog open, the MAIN composer is the "chat about this" path: an
|
|
1454
|
+
// empty submit confirms the highlighted option; typed text declines the
|
|
1455
|
+
// question ("Chat about this") and then sends that text as a normal message.
|
|
1456
|
+
// The structured answer ("Type something") is handled by the dialog's own box.
|
|
1457
|
+
async function answerDialog() {
|
|
1458
|
+
const text = input.value.trim();
|
|
1459
|
+
if (!text) { await sendKeysApi(['Enter']); return; }
|
|
1460
|
+
if (!chatAboutOpt) return; // nothing sensible to do (e.g. model picker)
|
|
1461
|
+
input.value = ''; input.style.height = 'auto';
|
|
1462
|
+
await sendKeysApi([String(chatAboutOpt.n)]); // decline → back to normal input
|
|
1463
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
1464
|
+
await doSend(text); // queues as a normal message
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// POST a normal message to the session with an optimistic bubble.
|
|
1468
|
+
async function doSend(text) {
|
|
1469
|
+
if (!text.trim()) return;
|
|
1470
|
+
const el = node(`<div class="msg user pending"><div class="bubble"></div></div>`);
|
|
1471
|
+
el.querySelector('.bubble').textContent = text;
|
|
1472
|
+
addBefore(el); autoscroll(true);
|
|
1473
|
+
lastUserText = text;
|
|
1474
|
+
// Sending to a paused machine: the proxy resumes it first (can take up to
|
|
1475
|
+
// ~a minute for a cold restore) — say so instead of looking frozen.
|
|
1476
|
+
if (sleeping) { offline.textContent = 'waking machine…'; offline.classList.add('on'); }
|
|
1477
|
+
try {
|
|
1478
|
+
const r = await fetch(api('/api/send'), {
|
|
1479
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1480
|
+
body: JSON.stringify({ text }),
|
|
1481
|
+
});
|
|
1482
|
+
if (!r.ok) {
|
|
1483
|
+
const j = await r.json().catch(() => ({}));
|
|
1484
|
+
el.remove();
|
|
1485
|
+
input.value = text; input.dispatchEvent(new Event('input'));
|
|
1486
|
+
alert('Send failed: ' + (j.error || r.status));
|
|
1487
|
+
} else if (sleeping) {
|
|
1488
|
+
wakeReconnect(); // machine is up again — reattach the stream
|
|
1489
|
+
}
|
|
1490
|
+
} catch (e) {
|
|
1491
|
+
el.remove(); input.value = text; input.dispatchEvent(new Event('input'));
|
|
1492
|
+
alert('Send failed: ' + e.message);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
async function send() {
|
|
1497
|
+
if (genericWaiting) { await sendKeysApi(['Enter']); return; }
|
|
1498
|
+
if (dialogOpen) { await answerDialog(); return; }
|
|
1499
|
+
if (waiting) return; // waiting but not yet classified — don't inject a message
|
|
1500
|
+
let text = input.value;
|
|
1501
|
+
if (attachments.length) {
|
|
1502
|
+
text = (text.trim() ? text.trimEnd() + '\n\n' : '') +
|
|
1503
|
+
attachments.map((a) => `Attached file: ${a.path}`).join('\n');
|
|
1504
|
+
}
|
|
1505
|
+
if (!text.trim()) return;
|
|
1506
|
+
input.value = ''; input.style.height = 'auto';
|
|
1507
|
+
attachments.length = 0; refreshAttachBar();
|
|
1508
|
+
await doSend(text);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
async function interrupt() {
|
|
1512
|
+
try { await fetch(api('/api/interrupt'), { method: 'POST' }); } catch {}
|
|
1513
|
+
// Mirror the TUI: esc restores the canceled prompt into the composer.
|
|
1514
|
+
if (!input.value.trim() && lastUserText) {
|
|
1515
|
+
input.value = lastUserText;
|
|
1516
|
+
input.dispatchEvent(new Event('input'));
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
sendBtn.onclick = send;
|
|
1521
|
+
stopBtn.onclick = interrupt;
|
|
1522
|
+
|
|
1523
|
+
function onEscape(e) {
|
|
1524
|
+
if (!fileview.hidden) { e.preventDefault(); fileview.hidden = true; return; }
|
|
1525
|
+
// Whenever the session is waiting on ANY interactive view, Esc exits it —
|
|
1526
|
+
// including unrecognized ones like /cost, so the user is never trapped.
|
|
1527
|
+
if (waiting) { e.preventDefault(); sendKeysApi(['Escape']); }
|
|
1528
|
+
else if (working) { e.preventDefault(); interrupt(); }
|
|
1529
|
+
}
|
|
1530
|
+
const ARROW = { ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right' };
|
|
1531
|
+
// Forward one browser key to the TUI (for the generic 'waiting' view). Returns
|
|
1532
|
+
// true if it mapped to something we sent.
|
|
1533
|
+
function forwardKey(e) {
|
|
1534
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return false; // leave browser shortcuts alone
|
|
1535
|
+
if (e.key === 'Enter' && !e.shiftKey) { sendKeysApi(['Enter']); return true; }
|
|
1536
|
+
if (ARROW[e.key]) { sendKeysApi([ARROW[e.key]]); return true; }
|
|
1537
|
+
if (e.key === 'Backspace') { sendKeysApi(['BSpace']); return true; }
|
|
1538
|
+
if (e.key === 'Tab') { sendKeysApi(['Tab']); return true; }
|
|
1539
|
+
if (e.key === ' ') { sendKeysApi(['Space']); return true; }
|
|
1540
|
+
if (e.key.length === 1) { sendKeysApi([e.key]); return true; }
|
|
1541
|
+
return false;
|
|
1542
|
+
}
|
|
1543
|
+
input.addEventListener('keydown', (e) => {
|
|
1544
|
+
if (e.key === 'Escape') { onEscape(e); return; }
|
|
1545
|
+
if (genericWaiting) return; // forwarded by the document handler below
|
|
1546
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
|
1547
|
+
// With a structured dialog open and nothing typed yet, arrows navigate it.
|
|
1548
|
+
else if (dialogOpen && !input.value && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
1549
|
+
e.preventDefault();
|
|
1550
|
+
sendKeysApi([ARROW[e.key]]);
|
|
1551
|
+
}
|
|
1552
|
+
});
|
|
1553
|
+
document.addEventListener('keydown', (e) => {
|
|
1554
|
+
if (e.key === 'Escape') {
|
|
1555
|
+
if (!fileview.hidden) { e.preventDefault(); fileview.hidden = true; return; }
|
|
1556
|
+
if (document.activeElement !== input && document.activeElement !== freeInput) onEscape(e);
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
// Unrecognized 'waiting' view (/cost, /doctor, y/n prompts…): the whole page
|
|
1560
|
+
// is a raw key pipe to the TUI so its shortcuts (d, w, arrows, Enter) work
|
|
1561
|
+
// without the user having to focus anything.
|
|
1562
|
+
if (genericWaiting && fileview.hidden && forwardKey(e)) e.preventDefault();
|
|
1563
|
+
});
|
|
1564
|
+
input.addEventListener('input', () => {
|
|
1565
|
+
input.style.height = 'auto';
|
|
1566
|
+
input.style.height = Math.min(input.scrollHeight, 220) + 'px';
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
// attach: button, paste, drag-drop
|
|
1570
|
+
attachBtn.onclick = () => fileInput.click();
|
|
1571
|
+
fileInput.onchange = () => { handleFiles([...fileInput.files]); fileInput.value = ''; };
|
|
1572
|
+
input.addEventListener('paste', (e) => {
|
|
1573
|
+
const files = [...(e.clipboardData?.items || [])]
|
|
1574
|
+
.filter((i) => i.kind === 'file').map((i) => i.getAsFile()).filter(Boolean);
|
|
1575
|
+
if (files.length) { e.preventDefault(); handleFiles(files); }
|
|
1576
|
+
});
|
|
1577
|
+
const comp = $('composer');
|
|
1578
|
+
for (const t of ['dragenter', 'dragover']) document.addEventListener(t, (e) => { e.preventDefault(); comp.classList.add('drag'); });
|
|
1579
|
+
for (const t of ['dragleave', 'drop']) document.addEventListener(t, (e) => { e.preventDefault(); if (t === 'drop' || e.target === document.documentElement) comp.classList.remove('drag'); });
|
|
1580
|
+
document.addEventListener('drop', (e) => {
|
|
1581
|
+
const files = [...(e.dataTransfer?.files || [])];
|
|
1582
|
+
if (files.length) handleFiles(files);
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
connect();
|
|
1586
|
+
// Don't auto-focus on touch — it pops the keyboard the instant the page loads.
|
|
1587
|
+
if (matchMedia('(pointer: fine)').matches) input.focus();
|
|
1588
|
+
})();
|
|
1589
|
+
</script>
|
|
1590
|
+
</body>
|
|
1591
|
+
</html>
|