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
78 changes: 71 additions & 7 deletions desktop/renderer/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,19 @@ function relTime(ms: number): string {
const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
// #11 perceived-latency: a few placeholder session cards painted INSTANTLY so the
// sidebar never looks empty/broken while /api/sessions is in flight.
function sessSkeleton(): string {
return `<div class="skel-group">${Array.from({ length: 5 }, () =>
`<div class="skel-sess"><div class="skel skel-line t"></div><div class="skel skel-line m"></div></div>`).join("")}</div>`;
}
async function renderSessions(): Promise<void> {
const sessions = await bridge.sessions().catch(() => null);
const list = $("#sessList");
if (!list) return;
// Show the skeleton only on a cold list (first load). On a re-render after sending a
// prompt the list already has content β€” don't flash it back to skeleton.
if (!list.firstElementChild || $(".skel-group", list)) list.innerHTML = sessSkeleton();
const sessions = await bridge.sessions().catch(() => null);
if (sessions === null) { list.innerHTML = `<div class="side-empty">Couldn't load history - the GUI server looks out of date. Relaunch it (launcher β†’ <b>G</b>), or restart <code>bun run desktop:web</code>.</div>`; return; }
if (!sessions.length) { list.innerHTML = `<div class="side-empty">No sessions yet - send a prompt to start one. They persist here across runs.</div>`; return; }
list.innerHTML = sessions.map((s, i) => `
Expand Down Expand Up @@ -443,7 +452,24 @@ function focusInspector(tab: Tab): void {
lastInspHash = ""; renderInspector();
}

// #11 perceived-latency: placeholder chips + rows shown on the inspector's very first
// paint, before the first 4s poll completes (state.lastOk === 0). Swaps to real content
// β€” or the genuine empty-state β€” the moment refresh() lands or fails.
function inspSkeleton(): string {
return `<div class="skel-chips">${Array.from({ length: 4 }, () => `<div class="skel skel-chip"></div>`).join("")}</div>`
+ `<div class="skel-group">${Array.from({ length: 5 }, () => `<div class="skel skel-row"></div>`).join("")}</div>`;
}
function renderInspector(): void {
const snap = state.inspectorTab === "security" ? state.security : state.memory;
// First-load only: no successful poll yet AND no snapshot for this tab.
if (state.lastOk === 0 && snap === null) {
const body = $("#inspBody")!;
const skelHash = "skel:" + state.inspectorTab;
if (skelHash === lastInspHash) return;
lastInspHash = skelHash;
body.innerHTML = inspSkeleton();
return;
}
const html = state.inspectorTab === "security" ? securityHtml(state.security) : memoryHtml(state.memory);
const hash = state.inspectorTab + html.length + html.slice(0, 64);
if (hash === lastInspHash) return;
Expand Down Expand Up @@ -766,12 +792,20 @@ async function renderKnowledge(): Promise<void> {
const canvas = $("#kgCanvas"), side = $("#kgSide"), scopeLbl = $("#kgScopeLbl");
if (!canvas || !side) return;
kgHandle?.destroy(); kgHandle = null;
const status = await bridge.personal();
// #11 perceived-latency: the graph store is encrypted, so personal()/personalGraph()
// can take a beat to decrypt. Paint a calm "Decrypting…" state INSTANTLY; the gate()
// / mountGraph below always replaces it (and the catch() guarantees no stuck shimmer).
canvas.innerHTML = `<div class="skel-kg">${icon("refresh", 26, "spin")}<div>Decrypting your graph…</div></div>`;
side.innerHTML = "";
let status: Awaited<ReturnType<typeof bridge.personal>>;
try { status = await bridge.personal(); }
catch { canvas.innerHTML = `<div class="kg-empty">${icon("graph", 30)}<div>Couldn't load your graph. Try reopening this panel.</div></div>`; return; }
if (scopeLbl) scopeLbl.textContent = status?.scope ? `Β· ${status.scope}` : "";
const gate = (msg: string) => { canvas.innerHTML = `<div class="kg-empty">${icon("graph", 30)}<div>${msg}</div></div>`; side.innerHTML = ""; };
if (!status?.enabled) return gate("Personalization is off. Enable it in Settings to build a knowledge graph.");
if (!status.unlocked) return gate("Your store is locked. Unlock it in Settings to view the graph.");
kgData = await bridge.personalGraph();
try { kgData = await bridge.personalGraph(); }
catch { return gate("Couldn't decrypt your graph. Try reopening this panel."); }
if (!kgData || kgData.nodes.length === 0) return gate("Nothing learned yet. It remembers durable facts about <b>you</b> - not what we discuss. Tell me things like <i>β€œI prefer Rust”</i>, <i>β€œI use vim”</i>, <i>β€œI decided to go with Postgres”</i>, or <i>β€œremember that I deploy with Kubernetes”</i> and they'll appear here (each is security-scanned first).");
side.innerHTML = `<div class="kg-side-empty">${icon("eye", 22)}<div>Click a node to see its facts.</div></div>`;
kgHandle = mountGraph(canvas as HTMLElement, kgData, (id) => renderKgSide(id));
Expand Down Expand Up @@ -825,7 +859,15 @@ function renderWorkspaceBar(): void {
bar.innerHTML = `${icon(w.isGit ? "git" : "folder", 14)}<span class="ws-bar-name">${esc(w.name)}</span>${icon("sliders", 12, "dim")}`;
}
async function loadWorkspace(): Promise<void> {
state.workspace = await bridge.workspace();
// #11 perceived-latency: the bar used to stay hidden until workspace() resolved, then
// pop in. Show a subtle "loading workspace…" pill instantly; renderWorkspaceBar() below
// always replaces it (even on a null/failed result, which hides the bar as before).
const bar = $("#wsBar") as HTMLButtonElement | null;
if (bar && !state.workspace) {
bar.hidden = false;
bar.innerHTML = `<span class="ws-bar-loading">${icon("refresh", 12, "spin")}loading workspace…</span>`;
}
state.workspace = await bridge.workspace().catch(() => null);
renderWorkspaceBar();
}
async function resumeSession(id: string): Promise<void> {
Expand All @@ -840,8 +882,15 @@ async function resumeSession(id: string): Promise<void> {
}

async function applyWorkspace(path: string): Promise<void> {
// #11 perceived-latency: setWorkspace() respawns the backend (2–5s). Reassure the user
// up front that work is happening, then confirm when it's ready, and reflect the switch
// immediately on the workspace bar via a "loading…" pill.
showToast({ title: "Switching workspace…", desc: "Restarting the agent in the new folder β€” ready in a moment.", timeout: 4000 });
const bar = $("#wsBar") as HTMLButtonElement | null;
if (bar) { bar.hidden = false; bar.innerHTML = `<span class="ws-bar-loading">${icon("refresh", 12, "spin")}switching…</span>`; }
const info = await bridge.setWorkspace(path);
if (info) { state.workspace = info; renderWorkspaceBar(); }
if (info) { state.workspace = info; }
renderWorkspaceBar();
seedThread(); state.liveUsage = null; renderStatus(); renderMetricsRail();
void renderSessions(); void renderSettings();
showToast({ title: "Workspace set", desc: `Agent now works in ${info?.name ?? path}.`, actions: [{ label: "OK" }], timeout: 2600 });
Expand Down Expand Up @@ -1697,12 +1746,27 @@ async function loadConfig(): Promise<void> {
/** Force omp to re-read its credential vault and refresh the model list (manual "Refresh models"
* button). Restarts the omp child, so it also picks up a provider connected since launch. */
async function refreshModels(): Promise<void> {
// #11 perceived-latency: refreshConfig() triggers an omp respawn (2–5s) during which the
// model badge would otherwise sit stale with no signal. Show an inline "Refreshing
// models…" spinner on the badge + a reassuring toast, and ALWAYS restore the real label
// afterwards (success or failure) so it can never get stuck spinning.
const mn = $("#modelName");
const badge = $("#modelBadge");
const prevName = mn?.textContent ?? "";
if (mn) mn.textContent = "Refreshing models…";
badge?.classList.add("busy");
showToast({ title: "Refreshing models…", desc: "Restarting omp to re-read your providers. Your next turn will pick up the new list.", timeout: 4000 });
try {
state.config = await bridge.refreshConfig();
const model = state.config.find((c) => c.id === "model");
if (model) { state.model = model.currentValue; const mn = $("#modelName"); if (mn) mn.textContent = modelLabel(model.currentValue); }
if (model) { state.model = model.currentValue; if (mn) mn.textContent = modelLabel(model.currentValue); }
else if (mn) mn.textContent = prevName;
updateComposerTools();
} catch { /* keep current */ }
} catch {
if (mn) mn.textContent = prevName; // keep current on failure
} finally {
badge?.classList.remove("busy"); // never leave the badge stuck in a busy state
}
}

/** After an OAuth login is kicked off, watch the provider's status until it flips to connected
Expand Down
42 changes: 42 additions & 0 deletions desktop/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,48 @@ body.resizing .sidebar,body.resizing .inspector{transition:none}
.fb-actions{display:flex;gap:8px;flex:none}

/* P10.3 budget chip near-limit warning */
/* ──────────────────────────────────────────────────────────────────────────
#11 Perceived-latency: generic loading skeletons + respawn spinner.
Every async surface paints one of these INSTANTLY on open, then swaps to real
content (or an empty-state) when the fetch resolves OR fails β€” never sticky.
Reuses the existing `shimmer` keyframe (defined above with .set-skel).
────────────────────────────────────────────────────────────────────────── */
.skel{border-radius:8px;background:linear-gradient(90deg,var(--bg-2) 25%,var(--bg-3) 50%,var(--bg-2) 75%);
background-size:200% 100%;animation:shimmer 1.3s ease-in-out infinite}
/* a placeholder block of skeleton rows; keep its motion calm and grouped */
.skel-group{display:flex;flex-direction:column;gap:9px}
/* sidebar session placeholders β€” mirror a real .sess card's two-line shape */
.skel-sess{padding:10px 11px;display:flex;flex-direction:column;gap:7px;
border:1px solid var(--line-soft);border-radius:9px;background:var(--bg-1)}
.skel-line{height:11px;border-radius:6px}
.skel-line.t{width:72%;height:13px}
.skel-line.m{width:52%}
/* inspector first-load β€” placeholder metric chips + a couple of rows */
.skel-chips{display:flex;gap:9px;margin:2px 0 12px}
.skel-chip{flex:1;height:54px;border-radius:11px}
.skel-row{height:32px;border-radius:8px}
/* knowledge-graph "decrypting…" state β€” quiet, centered, with a spinner */
.skel-kg{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:12px;color:var(--txt-4);font-size:13px;text-align:center;padding:0 36px}
.skel-kg .spin{color:var(--txt-3)}
/* workspace bar β€” a subtle "loading workspace…" pill while it resolves */
.ws-bar-loading{display:inline-flex;align-items:center;gap:7px;color:var(--txt-4);font-size:12px}
.ws-bar-loading .spin{color:var(--txt-3)}
/* generic spinner: any icon with .spin rotates; cfg-refresh gets a busy state */
.spin{display:inline-flex;animation:lucid-spin .9s linear infinite}
.cfg-refresh.busy{color:var(--txt-3);cursor:default}
.cfg-refresh.busy .ic{animation:lucid-spin .9s linear infinite}
/* model badge during an omp respawn (refreshModels): calm pulsing dot + dimmed text */
.model-badge.busy{cursor:default;color:var(--txt-3)}
.model-badge.busy .dot{animation:lucid-pulse 1.1s ease-in-out infinite}
@keyframes lucid-spin{to{transform:rotate(360deg)}}
@keyframes lucid-pulse{0%,100%{opacity:.35}50%{opacity:1}}
@media (prefers-reduced-motion: reduce){
.skel,.spin,.cfg-refresh.busy .ic,.model-badge.busy .dot{animation:none!important}
/* without shimmer motion, fall back to a calm static tint so it still reads as "loading" */
.skel{background:var(--bg-2)}
}

.statusbar .seg.warn{border:1px solid color-mix(in srgb,var(--red) 45%,var(--line));
background:var(--red-dim);border-radius:6px;padding:1px 6px;color:#ffb3b3;
box-shadow:0 0 0 1px rgba(239,95,95,.08),inset 0 0 8px rgba(239,95,95,.06)}
Expand Down