From bdd6e8b386421777330fc9d9b2f8011e3e8cb157 Mon Sep 17 00:00:00 2001 From: moltenhub-bot Date: Mon, 11 May 2026 09:35:09 -0700 Subject: [PATCH] chore: Add a close button for active projects in chat; when there is Co-authored-by: Molten Bot 000 <260473928+moltenbot000@users.noreply.github.com> --- internal/web/server_test.go | 13 ++++- internal/web/static/index.html | 90 +++++++++++++++++++++++++++++++++- internal/web/static/style.css | 31 +++++++++++- 3 files changed, 130 insertions(+), 4 deletions(-) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 662fd196..b367aaf8 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -1730,13 +1730,19 @@ func TestHandlerIndexServesHTML(t *testing.T) { if !strings.Contains(markup, `id="chat-repo-tabs"`) || !strings.Contains(markup, `function chatPromptedRepoTabs()`) || !strings.Contains(markup, `state.snapshot.prompted_repos`) || + !strings.Contains(markup, `chatClosedRepoKeys: loadChatClosedRepoKeys(),`) || + !strings.Contains(markup, `function canCloseChatProject(repoKey)`) || + !strings.Contains(markup, `const canCloseProject = canCloseChatProject(repoKey);`) || + !strings.Contains(markup, `closeButton.className = "chat-repo-card-close";`) || + !strings.Contains(markup, `if (state.chatClosedRepoKeys.has(key) && !activeKeys.has(key)) continue;`) || + !strings.Contains(markup, `state.chatClosedRepoKeys.delete(chatRepoKey(repoValue));`) || !strings.Contains(markup, `reposIcon.innerHTML = `+"`"+``+"`"+`;`) || !strings.Contains(markup, `reposLabel.textContent = "All repositories";`) || !strings.Contains(markup, `function selectedChatRepo(repos, selectedTab)`) || !strings.Contains(markup, `chatRepoGrid.classList.toggle("chat-repo-grid-active-repo", Boolean(openRepo));`) || !strings.Contains(markup, `const viewingRepoChat = Boolean(selectedTab && openRepo);`) || !strings.Contains(markup, `displayRepos = [openRepo];`) { - t.Fatalf("expected index html chat to render prompted repository tabs, icon-only repos tab, and isolate active repository chat") + t.Fatalf("expected index html chat to render closable prompted repository tabs, icon-only repos tab, and isolate active repository chat") } if !strings.Contains(markup, `if (viewingRepoChat) { chatStatus.textContent = "";`) { @@ -2902,6 +2908,11 @@ func TestHandlerServesStaticCSS(t *testing.T) { if !strings.Contains(css, ".chat-repo-card[aria-expanded=\"true\"] {\n grid-column: 1 / -1;") { t.Fatalf("expected expanded chat repository cards to span the full repository grid width") } + if !strings.Contains(css, ".chat-repo-card-actions") || + !strings.Contains(css, ".chat-repo-card-close") || + !strings.Contains(css, ".chat-repo-card-closable .chat-repo-card-head") { + t.Fatalf("expected stylesheet to place active chat project close button beside the chat icon") + } if !strings.Contains(css, ".chat-shell {\n display: flex;\n flex-direction: column;") || !strings.Contains(css, ".chat-repo-grid-active-repo {\n flex: 1 1 auto;") || !strings.Contains(css, ".chat-repo-grid-active-repo .chat-repo-card[aria-expanded=\"true\"] {") || diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 33f50cc0..26bebab5 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -685,6 +685,7 @@

Connect to Hub

const TASK_HISTORY_KEY = "hubui.taskHistory.v1"; const TASK_HISTORY_UNSEEN_KEY = "hubui.taskHistoryUnseen.v1"; const TASK_SOUND_MUTED_KEY = "hubui.taskSoundMuted"; + const CHAT_CLOSED_REPOS_KEY = "hubui.chatClosedRepoKeys.v1"; const THEME_KEY = "hubui.theme"; const HOVER_MODE_KEY = "hubui.hoverMode"; const THEME_MODES = ["light", "dark", "night", "pink"]; @@ -1662,6 +1663,20 @@

Connect to Hub

} } + function loadChatClosedRepoKeys() { + try { + const raw = localStorage.getItem(CHAT_CLOSED_REPOS_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed + .map((key) => String(key || "").trim().toLowerCase()) + .filter(Boolean)); + } catch (_err) { + return new Set(); + } + } + function loadTaskSoundMuted() { try { return localStorage.getItem(TASK_SOUND_MUTED_KEY) === "true"; @@ -1740,6 +1755,7 @@

Connect to Hub

chatPromptDrafts: new Map(), chatOpenRepoKey: "", chatPromptedRepos: [], + chatClosedRepoKeys: loadChatClosedRepoKeys(), chatRepoActivityKeys: new Set(), chatRepoActivityTones: new Map(), chatActiveRepoTab: "repos", @@ -5318,6 +5334,14 @@

Connect to Hub

} } + function persistChatClosedRepoKeys() { + try { + localStorage.setItem(CHAT_CLOSED_REPOS_KEY, JSON.stringify(Array.from(state.chatClosedRepoKeys))); + } catch (_err) { + // Ignore localStorage failures. + } + } + function rememberRepos(repos) { const unique = dedupeRepoValues([...(repos || []), ...state.repoHistory]); if (unique.length === state.repoHistory.length && unique.every((value, index) => value === state.repoHistory[index])) { @@ -5984,14 +6008,56 @@

Connect to Hub

return Boolean(control && control !== card); } + function activeChatRepoKeys() { + const keys = new Set(); + for (const task of state.snapshot?.tasks || []) { + if (isCompletedTask(task)) continue; + for (const key of chatRepoKeysForTask(task)) { + keys.add(key); + } + } + return keys; + } + + function chatRepoHasActiveWork(repoKey) { + const key = String(repoKey || "").trim(); + return Boolean(key && activeChatRepoKeys().has(key)); + } + + function canCloseChatProject(repoKey) { + const key = String(repoKey || "").trim(); + return Boolean(key && state.chatActiveRepoTab === key && !chatRepoHasActiveWork(key)); + } + + function closeChatProject(repoKey) { + const key = String(repoKey || "").trim(); + if (!key || chatRepoHasActiveWork(key)) return false; + state.chatClosedRepoKeys.add(key); + persistChatClosedRepoKeys(); + state.chatPromptedRepos = state.chatPromptedRepos.filter((repo) => chatRepoKey(repo) !== key); + clearChatRepoActivity(key); + if (state.chatActiveRepoTab === key) { + state.chatActiveRepoTab = "repos"; + } + if (state.chatOpenRepoKey === key) { + state.chatOpenRepoKey = ""; + } + state.chatRepoPage = 1; + renderChatRepos(); + trackAnalyticsEvent("chat_project_closed", { source: "chat" }); + return true; + } + function renderChatRepoCard(repo) { const repoKey = chatRepoKey(chatRepoRunValue(repo)); const expanded = Boolean(repoKey && state.chatOpenRepoKey === repoKey); + const canCloseProject = canCloseChatProject(repoKey); const visibility = repo?.private ? "Private" : "Public"; const repoTitle = String(repo?.full_name || repo?.name || "Unnamed repository"); const repoDescription = String(repo?.description || "No description."); const card = document.createElement("div"); card.className = "chat-repo-card"; + card.classList.toggle("chat-repo-card-closable", canCloseProject); card.tabIndex = 0; card.setAttribute("role", "button"); card.dataset.repoKey = repoKey; @@ -6016,12 +6082,30 @@

Connect to Hub

} head.appendChild(title); + const actions = document.createElement("span"); + actions.className = "chat-repo-card-actions"; + if (canCloseProject) { + const closeButton = document.createElement("button"); + closeButton.className = "chat-repo-card-close"; + closeButton.type = "button"; + closeButton.title = "Close project"; + closeButton.setAttribute("aria-label", `Close ${repoTitle} project`); + closeButton.innerHTML = ``; + closeButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + closeChatProject(repoKey); + }); + actions.appendChild(closeButton); + } + const chatIcon = document.createElement("span"); chatIcon.className = "chat-repo-card-chat-icon"; chatIcon.title = "Prompt"; chatIcon.setAttribute("aria-hidden", "true"); chatIcon.innerHTML = ``; - head.appendChild(chatIcon); + actions.appendChild(chatIcon); + head.appendChild(actions); card.appendChild(head); const description = document.createElement("span"); @@ -6793,11 +6877,13 @@

