Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 156 additions & 2 deletions scripts/ui/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -821,10 +821,148 @@ async function pollBench(initial) {
}
}

// ---- Chat ------------------------------------------------------------------
// Daemon-backed chat (replaces the retired tkinter popup). Threads + send live
// in ffp_chat behind the chat_* actions. All DOM via textContent/createElement.

let chatThreadId = "";

async function loadChat() {
// Pick up a selection staged by Ctrl+Shift+A (read-and-clear on the daemon).
try {
const staged = await action("chat_take_staged");
if (staged && staged.text) {
$("chat-input").value = staged.text;
chatThreadId = ""; // a staged selection starts a fresh conversation
}
} catch (e) { /* no staged selection */ }
await loadChatThreads();
if (chatThreadId) await openChatThread(chatThreadId);
else renderTranscript([]);
$("chat-input").focus();
}

async function loadChatThreads() {
let threads = [];
try {
const res = await action("chat_threads_list");
threads = (res && res.threads) || [];
} catch (e) {
setStatus("chat-status", `Conversations unavailable: ${e.message}`, false);
}
const list = $("chat-thread-list");
list.textContent = "";
for (const t of threads) {
const li = document.createElement("li");
li.className = "thread-item" + (t.thread_id === chatThreadId ? " active" : "");
const open = document.createElement("button");
open.className = "thread-open";
open.textContent = t.title || "New chat";
open.title = t.updated_at || "";
open.addEventListener("click", () => openChatThread(t.thread_id));
const del = document.createElement("button");
del.className = "thread-del";
del.textContent = "✕";
del.title = "Delete conversation";
del.addEventListener("click", (e) => { e.stopPropagation(); deleteChatThread(t.thread_id); });
li.append(open, del);
list.append(li);
}
$("chat-threads-empty").hidden = threads.length > 0;
}

async function openChatThread(id) {
try {
const t = await action("chat_thread_get", { thread_id: id });
chatThreadId = t.thread_id || id;
renderTranscript(t.history || []);
await loadChatThreads(); // reflect the active thread in the sidebar
} catch (e) {
setStatus("chat-status", `Open failed: ${e.message}`, false);
}
$("chat-input").focus();
}

function renderTranscript(history) {
const box = $("chat-transcript");
box.textContent = "";
let hasTurns = false;
for (const m of history) {
if (m.role !== "user" && m.role !== "assistant") continue; // hide system/grounding
hasTurns = true;
const div = document.createElement("div");
div.className = `chat-msg chat-msg-${m.role}`;
div.textContent = m.content || "";
box.append(div);
}
$("chat-placeholder").hidden = hasTurns;
box.scrollTop = box.scrollHeight;
}

function newChat() {
chatThreadId = "";
renderTranscript([]);
$("chat-input").value = "";
$("chat-input").focus();
setStatus("chat-status", "");
loadChatThreads();
}

async function deleteChatThread(id) {
if (!confirm("Delete this conversation?")) return;
try {
await action("chat_thread_delete", { thread_id: id });
if (id === chatThreadId) { chatThreadId = ""; renderTranscript([]); }
await loadChatThreads();
} catch (e) {
setStatus("chat-status", `Delete failed: ${e.message}`, false);
}
}

async function sendChat() {
const input = $("chat-input");
const message = input.value.trim();
if (!message) return;
const btn = $("chat-send");
btn.disabled = true;
setStatus("chat-status", "Thinking…");
// Optimistically show the user's message; the reply lands when the model returns.
const box = $("chat-transcript");
const userDiv = document.createElement("div");
userDiv.className = "chat-msg chat-msg-user";
userDiv.textContent = message;
box.append(userDiv);
$("chat-placeholder").hidden = true;
box.scrollTop = box.scrollHeight;
input.value = "";
try {
const res = await action("chat_send", {
thread_id: chatThreadId,
message,
use_notes: $("chat-use-notes").checked,
});
chatThreadId = res.thread_id || chatThreadId;
const reply = document.createElement("div");
reply.className = "chat-msg chat-msg-assistant";
reply.textContent = res.reply || "(no reply)";
box.append(reply);
Comment on lines +944 to +948

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent stale chat sends from mutating active transcript

When a local model call is pending, only the Send button is disabled; the sidebar/New Chat controls remain active and Ctrl+Enter can still invoke sendChat. Because this continuation writes through the mutable global chatThreadId and the shared #chat-transcript, if the user opens another conversation or starts a new one before chat_send returns, the late response resets the active thread and appends the assistant reply into whichever transcript is currently rendered. Capture the request's thread/render token or block navigation/resubmission while a send is in flight.

Useful? React with 👍 / 👎.

box.scrollTop = box.scrollHeight;
setStatus("chat-status",
res.notes_used && res.notes_used.length ? `📚 Grounded in: ${res.notes_used.join(", ")}` : "");
loadChatThreads();
} catch (e) {
setStatus("chat-status", `Send failed: ${e.message}`, false);
} finally {
btn.disabled = false;
input.focus();
}
}

// ---- Tabs & refresh --------------------------------------------------------

