Skip to content

Commit e2fb785

Browse files
committed
feat: add message copy actions, follow-tail control, and draft persistence
1 parent f06fa88 commit e2fb785

6 files changed

Lines changed: 183 additions & 17 deletions

File tree

frontend/app.js

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,19 +50,22 @@ const hubCount = document.getElementById('hubCount');
5050
const hubEl = document.getElementById('hub');
5151
const hubOverlay = document.getElementById('hub-overlay');
5252
const hubList = document.getElementById('hubList');
53+
const jumpLatestBtn = document.getElementById('jumpLatest');
5354

5455
// ── 2. State ─────────────────────────────────────────────────────────
5556

5657
let ws = null;
5758
let streaming = false;
58-
let activeSessionId = null;
59+
let activeSessionId = localStorage.getItem('chat-active-session');
5960
let reconnectTimer = null;
6061
let promptWatchdog = null;
6162
let lastState = null; // last state_update (model, thinkingLevel, …)
6263
let modelCache = null; // last model_list payload
6364
let caps = { models: false, thinking: false, subagents: false, transcripts: false, cost: false, commands: false, abort: true };
6465
let COMMANDS = []; // adapter-provided slash commands (via capabilities)
6566
let hubAgents = []; // last subagent_update payload
67+
let followTail = true; // false while the user is reading older output
68+
let restoringHistory = false;
6669

6770
// In-flight assistant message being streamed.
6871
let agentEl = null; // bubble element
@@ -86,7 +89,80 @@ function resetAgent() {
8689
agentEl = null; textEl = null; thinkEl = null; pendingMd = '';
8790
}
8891

89-
function scrollBottom() { chat.scrollTop = chat.scrollHeight; }
92+
function isNearBottom() {
93+
return chat.scrollHeight - chat.scrollTop - chat.clientHeight < 72;
94+
}
95+
96+
function updateFollowTail() {
97+
if (restoringHistory) return;
98+
followTail = isNearBottom();
99+
jumpLatestBtn.classList.toggle('visible', !followTail);
100+
}
101+
102+
function scrollBottom(force = false) {
103+
if (!force && !followTail) {
104+
jumpLatestBtn.classList.add('visible');
105+
return;
106+
}
107+
chat.scrollTop = chat.scrollHeight;
108+
followTail = true;
109+
jumpLatestBtn.classList.remove('visible');
110+
}
111+
112+
function jumpToLatest() {
113+
scrollBottom(true);
114+
}
115+
116+
function draftKey(sessionId = activeSessionId) {
117+
return sessionId ? `chat-draft:${sessionId}` : null;
118+
}
119+
120+
function persistDraft() {
121+
const key = draftKey();
122+
if (!key) return;
123+
if (input.value) localStorage.setItem(key, input.value);
124+
else localStorage.removeItem(key);
125+
}
126+
127+
function restoreDraft() {
128+
input.value = localStorage.getItem(draftKey()) || '';
129+
input.style.height = 'auto';
130+
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
131+
}
132+
133+
async function copyText(text, button) {
134+
if (!text) return;
135+
try {
136+
await navigator.clipboard.writeText(text);
137+
} catch {
138+
const helper = document.createElement('textarea');
139+
helper.value = text;
140+
helper.style.position = 'fixed';
141+
helper.style.opacity = '0';
142+
document.body.appendChild(helper);
143+
helper.select();
144+
document.execCommand('copy');
145+
helper.remove();
146+
}
147+
const old = button.textContent;
148+
button.textContent = 'Copied';
149+
button.classList.add('copied');
150+
setTimeout(() => { button.textContent = old; button.classList.remove('copied'); }, 1200);
151+
}
152+
153+
function addMessageActions(el, text) {
154+
if (!text) return;
155+
const actions = document.createElement('div');
156+
actions.className = 'message-actions';
157+
const button = document.createElement('button');
158+
button.className = 'copy-btn';
159+
button.type = 'button';
160+
button.textContent = 'Copy';
161+
button.setAttribute('aria-label', 'Copy message');
162+
button.onclick = () => copyText(text, button);
163+
actions.appendChild(button);
164+
el.appendChild(actions);
165+
}
90166