Connect to Hub

} repos.push(...state.chatPromptedRepos); + const activeKeys = activeChatRepoKeys(); const seen = new Set(); const out = []; for (const repo of repos) { const key = chatRepoKey(repo); if (!key || seen.has(key)) continue; + if (state.chatClosedRepoKeys.has(key) && !activeKeys.has(key)) continue; seen.add(key); out.push({ key, value: repo, label: chatRepoTabLabel(repo) }); } @@ -7171,6 +7257,8 @@

Connect to Hub

source: "chat", sourceLabel: "Chat", }); + state.chatClosedRepoKeys.delete(chatRepoKey(repoValue)); + persistChatClosedRepoKeys(); state.chatPromptedRepos = dedupeRepoValues([repoValue, ...state.chatPromptedRepos]); state.chatActiveRepoTab = chatRepoKey(repoValue) || state.chatActiveRepoTab; state.chatRepoPage = 1; diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 04714dc4..38cc5dfb 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -1577,6 +1577,7 @@ html.pink .hub-dock-group[data-configured="true"] #moltenbot-hub-link:focus-visi .task-sound-toggle:focus-visible, .task-history-toggle:focus-visible, .task-close:focus-visible, +.chat-repo-card-close:focus-visible, .task-rerun:focus-visible, .task-control-toggle:focus-visible, .task-stop:focus-visible, @@ -3106,6 +3107,10 @@ select.prompt-control { padding-right: 34px; } +.chat-repo-card-closable .chat-repo-card-head { + padding-right: 70px; +} + .chat-repo-card-owner-icon { flex: 0 0 auto; display: inline-grid; @@ -3132,10 +3137,17 @@ select.prompt-control { line-height: 1.25; } -.chat-repo-card-chat-icon { +.chat-repo-card-actions { position: absolute; top: 10px; right: 10px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.chat-repo-card-chat-icon, +.chat-repo-card-close { display: inline-grid; place-items: center; width: 28px; @@ -3147,7 +3159,19 @@ select.prompt-control { box-shadow: var(--surface-button-shadow); } -.chat-repo-card-chat-icon svg { +.chat-repo-card-close { + color: var(--text-soft); +} + +.chat-repo-card-close:hover, +.chat-repo-card-close:focus-visible { + border-color: color-mix(in srgb, var(--surface-danger) 36%, var(--border)); + background: color-mix(in srgb, var(--surface-danger) 10%, var(--surface-button-bg)); + color: color-mix(in srgb, var(--surface-danger) 72%, var(--text-soft)); +} + +.chat-repo-card-chat-icon svg, +.chat-repo-card-close svg { width: 15px; height: 15px; stroke-width: 2.25; @@ -6516,6 +6540,7 @@ html[data-hover-mode="on"] .task-view-toggle:not(:disabled), html[data-hover-mode="on"] .task-fullscreen-toggle:not(:disabled), html[data-hover-mode="on"] .prompt-visibility-toggle:not(:disabled), html[data-hover-mode="on"] .task-close:not(:disabled), +html[data-hover-mode="on"] .chat-repo-card-close:not(:disabled), html[data-hover-mode="on"] .task-control-toggle:not(:disabled), html[data-hover-mode="on"] .task-stop:not(:disabled), html[data-hover-mode="on"] .task:not(.task-closing) { @@ -6552,6 +6577,7 @@ html[data-hover-mode="on"] .task-fullscreen-toggle:not(:disabled) { html[data-hover-mode="off"] .task, html[data-hover-mode="off"] .task-close, +html[data-hover-mode="off"] .chat-repo-card-close, html[data-hover-mode="off"] .task-control-toggle, html[data-hover-mode="off"] .task-stop, html[data-hover-mode="off"] .prompt-action-button, @@ -6568,6 +6594,7 @@ html[data-hover-mode="off"] .prompt-visibility-toggle { html[data-hover-mode="off"] .task:hover, html[data-hover-mode="off"] .task-close:hover, +html[data-hover-mode="off"] .chat-repo-card-close:hover, html[data-hover-mode="off"] .task-control-toggle:hover, html[data-hover-mode="off"] .task-stop:hover, html[data-hover-mode="off"] .prompt-action-button:hover,