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,