Skip to content

Commit 7f91537

Browse files
committed
feat: rewrite homescreen client with AI tools accordion state machine
1 parent b984692 commit 7f91537

1 file changed

Lines changed: 360 additions & 15 deletions

File tree

src/webview/client/homescreen.ts

Lines changed: 360 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,365 @@
1-
/**
2-
* Homescreen webview client-side script.
3-
* Wires up button event listeners and posts messages to the extension host.
4-
*/
5-
61
import { initCommon, getVSCode } from "./common";
72

8-
function postMessage(command: string): void {
9-
getVSCode()?.postMessage({ command });
3+
// ── Types (mirrored from aiToolsService — no import possible in webview client) ──
4+
5+
interface SkillInfo {
6+
name: string;
7+
description: string;
8+
dirName: string;
9+
}
10+
11+
interface McpServerInfo {
12+
key: string;
13+
label: string;
14+
description: string;
15+
}
16+
17+
interface AiToolsDataMessage {
18+
command: "aiToolsData";
19+
skills: SkillInfo[];
20+
installedByIde: Record<string, string[]>; // ideLabel → array of dirNames
21+
mcpServers: McpServerInfo[];
22+
configuredMcpKeys: string[];
23+
detectedIde: string;
24+
error?: string;
25+
}
26+
27+
interface AiToolsProgressMessage {
28+
command: "aiToolsProgress";
29+
item: string; // skill dirName or MCP key
30+
status: "done" | "error";
31+
}
32+
33+
interface AiToolsResultMessage {
34+
command: "aiToolsResult";
35+
errors: string[];
36+
}
37+
38+
type InboundMessage = AiToolsDataMessage | AiToolsProgressMessage | AiToolsResultMessage;
39+
40+
// ── Module state ──────────────────────────────────────────────────────────────
41+
42+
let _isOpen = false;
43+
let _dataFetched = false;
44+
let _cachedData: Omit<AiToolsDataMessage, "command"> | null = null;
45+
let _activeIde = "Claude Code";
46+
47+
// ── DOM helpers ───────────────────────────────────────────────────────────────
48+
49+
function el<T extends HTMLElement>(id: string): T {
50+
return document.getElementById(id) as T;
51+
}
52+
53+
function show(id: string): void {
54+
el(id).classList.remove("hidden");
55+
}
56+
57+
function hide(id: string): void {
58+
el(id).classList.add("hidden");
1059
}
1160

12-
initCommon();
61+
// ── State rendering ───────────────────────────────────────────────────────────
62+
63+
function showPanelState(state: "loading" | "ready" | "done" | "error"): void {
64+
for (const s of ["loading", "ready", "done", "error"] as const) {
65+
const elem = el(`hs-ai-state-${s}`);
66+
if (elem) {
67+
elem.classList.toggle("hidden", s !== state);
68+
}
69+
}
70+
}
71+
72+
// ── IDE pill ──────────────────────────────────────────────────────────────────
73+
74+
function movePill(btn: HTMLElement): void {
75+
const pill = el<HTMLElement>("hs-ai-ide-pill");
76+
if (!pill) { return; }
77+
pill.style.left = btn.offsetLeft + "px";
78+
pill.style.width = btn.offsetWidth + "px";
79+
}
80+
81+
function initPill(): void {
82+
const activeBtn = document.querySelector<HTMLElement>(".hs-ai-ide-btn.active");
83+
if (activeBtn) { movePill(activeBtn); }
84+
}
85+
86+
// ── Checklist rendering ───────────────────────────────────────────────────────
87+
88+
function renderSkillRows(
89+
skills: SkillInfo[],
90+
installedDirNames: string[]
91+
): void {
92+
const list = el("hs-ai-skills-list");
93+
if (!list) { return; }
94+
const installedSet = new Set(installedDirNames);
95+
list.innerHTML = skills
96+
.map((s) => {
97+
const isInstalled = installedSet.has(s.dirName);
98+
const statusClass = isInstalled ? "hs-ai-item-status--ok" : "hs-ai-item-status--none";
99+
const statusText = isInstalled ? "installed" : "—";
100+
return `<label class="hs-ai-item">
101+
<input type="checkbox" class="hs-ai-cb" data-skill="${s.dirName}" ${isInstalled ? "" : "checked"}>
102+
<span class="hs-ai-item-name" title="${s.description}">${s.name}</span>
103+
<span class="hs-ai-item-status ${statusClass}">${statusText}</span>
104+
</label>`;
105+
})
106+
.join("");
107+
list.querySelectorAll<HTMLInputElement>(".hs-ai-cb").forEach((cb) => {
108+
cb.addEventListener("change", updateApplyButton);
109+
});
110+
}
111+
112+
function renderMcpRows(
113+
servers: McpServerInfo[],
114+
configuredKeys: string[]
115+
): void {
116+
const list = el("hs-ai-mcp-list");
117+
if (!list) { return; }
118+
const configuredSet = new Set(configuredKeys);
119+
list.innerHTML = servers
120+
.map((s) => {
121+
const isConfigured = configuredSet.has(s.key);
122+
const statusClass = isConfigured ? "hs-ai-item-status--ok" : "hs-ai-item-status--none";
123+
const statusText = isConfigured ? "configured" : "—";
124+
return `<label class="hs-ai-item">
125+
<input type="checkbox" class="hs-ai-cb" data-mcp="${s.key}" ${isConfigured ? "" : "checked"}>
126+
<span class="hs-ai-item-name" title="${s.description}">${s.label}</span>
127+
<span class="hs-ai-item-status ${statusClass}">${statusText}</span>
128+
</label>`;
129+
})
130+
.join("");
131+
list.querySelectorAll<HTMLInputElement>(".hs-ai-cb").forEach((cb) => {
132+
cb.addEventListener("change", updateApplyButton);
133+
});
134+
}
135+
136+
function updateApplyButton(): void {
137+
const applyBtn = el<HTMLButtonElement>("hs-ai-apply");
138+
if (!applyBtn) { return; }
139+
const anyChecked = [...document.querySelectorAll<HTMLInputElement>(".hs-ai-cb")]
140+
.some((c) => c.checked && !c.disabled);
141+
applyBtn.disabled = !anyChecked;
142+
}
143+
144+
// ── Accordion toggle ──────────────────────────────────────────────────────────
145+
146+
function toggleAccordion(): void {
147+
_isOpen = !_isOpen;
148+
149+
const panel = el("hs-ai-panel");
150+
const btn = el("hs-btn-ai-tools");
151+
const chevron = el("hs-ai-chevron");
152+
153+
panel.classList.toggle("open", _isOpen);
154+
btn.classList.toggle("expanded", _isOpen);
155+
btn.setAttribute("aria-expanded", String(_isOpen));
156+
chevron.classList.toggle("hs-chevron--open", _isOpen);
157+
158+
if (_isOpen && !_dataFetched) {
159+
_dataFetched = true;
160+
showPanelState("loading");
161+
getVSCode()?.postMessage({ command: "aiToolsExpanded" });
162+
}
163+
164+
if (_isOpen) {
165+
// Re-position pill after layout settles (accordion may have just opened)
166+
panel.addEventListener("transitionend", () => {
167+
initPill();
168+
}, { once: true });
169+
}
170+
}
171+
172+
// ── Apply ─────────────────────────────────────────────────────────────────────
173+
174+
function handleApply(): void {
175+
if (!_cachedData) { return; }
176+
177+
const skillCheckboxes = document.querySelectorAll<HTMLInputElement>(".hs-ai-cb[data-skill]");
178+
const mcpCheckboxes = document.querySelectorAll<HTMLInputElement>(".hs-ai-cb[data-mcp]");
179+
180+
const selectedSkills = [...skillCheckboxes]
181+
.filter((c) => c.checked)
182+
.map((c) => c.dataset.skill!);
183+
184+
const selectedMcpKeys = [...mcpCheckboxes]
185+
.filter((c) => c.checked)
186+
.map((c) => c.dataset.mcp!);
187+
188+
// Switch to applying visual: disable checkboxes and apply button
189+
document.querySelectorAll<HTMLInputElement>(".hs-ai-cb").forEach((c) => { c.disabled = true; });
190+
const applyBtn = el<HTMLButtonElement>("hs-ai-apply");
191+
if (applyBtn) {
192+
applyBtn.disabled = true;
193+
applyBtn.textContent = "Applying…";
194+
}
195+
196+
getVSCode()?.postMessage({
197+
command: "installAiTools",
198+
skills: selectedSkills,
199+
ideTarget: _activeIde,
200+
mcpServers: selectedMcpKeys,
201+
});
202+
}
203+
204+
// ── Message handling ──────────────────────────────────────────────────────────
205+
206+
function handleAiToolsData(msg: AiToolsDataMessage): void {
207+
if (msg.error) {
208+
el("hs-ai-error-msg").textContent = msg.error;
209+
showPanelState("error");
210+
return;
211+
}
212+
213+
_cachedData = {
214+
skills: msg.skills,
215+
installedByIde: msg.installedByIde,
216+
mcpServers: msg.mcpServers,
217+
configuredMcpKeys: msg.configuredMcpKeys,
218+
detectedIde: msg.detectedIde,
219+
};
220+
221+
// Set active IDE to detected editor
222+
_activeIde = msg.detectedIde;
223+
document.querySelectorAll<HTMLElement>(".hs-ai-ide-btn").forEach((btn) => {
224+
const isActive = btn.dataset.ide === _activeIde;
225+
btn.classList.toggle("active", isActive);
226+
});
227+
228+
renderSkillRows(msg.skills, msg.installedByIde[_activeIde] ?? []);
229+
renderMcpRows(msg.mcpServers, msg.configuredMcpKeys);
230+
231+
showPanelState("ready");
232+
updateApplyButton();
233+
234+
requestAnimationFrame(() => { initPill(); });
235+
}
236+
237+
function handleAiToolsProgress(msg: AiToolsProgressMessage): void {
238+
// Find the row by data-skill or data-mcp attribute
239+
const cb = document.querySelector<HTMLInputElement>(
240+
`[data-skill="${msg.item}"], [data-mcp="${msg.item}"]`
241+
);
242+
if (!cb) { return; }
243+
244+
const row = cb.closest(".hs-ai-item");
245+
if (!row) { return; }
246+
247+
// Remove existing tick if any
248+
row.querySelector(".hs-ai-item-tick")?.remove();
249+
250+
const tick = document.createElement("span");
251+
tick.className = `hs-ai-item-tick hs-ai-item-tick--${msg.status === "done" ? "ok" : "err"}`;
252+
tick.textContent = msg.status === "done" ? "✓" : "✕";
253+
row.appendChild(tick);
254+
}
255+
256+
function handleAiToolsResult(msg: AiToolsResultMessage): void {
257+
// Force a re-fetch next time the panel opens so installed state is fresh
258+
_dataFetched = false;
259+
_cachedData = null;
260+
261+
// Build done state: collect rows with ticks and show them
262+
const doneSkillsDiv = el("hs-ai-done-skills-list");
263+
const doneMcpDiv = el("hs-ai-done-mcp-list");
264+
265+
if (doneSkillsDiv) {
266+
const rows = document.querySelectorAll<HTMLElement>("#hs-ai-skills-list .hs-ai-item");
267+
doneSkillsDiv.innerHTML = "";
268+
rows.forEach((row) => {
269+
const tick = row.querySelector(".hs-ai-item-tick");
270+
if (!tick) { return; }
271+
const name = row.querySelector(".hs-ai-item-name")?.textContent ?? "";
272+
const isOk = tick.classList.contains("hs-ai-item-tick--ok");
273+
const statusClass = isOk ? "hs-ai-item-status--ok" : "hs-ai-item-status--none";
274+
const statusText = isOk ? "installed" : "error";
275+
doneSkillsDiv.insertAdjacentHTML(
276+
"beforeend",
277+
`<div class="hs-ai-item">
278+
<span class="hs-ai-item-tick hs-ai-item-tick--${isOk ? "ok" : "err"}">${isOk ? "✓" : "✕"}</span>
279+
<span class="hs-ai-item-name">${name}</span>
280+
<span class="hs-ai-item-status ${statusClass}">${statusText}</span>
281+
</div>`
282+
);
283+
});
284+
}
285+
286+
if (doneMcpDiv) {
287+
const rows = document.querySelectorAll<HTMLElement>("#hs-ai-mcp-list .hs-ai-item");
288+
doneMcpDiv.innerHTML = "";
289+
rows.forEach((row) => {
290+
const tick = row.querySelector(".hs-ai-item-tick");
291+
if (!tick) { return; }
292+
const name = row.querySelector(".hs-ai-item-name")?.textContent ?? "";
293+
const isOk = tick.classList.contains("hs-ai-item-tick--ok");
294+
const statusClass = isOk ? "hs-ai-item-status--ok" : "hs-ai-item-status--none";
295+
const statusText = isOk ? "configured" : "error";
296+
doneMcpDiv.insertAdjacentHTML(
297+
"beforeend",
298+
`<div class="hs-ai-item">
299+
<span class="hs-ai-item-tick hs-ai-item-tick--${isOk ? "ok" : "err"}">${isOk ? "✓" : "✕"}</span>
300+
<span class="hs-ai-item-name">${name}</span>
301+
<span class="hs-ai-item-status ${statusClass}">${statusText}</span>
302+
</div>`
303+
);
304+
});
305+
}
306+
307+
showPanelState("done");
308+
}
309+
310+
// ── Init ──────────────────────────────────────────────────────────────────────
311+
312+
function init(): void {
313+
initCommon();
314+
315+
// Standard action buttons (non-accordion)
316+
document.querySelectorAll<HTMLElement>(".hs-action:not(#hs-btn-ai-tools)").forEach((btn) => {
317+
btn.addEventListener("click", () => {
318+
getVSCode()?.postMessage({ command: btn.dataset.command });
319+
});
320+
});
321+
322+
// Accordion toggle
323+
el("hs-btn-ai-tools").addEventListener("click", toggleAccordion);
324+
325+
// IDE selector buttons
326+
document.querySelectorAll<HTMLElement>(".hs-ai-ide-btn").forEach((btn) => {
327+
btn.addEventListener("click", () => {
328+
if (!_cachedData) { return; }
329+
document.querySelectorAll<HTMLElement>(".hs-ai-ide-btn").forEach((b) => b.classList.remove("active"));
330+
btn.classList.add("active");
331+
movePill(btn);
332+
_activeIde = btn.dataset.ide ?? "Claude Code";
333+
renderSkillRows(_cachedData.skills, _cachedData.installedByIde[_activeIde] ?? []);
334+
updateApplyButton();
335+
});
336+
});
337+
338+
// Apply button
339+
el<HTMLButtonElement>("hs-ai-apply")?.addEventListener("click", handleApply);
340+
341+
// Apply again button (done state)
342+
el<HTMLButtonElement>("hs-ai-apply-again")?.addEventListener("click", () => {
343+
// Re-open: reset state and re-fetch
344+
_isOpen = false;
345+
toggleAccordion();
346+
});
347+
348+
// VS Code → webview messages
349+
window.addEventListener("message", (event: MessageEvent<InboundMessage>) => {
350+
const msg = event.data;
351+
switch (msg.command) {
352+
case "aiToolsData":
353+
handleAiToolsData(msg as AiToolsDataMessage);
354+
break;
355+
case "aiToolsProgress":
356+
handleAiToolsProgress(msg as AiToolsProgressMessage);
357+
break;
358+
case "aiToolsResult":
359+
handleAiToolsResult(msg as AiToolsResultMessage);
360+
break;
361+
}
362+
});
363+
}
13364

14-
document.addEventListener("DOMContentLoaded", () => {
15-
document.getElementById("hs-btn-configure")?.addEventListener("click", () => postMessage("openGlobalConfig"));
16-
document.getElementById("hs-btn-library")?.addEventListener("click", () => postMessage("showLibrary"));
17-
document.getElementById("hs-btn-upload")?.addEventListener("click", () => postMessage("openUploadWidget"));
18-
document.getElementById("hs-link-welcome")?.addEventListener("click", () => postMessage("openWelcomeScreen"));
19-
document.getElementById("hs-btn-ai-tools")?.addEventListener("click", () => postMessage("configureAiTools"));
20-
});
365+
document.addEventListener("DOMContentLoaded", init);

0 commit comments

Comments
 (0)