91167
function saveNick(v) {
92168
nick = (v || '').trim().slice(0, 20) || nick;
@@ -205,6 +281,7 @@ function userMsg(text, who) {
205281
el.className = 'msg theirs';
206282
el.innerHTML = `<span class="nick">${esc(who)}</span>${esc(text)}`;
207283
}
284+
addMessageActions(el, text);
208285
chat.appendChild(el);
209286
scrollBottom();
210287
}
@@ -224,6 +301,15 @@ function renderText(delta) {
224301
agentBubble();
225302
pendingMd += delta;
226303
textEl.innerHTML = md(pendingMd);
304+
let actions = agentEl.querySelector('.message-actions');
305+
if (!actions) {
306+
actions = document.createElement('div');
307+
actions.className = 'message-actions';
308+
actions.innerHTML = '<button class="copy-btn" type="button" aria-label="Copy message">Copy</button>';
309+
agentEl.appendChild(actions);
310+
}
311+
const messageText = pendingMd;
312+
actions.querySelector('button').onclick = (e) => copyText(messageText, e.currentTarget);
227313
scrollBottom();
228314
}
229315

@@ -247,7 +333,7 @@ function renderToolCard(name, params) {
247333
<div class="hd"><span class="icon">⚙</span><span class="name">${esc(name)}</span><span class="st">running…</span></div>
248334
<div class="bd">
249335
${paramsStr ? `<div class="tool-params"><span class="lbl">params</span><pre>${esc(paramsStr)}</pre></div>` : ''}
250-
<div class="tool-result"><span class="lbl">result</span><pre>waiting…</pre></div>
336+
<div class="tool-result"><div class="tool-label-row"><span class="lbl">result</span><button class="copy-btn" type="button" aria-label="Copy tool result" disabled>Copy</button></div><pre>waiting…</pre></div>
251337
</div>`;
252338
card.querySelector('.hd').onclick = () => card.classList.toggle('open');
253339
chat.appendChild(card);
@@ -262,6 +348,10 @@ function finishToolCard(result) {
262348
last.querySelector('.st').classList.add('done');
263349
const out = last.querySelector('.tool-result pre');
264350
if (!out) return;
351+
const copyButton = last.querySelector('.tool-result .copy-btn');
352+
const resultText = result || '(no output)';
353+
copyButton.disabled = false;
354+
copyButton.onclick = () => copyText(resultText, copyButton);
265355
if (result) {
266356
// Markdown-render text-like results, truncate long ones
267357
if (result.length < 2000 && (result.includes('\n') || result.includes('`') || result.includes('**'))) {
@@ -318,6 +408,7 @@ function clearChat() {
318408
}
319409

320410
function loadHistory(messages) {
411+
restoringHistory = true;
321412
clearChat();
322413
for (const m of messages) {
323414
if (m.role === 'user') {
@@ -326,10 +417,12 @@ function loadHistory(messages) {
326417
agentBubble();
327418
pendingMd = m.content;
328419
textEl.innerHTML = md(pendingMd);
420+
addMessageActions(agentEl, m.content);
329421
resetAgent();
330422
}
331423
}
332-
chat.scrollTop = chat.scrollHeight;
424+
restoringHistory = false;
425+
scrollBottom(true);
333426
}
334427

335428
// ── 7. Agent Hub (R14 + transcripts) ────────────────────────────────
@@ -678,8 +771,11 @@ function handleEvent(msg) {
678771
renderSessionList(lastSessions);
679772
break;
680773
case 'session_switched':
774+
persistDraft();
681775
activeSessionId = msg.sessionId;
776+
localStorage.setItem('chat-active-session', activeSessionId);
682777
clearChat();
778+
restoreDraft();
683779
// Per-session state from the previous session must not leak in
684780
hubAgents = [];
685781
hubView = { mode: 'list', agentId: null };
@@ -871,6 +967,7 @@ function send() {
871967
return;
872968
}
873969
input.value = '';
970+
persistDraft();
874971
input.style.height = 'auto';
875972
userMsg(t, nick);
876973
ws.send(JSON.stringify({
@@ -893,7 +990,7 @@ function stop() {
893990
input.addEventListener('keydown', (e) => {
894991
const pickerOpen = pickerEl.classList.contains('visible');
895992
const cmdOpen = cmdList.classList.contains('visible');
896-
if (e.key === 'Enter' && !e.shiftKey) {
993+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
897994
e.preventDefault();
898995
if (pickerOpen) pickerAccept();
899996
else if (cmdOpen) cmdAccept();
@@ -917,13 +1014,25 @@ input.addEventListener('keydown', (e) => {
9171014
});
9181015

9191016
input.addEventListener('input', () => {
1017+
persistDraft();
9201018
input.style.height = 'auto';
9211019
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
9221020
if (maybeShowPicker(input.value)) return;
9231021
hidePicker();
9241022
showAutocomplete(input.value);
9251023
});
9261024

1025+
chat.addEventListener('scroll', updateFollowTail, { passive: true });
1026+
chat.addEventListener('click', (e) => {
1027+
const button = e.target.closest('[data-copy="code"]');
1028+
if (!button) return;
1029+
const code = button.closest('.code-block')?.querySelector('code')?.textContent || '';
1030+
copyText(code, button);
1031+
});
1032+
document.addEventListener('visibilitychange', () => {
1033+
if (document.hidden) persistDraft();
1034+
});
1035+
9271036
// ── init ─────────────────────────────────────────────────────────────
9281037

9291038
connect();

frontend/contract.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export function runStaticChecks(): { passed: CheckResult[]; failed: CheckResult[
4949
"lastState", "modelCache", "caps", "COMMANDS", "hubAgents", "agentEl",
5050
"textEl", "thinkEl", "pendingMd", "lastSessions", "nick", "hubView",
5151
"sessionFiles", "hubTranscripts", "pickerMode", "cmdActiveIndex", "pickerActiveIndex",
52+
"followTail", "restoringHistory",
5253
];
5354
const problems: string[] = [];
5455
for (const name of canonical) {
@@ -122,6 +123,16 @@ export function runStaticChecks(): { passed: CheckResult[]; failed: CheckResult[
122123
assert(undeclared.length === 0, `var() without :root declaration: [${undeclared}]`);
123124
});
124125

126+
run("mobile UX: semantic copy, IME-safe send, draft and follow-tail", () => {
127+
const app = file("app.js");
128+
const html = file("index.html");
129+
const markdown = file("markdown.js");
130+
assert(app.includes("e.isComposing") && app.includes("e.keyCode !== 229"), "Enter send is not IME-safe");
131+
assert(app.includes("chat-draft:") && app.includes("persistDraft"), "session drafts are not persisted");
132+
assert(app.includes("followTail") && html.includes('id="jumpLatest"'), "follow-tail control missing");
133+
assert(markdown.includes('data-copy="code"') && app.includes("addMessageActions") && app.includes("Copy tool result"), "copy actions missing");
134+
});
135+
125136
// C9: adapter request/response pairing — an edit once deleted the
126137
// get_available_models response branch, leaving /model permanently
127138
// empty. Every RPC command the adapter writes must have a matching

frontend/index.html

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ <h1 id="chatTitle">Agent Chat</h1>
4242
<input class="nick" id="nickInput" placeholder="nick" maxlength="20" onchange="saveNick(this.value)">
4343
</header>
4444

45-
<main id="chat"></main>
45+
<main id="chat" aria-live="polite" aria-relevant="additions text"></main>
46+
<button id="jumpLatest" class="jump-latest" type="button" onclick="jumpToLatest()" aria-label="Jump to latest message">
47+
<span>New activity</span><span aria-hidden="true"></span>
48+
</button>
4649

4750
<div id="hub-overlay" onclick="toggleHub()"></div>
4851
<div id="hub">
@@ -56,7 +59,7 @@ <h1 id="chatTitle">Agent Chat</h1>
5659
<footer>
5760
<div class="cmd-list" id="cmdList"></div>
5861
<div class="row">
59-
<textarea id="input" placeholder="Send a prompt… or / for commands" rows="1"></textarea>
62+
<textarea id="input" placeholder="Send a prompt… or / for commands" rows="1" enterkeyhint="send" aria-label="Message"></textarea>
6063
<button class="send" id="sendBtn" onclick="send()">Send</button>
6164
<button class="stop" id="stopBtn" onclick="stop()">Stop</button>
6265
</div>

frontend/markdown.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function md(src) {
6565
if (cb) {
6666
flushPara(); flushList();
6767
const b = blocks[parseInt(cb[1])];
68-
html += `<div class="code-block"><div class="code-lang">${escAttr(b.lang || 'text')}</div><pre><code>${hl(b.code, b.lang)}</code></pre></div>`;
68+
html += codeBlock(b);
6969
continue;
7070
}
7171

@@ -116,12 +116,16 @@ function md(src) {
116116
// Restore any remaining code blocks
117117
html = html.replace(/\x00CB(\d+)\x00/g, (_, i) => {
118118
const b = blocks[parseInt(i)];
119-
return `<div class="code-block"><div class="code-lang">${escAttr(b.lang || 'text')}</div><pre><code>${hl(b.code, b.lang)}</code></pre></div>`;
119+
return codeBlock(b);
120120
});
121121

122122
return html;
123123
}
124124

125+
function codeBlock(block) {
126+
return `<div class="code-block"><div class="code-head"><span class="code-lang">${escAttr(block.lang || 'text')}</span><button class="copy-btn" type="button" data-copy="code" aria-label="Copy code">Copy</button></div><pre><code>${hl(block.code, block.lang)}</code></pre></div>`;
127+
}
128+
125129
function escAttr(s) { return s.replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
126130

127131
// ── Basic syntax highlighting ─────────────────────────────────────────

frontend/style.css

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,13 @@ main::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
8585

8686
/* Code blocks */
8787
.code-block { background:var(--bg); border:1px solid var(--border); border-radius:8px; margin:8px 0; overflow:hidden; }
88-
.code-block .code-lang { font-size:10px; color:var(--dim); padding:4px 10px; border-bottom:1px solid var(--border); background:var(--surface); text-transform:uppercase; letter-spacing:.5px; }
88+
.code-block .code-head { min-height:36px; padding:3px 5px 3px 10px; border-bottom:1px solid var(--border); background:var(--surface); display:flex; align-items:center; justify-content:space-between; gap:8px; }
89+
.code-block .code-lang { font-size:10px; color:var(--dim); text-transform:uppercase; letter-spacing:.5px; }
8990
.code-block pre { padding:10px 12px; overflow-x:auto; margin:0; }
9091
.code-block code { font-family:var(--code-font); font-size:12px; line-height:1.5; color:var(--text); }
92+
.copy-btn { min-width:44px; min-height:30px; padding:4px 9px; border:1px solid var(--border); border-radius:6px; background:transparent; color:var(--dim); font:500 11px var(--font); cursor:pointer; touch-action:manipulation; }
93+
.copy-btn:hover, .copy-btn:focus-visible { color:var(--text); border-color:var(--accent); outline:none; }
94+
.copy-btn.copied { color:var(--success); border-color:var(--success); }
9195

9296
/* Syntax highlighting */
9397
.code-block .k { color:#ff7b72; }
@@ -99,6 +103,9 @@ main::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
99103
/* Rich tool cards */
100104
.tool .bd .tool-params, .tool .bd .tool-result { margin:6px 0; }
101105
.tool .bd .lbl { display:block; font-size:10px; color:var(--dim); text-transform:uppercase; letter-spacing:.5px; margin-bottom:4px; }
106+
.tool-label-row { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:4px; }
107+
.tool-label-row .lbl { margin-bottom:0; }
108+
.copy-btn:disabled { opacity:.4; cursor:default; }
102109
.tool .bd .tool-params pre { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:8px; font-size:11px; color:var(--dim); white-space:pre-wrap; max-height:120px; overflow-y:auto; }
103110
.tool .bd .tool-result pre { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:8px; font-size:12px; white-space:pre-wrap; max-height:180px; overflow-y:auto; }
104111
.tool .bd .tool-result pre code { font-family:var(--code-font); font-size:12px; line-height:1.5; }
@@ -179,13 +186,16 @@ main::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
179186
.hc-stats { font-variant-numeric:tabular-nums; }
180187
.hc-out { font-size:12px; color:var(--text); background:var(--surface); border-radius:6px; padding:6px 8px; margin-top:4px; white-space:pre-wrap; max-height:80px; overflow:hidden; font-family:var(--code-font); }
181188

182-
.msg { max-width:85%; line-height:1.6; font-size:14px; word-break:break-word; }
189+
.msg { position:relative; max-width:85%; line-height:1.6; font-size:14px; word-break:break-word; }
183190
.msg.mine, .msg.theirs { white-space:pre-wrap; }
184191
.msg.mine { align-self:flex-end; background:var(--accent); color:#fff; padding:10px 14px; border-radius:var(--radius) var(--radius) 4px var(--radius); }
185192
.msg.theirs { align-self:flex-start; background:var(--surface); border:1px solid var(--accent); padding:10px 14px; border-radius:var(--radius) var(--radius) var(--radius) 4px; }
186193
.msg.theirs .nick { font-size:11px; color:var(--accent); display:block; margin-bottom:4px; font-weight:500; }
187194
.msg.agent { align-self:flex-start; background:var(--surface); border:1px solid var(--border); padding:10px 14px; border-radius:var(--radius) var(--radius) var(--radius) 4px; }
188195
.msg.agent .thinking { color:var(--dim); font-style:italic; font-size:13px; margin-bottom:6px; }
196+
.message-actions { display:flex; justify-content:flex-end; margin:7px -5px -5px 0; }
197+
.msg.mine .copy-btn { color:rgba(255,255,255,.8); border-color:rgba(255,255,255,.35); }
198+
.msg.mine .copy-btn.copied { color:#fff; border-color:#fff; }
189199

190200
.tool { align-self:flex-start; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); max-width:85%; overflow:hidden; }
191201
.tool .hd { padding:8px 12px; display:flex; align-items:center; gap:8px; cursor:pointer; user-select:none; }
@@ -198,6 +208,9 @@ main::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
198208

199209
.notice { align-self:center; font-size:12px; color:var(--dim); padding:4px 12px; background:var(--surface); border-radius:20px; border:1px solid var(--border); }
200210

211+
.jump-latest { position:fixed; right:16px; bottom:calc(82px + env(safe-area-inset-bottom)); z-index:9; display:flex; align-items:center; gap:7px; min-height:44px; padding:8px 14px; border:1px solid var(--accent); border-radius:999px; background:var(--surface); color:var(--text); box-shadow:0 6px 24px rgba(0,0,0,.35); font:500 12px var(--font); cursor:pointer; opacity:0; pointer-events:none; transform:translateY(8px); transition:opacity .15s, transform .15s; }
212+
.jump-latest.visible { opacity:1; pointer-events:auto; transform:none; }
213+
201214
/* Command autocomplete */
202215
.cmd-list { position:absolute; bottom:100%; left:12px; right:12px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius) var(--radius) 0 0; max-height:220px; overflow-y:auto; z-index:10; display:none; }
203216
.cmd-list.visible { display:block; }
@@ -217,10 +230,17 @@ footer { position:relative; width:100%; background:var(--surface); border-top:1p
217230
.row .stop { background:var(--error); color:#fff; display:none; }
218231

219232
@media (max-width:600px) {
220-
.msg, .tool { max-width:92%; }
233+
.msg, .tool { max-width:96%; }
221234
header { padding:8px 10px; }
222235
main { padding:10px; }
223-
#sidebar { width:80vw; }
236+
#sidebar { width:min(88vw, 360px); }
237+
.sb-item { min-height:48px; }
238+
.sb-item .sb-ren, .sb-item .sb-del { min-width:36px; min-height:36px; }
239+
.row button { min-width:56px; min-height:44px; padding:10px 12px; }
240+
.row textarea { font-size:16px; }
241+
.tool .hd { min-height:44px; }
242+
footer { padding-left:10px; padding-right:10px; }
243+
.jump-latest { right:10px; }
224244
}
225245

226246
/* ── Desktop (≥768px): docked sidebar, full-width content ──────────── */
@@ -233,4 +253,5 @@ footer { position:relative; width:100%; background:var(--surface); border-top:1p
233253
#sidebar-overlay { display:none !important; }
234254
#app { flex:1; min-width:0; width:auto; margin-left:0; }
235255
.menu-btn { display:none; }
256+
.jump-latest { right:24px; }
236257
}

0 commit comments

Comments
 (0)