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
13 changes: 12 additions & 1 deletion internal/web/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `+"`"+`<i data-lucide="brick-wall" aria-hidden="true"></i>`+"`"+`;`) ||
!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 = "";`) {
Expand Down Expand Up @@ -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\"] {") ||
Expand Down
90 changes: 89 additions & 1 deletion internal/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,7 @@ <h2 id="hub-setup-title">Connect to Hub</h2>
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"];
Expand Down Expand Up @@ -1662,6 +1663,20 @@ <h2 id="hub-setup-title">Connect to Hub</h2>
}
}

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";
Expand Down Expand Up @@ -1740,6 +1755,7 @@ <h2 id="hub-setup-title">Connect to Hub</h2>
chatPromptDrafts: new Map(),
chatOpenRepoKey: "",
chatPromptedRepos: [],
chatClosedRepoKeys: loadChatClosedRepoKeys(),
chatRepoActivityKeys: new Set(),
chatRepoActivityTones: new Map(),
chatActiveRepoTab: "repos",
Expand Down Expand Up @@ -5318,6 +5334,14 @@ <h2 id="hub-setup-title">Connect to Hub</h2>
}
}

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])) {
Expand Down Expand Up @@ -5984,14 +6008,56 @@ <h2 id="hub-setup-title">Connect to Hub</h2>
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;
Expand All @@ -6016,12 +6082,30 @@ <h2 id="hub-setup-title">Connect to Hub</h2>
}
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 = `<i data-lucide="x" aria-hidden="true"></i>`;
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 = `<i data-lucide="message-circle" aria-hidden="true"></i>`;
head.appendChild(chatIcon);
actions.appendChild(chatIcon);
head.appendChild(actions);
card.appendChild(head);

const description = document.createElement("span");
Expand Down Expand Up @@ -6793,11 +6877,13 @@ <h2 id="hub-setup-title">Connect to Hub</h2>
}
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) });
}
Expand Down Expand Up @@ -7171,6 +7257,8 @@ <h2 id="hub-setup-title">Connect to Hub</h2>
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;
Expand Down
31 changes: 29 additions & 2 deletions internal/web/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down