const TAB_LOADERS = {
overview: loadOverview,
chat: loadChat,
telemetry: loadTelemetry,
history: loadHistory,
notes: loadNotes,
Expand All @@ -846,10 +984,26 @@ function refreshAll() {
(TAB_LOADERS[currentTab] || (() => {}))();
}

// Deep-link support: `/#chat` (or any tab id) selects that tab. Lets a hotkey or
// the tray open the dashboard straight to Chat via daemonBaseUrl + "#chat".
function tabFromHash() {
const h = (location.hash || "").replace(/^#/, "");
return TAB_LOADERS[h] ? h : "";
}

document.addEventListener("DOMContentLoaded", () => {
$("tabs").addEventListener("click", (e) => {
const btn = e.target.closest(".tab");
if (btn) switchTab(btn.dataset.tab);
if (btn) { location.hash = btn.dataset.tab; switchTab(btn.dataset.tab); }
});
window.addEventListener("hashchange", () => {
const t = tabFromHash();
if (t && t !== currentTab) switchTab(t);
});
$("chat-send").addEventListener("click", sendChat);
$("chat-new").addEventListener("click", newChat);
$("chat-input").addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendChat(); }
});
$("refresh-btn").addEventListener("click", refreshAll);
$("theme-btn").addEventListener("click", cycleTheme);
Expand All @@ -873,6 +1027,6 @@ document.addEventListener("DOMContentLoaded", () => {
$("flm-check").addEventListener("click", () => loadFlmVersion(true));
$("bench-run").addEventListener("click", runBenchmark);
refreshHealth();
loadOverview();
switchTab(tabFromHash() || "overview");
setInterval(refreshHealth, 10000);
});
23 changes: 23 additions & 0 deletions scripts/ui/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ <h1>Flowkey</h1>

<nav class="tabs" id="tabs" role="tablist">
<button class="tab active" data-tab="overview">Overview</button>
<button class="tab" data-tab="chat">Chat</button>
<button class="tab" data-tab="telemetry">Telemetry</button>
<button class="tab" data-tab="history">History</button>
<button class="tab" data-tab="notes">Notes</button>
Expand Down Expand Up @@ -74,6 +75,28 @@ <h2>Hotkeys</h2>
</div>
</section>

<section class="panel" id="tab-chat">
<div class="chat-layout">
<aside class="card chat-threads">
<div class="card-actions">
<button class="btn btn-primary" id="chat-new">+ New chat</button>
</div>
<ul class="thread-list" id="chat-thread-list"></ul>
<p class="muted small" id="chat-threads-empty" hidden>No conversations yet.</p>
</aside>
<div class="card chat-main">
<div class="chat-transcript" id="chat-transcript" aria-live="polite"></div>
<p class="muted small" id="chat-placeholder">Start a conversation below. Replies run on your local model — nothing leaves this machine.</p>
<label class="check-row"><input type="checkbox" id="chat-use-notes"> 📚 Ground answers in my notes</label>
<div class="form-row chat-input-row">
<textarea id="chat-input" rows="3" placeholder="Type a message… (Ctrl+Enter to send)"></textarea>
<button class="btn btn-primary" id="chat-send">Send</button>
</div>
<span class="muted small" id="chat-status"></span>
</div>
</div>
</section>

<section class="panel" id="tab-telemetry">
<div class="grid">
<div class="card card-wide">
Expand Down
42 changes: 42 additions & 0 deletions scripts/ui/web/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,45 @@ footer { padding: 14px 24px 22px; }
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background-color: var(--accent); background-clip: content-box; }

/* ---- Chat tab ---- */
.chat-layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 720px) { .chat-layout { grid-template-columns: 1fr; } }

.chat-threads { min-height: 320px; }
.thread-list { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.thread-item {
display: flex; align-items: center; gap: 4px;
border-radius: 8px; padding: 2px;
}
.thread-item.active { background: var(--accent-soft); }
.thread-open {
flex: 1; text-align: left; background: none; border: none; color: var(--text);
font: inherit; padding: 6px 8px; border-radius: 6px; cursor: pointer;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.thread-open:hover { background: var(--accent-soft); }
.thread-del {
background: none; border: none; color: var(--text-muted); cursor: pointer;
padding: 4px 6px; border-radius: 6px; font-size: 13px;
}
.thread-del:hover { color: var(--warn); }

.chat-main { display: flex; flex-direction: column; min-height: 420px; }
.chat-transcript {
flex: 1; min-height: 260px; max-height: 56vh; overflow-y: auto;
display: flex; flex-direction: column; gap: 10px; padding: 4px 2px 10px;
}
.chat-msg {
max-width: 82%; padding: 9px 13px; border-radius: 14px;
white-space: pre-wrap; word-wrap: break-word; line-height: 1.45;
}
.chat-msg-user { align-self: flex-end; background: var(--grad); color: #fff; border-bottom-right-radius: 5px; }
.chat-msg-assistant { align-self: flex-start; background: var(--surface); border: 1px solid var(--border); border-bottom-left-radius: 5px; }
.chat-input-row { align-items: stretch; margin-top: 8px; }
.chat-input-row textarea { flex: 1; resize: vertical; font: inherit; }