Skip to content

Commit b93c327

Browse files
committed
fix(app): refine discovery and overlay layout
- Scope agent status discovery across native and WSL distros - Stabilize sidebar and right-panel overlay dismissal behavior - Update draft welcome, composer attachments, and panel visibility - Harden secret storage key reads and add regression coverage
1 parent 69358fb commit b93c327

29 files changed

Lines changed: 715 additions & 322 deletions

src/main/secretStorageKey.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
import { randomBytes } from "node:crypto";
33
import { dirname, join } from "node:path";
44
import { safeStorage } from "electron";
@@ -19,10 +19,15 @@ export function readOrCreateSafeStorageSecretKey(baseDir: string): string {
1919
}
2020

2121
const path = keyFilePath(baseDir);
22-
if (existsSync(path)) {
22+
try {
2323
const encrypted = Buffer.from(readFileSync(path, "utf8"), "base64");
2424
const key = safeStorage.decryptString(encrypted);
2525
if (isValidKey(key)) return key;
26+
} catch {
27+
// Either the key file does not exist yet (first launch) or the OS-level
28+
// safeStorage key is no longer available (e.g. credential reset, reinstall,
29+
// different user). Fall through to regenerate; anything sealed with the
30+
// prior key is unrecoverable regardless.
2631
}
2732

2833
const key = randomBytes(32).toString("base64");

src/renderer/actions/panelActions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ export function closeAllPanels(): void {
7878
usePanelStore.getState().closeAllPanels();
7979
}
8080

81+
/** Dismiss every panel that can occupy the right edge — used by the overlay backdrop. */
82+
export function dismissRightOverlay(): void {
83+
usePanelStore.getState().closeAllPanels();
84+
useDevTerminalStore.getState().closePanel();
85+
}
86+
8187
function applyFilesPanel(
8288
projectId: string,
8389
worktreePath: string | undefined,

src/renderer/app.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ const unsubSupervisor = readBridge().onSupervisorEvent((event) => {
146146
if (event.type === "windows-agent-statuses") {
147147
console.log(`[renderer] event: windows-agent-statuses (${event.statuses.length} agents)`);
148148
const store = useAgentStatusesStore.getState();
149-
if (store.inFirstLaunchDiscovery) {
149+
if (store.inFirstLaunchDiscovery && store.discoveryScope?.kind !== "wsl") {
150150
const statuses = event.statuses;
151151
setTimeout(() => {
152152
useAgentStatusesStore.getState().setAgentStatuses(statuses);
@@ -157,7 +157,15 @@ const unsubSupervisor = readBridge().onSupervisorEvent((event) => {
157157
}
158158
if (event.type === "wsl-agent-statuses") {
159159
console.log(`[renderer] event: wsl-agent-statuses (${event.statuses.length} agents)`);
160-
useAgentStatusesStore.getState().setWslAgentStatuses(event.statuses);
160+
const store = useAgentStatusesStore.getState();
161+
if (store.inFirstLaunchDiscovery && store.discoveryScope?.kind === "wsl") {
162+
const statuses = event.statuses;
163+
setTimeout(() => {
164+
useAgentStatusesStore.getState().setWslAgentStatuses(statuses);
165+
}, 1000);
166+
} else {
167+
store.setWslAgentStatuses(event.statuses);
168+
}
161169
}
162170
});
163171

src/renderer/components/composer/AttachmentBar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ReactNode } from "react";
12
import { Globe, X } from "lucide-react";
23
import { getEntryIconUrl } from "@/renderer/components/common/fileIcons";
34
import { toLocalFileUrl } from "@/shared/promptContent";
@@ -138,6 +139,7 @@ export function AttachmentBar(props: {
138139
layout?: "inset" | "flush";
139140
hideImageNames?: boolean;
140141
imagesAsPreview?: boolean;
142+
leading?: ReactNode;
141143
}) {
142144
const {
143145
attachments,
@@ -146,8 +148,9 @@ export function AttachmentBar(props: {
146148
layout = "inset",
147149
hideImageNames,
148150
imagesAsPreview,
151+
leading,
149152
} = props;
150-
if (attachments.length === 0) return null;
153+
if (attachments.length === 0 && !leading) return null;
151154

152155
const className =
153156
layout === "inset"
@@ -156,6 +159,7 @@ export function AttachmentBar(props: {
156159

157160
return (
158161
<div className={className}>
162+
{leading}
159163
{attachments.map((att) =>
160164
imagesAsPreview && att.isImage && !att.selector ? (
161165
<ImagePreview key={att.id} attachment={att} onPreviewImage={onPreviewImage} />

src/renderer/components/layout/PageLayout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export function PageLayout(props: {
150150
gitPanel?: ReactNode;
151151
forceSidebarExpanded?: boolean;
152152
onRequestClosePanels?: () => void;
153+
onDismissRightOverlay?: () => void;
153154
}) {
154155
const {
155156
title,
@@ -162,6 +163,7 @@ export function PageLayout(props: {
162163
gitPanel,
163164
forceSidebarExpanded,
164165
onRequestClosePanels,
166+
onDismissRightOverlay,
165167
} = props;
166168

167169
const sidebarHeader = (
@@ -185,6 +187,7 @@ export function PageLayout(props: {
185187
gitPanel={gitPanel}
186188
{...(forceSidebarExpanded === true ? { forceSidebarExpanded: true } : {})}
187189
{...(onRequestClosePanels != null ? { onRequestClosePanels } : {})}
190+
{...(onDismissRightOverlay != null ? { onDismissRightOverlay } : {})}
188191
/>
189192
);
190193

src/renderer/components/layout/UnifiedRightPanel.tsx

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export function UnifiedRightPanel(props: {
2525
filesContent: ReactNode;
2626
browserContent: ReactNode;
2727
showTerminalTab?: boolean;
28+
showFilesTab?: boolean;
29+
showGitTab?: boolean;
2830
projectName: string | undefined;
2931
onExpandGitToOverlay?: () => void;
3032
onExpandFilesToOverlay?: () => void;
@@ -43,6 +45,8 @@ export function UnifiedRightPanel(props: {
4345
filesContent,
4446
browserContent,
4547
showTerminalTab = true,
48+
showFilesTab = true,
49+
showGitTab = true,
4650
projectName,
4751
onExpandGitToOverlay,
4852
onExpandFilesToOverlay,
@@ -122,28 +126,32 @@ export function UnifiedRightPanel(props: {
122126
<TerminalSquare className="size-3.5" />
123127
</button>
124128
) : null}
125-
<button
126-
type="button"
127-
className={`${dragCtl} ${panelHeaderTabIconButtonClass(activeTab === "files")}`}
128-
title="Files"
129-
onClick={() => {
130-
if (onOpenFiles) onOpenFiles();
131-
else onTabChange("files");
132-
}}
133-
>
134-
<FolderOpen className="size-3.5" />
135-
</button>
136-
<button
137-
type="button"
138-
className={`${dragCtl} ${panelHeaderTabIconButtonClass(activeTab === "git")}`}
139-
title="Git"
140-
onClick={() => {
141-
if (onOpenGit) onOpenGit();
142-
else onTabChange("git");
143-
}}
144-
>
145-
<FileDiff className="size-3.5" />
146-
</button>
129+
{showFilesTab ? (
130+
<button
131+
type="button"
132+
className={`${dragCtl} ${panelHeaderTabIconButtonClass(activeTab === "files")}`}
133+
title="Files"
134+
onClick={() => {
135+
if (onOpenFiles) onOpenFiles();
136+
else onTabChange("files");
137+
}}
138+
>
139+
<FolderOpen className="size-3.5" />
140+
</button>
141+
) : null}
142+
{showGitTab ? (
143+
<button
144+
type="button"
145+
className={`${dragCtl} ${panelHeaderTabIconButtonClass(activeTab === "git")}`}
146+
title="Git"
147+
onClick={() => {
148+
if (onOpenGit) onOpenGit();
149+
else onTabChange("git");
150+
}}
151+
>
152+
<FileDiff className="size-3.5" />
153+
</button>
154+
) : null}
147155
<button
148156
type="button"
149157
className={`${dragCtl} ${panelHeaderTabIconButtonClass(activeTab === "browser")}`}

src/renderer/components/thread/AgentDiscoveryScreen.tsx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { PixelLoader } from "@/renderer/components/common";
22
import { getRegisteredProviders, ProviderIcon } from "@/renderer/components/providers/ProviderIcon";
33
import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore";
4-
import type { AgentStatus } from "@/shared/contracts";
4+
import type { AgentStatus, ProjectLocation } from "@/shared/contracts";
55

66
function readyBadge(status: AgentStatus): { label: string; toneClass: string } | null {
77
if (!status.installed) return null;
@@ -11,10 +11,22 @@ function readyBadge(status: AgentStatus): { label: string; toneClass: string } |
1111
return { label: "Ready", toneClass: "text-success" };
1212
}
1313

14-
export function AgentDiscoveryScreen() {
14+
function statusLine(scopedCount: number, installedCount: number, wslDistro: string | undefined) {
15+
if (scopedCount === 0) {
16+
return wslDistro ? "Warming up WSL shell environment…" : "Warming up shell environment…";
17+
}
18+
if (installedCount === 0) return "No agents installed yet";
19+
if (installedCount === 1) return "1 agent ready";
20+
return `${installedCount} agents ready`;
21+
}
22+
23+
export function AgentDiscoveryScreen(props: { location?: ProjectLocation }) {
24+
// `discoveredAgents` is already scoped by `pushDiscoveredAgent` to the active
25+
// discovery scope, so no additional location filtering is needed here.
1526
const discovered = useAgentStatusesStore((s) => s.discoveredAgents);
1627
const byKind = new Map(discovered.map((status) => [status.kind, status]));
1728
const installedCount = discovered.reduce((n, s) => n + (s.installed ? 1 : 0), 0);
29+
const wslDistro = props.location?.kind === "wsl" ? props.location.distro : undefined;
1830
// Provider plugins self-register at module-load time; reading the registry
1931
// each render keeps this screen in sync as new agent kinds are added.
2032
const providers = getRegisteredProviders();
@@ -25,7 +37,9 @@ export function AgentDiscoveryScreen() {
2537
<PixelLoader size="lg" className="text-foreground" />
2638
<h1 className="text-xl font-semibold tracking-tight">Discovering coding agents…</h1>
2739
<p className="max-w-sm text-sm text-muted">
28-
Scanning your system for installed CLIs. This usually takes a couple of seconds.
40+
{wslDistro
41+
? `Scanning ${wslDistro} for installed CLIs. This usually takes a couple of seconds.`
42+
: "Scanning your system for installed CLIs. This usually takes a couple of seconds."}
2943
</p>
3044
</div>
3145

@@ -59,13 +73,7 @@ export function AgentDiscoveryScreen() {
5973
</div>
6074

6175
<div className="text-xs text-muted/70" aria-live="polite">
62-
{discovered.length === 0
63-
? "Warming up shell environment…"
64-
: installedCount === 0
65-
? "No agents installed yet"
66-
: installedCount === 1
67-
? "1 agent ready"
68-
: `${installedCount} agents ready`}
76+
{statusLine(discovered.length, installedCount, wslDistro)}
6977
</div>
7078
</div>
7179
);

src/renderer/components/thread/ThreadAgentUpdateDock.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,6 @@ import {
1818
} from "@/renderer/utils/acpRegistryAuth";
1919
import { ThreadDockHeader, ThreadDockSection } from "./ThreadDockUI";
2020

21-
function UpdatePendingIcon({ className }: { className?: string }) {
22-
return (
23-
<span className={`inline-flex items-center justify-center ${className ?? ""}`}>
24-
<PixelLoader size="xs" />
25-
</span>
26-
);
27-
}
28-
2921
/**
3022
* Composer-placed status row that surfaces "{agent} v0.130.0 → v0.130.5 is
3123
* available" with an inline Update button. Reads the *project-scoped* status
@@ -145,7 +137,7 @@ export function ThreadAgentUpdateDock(props: {
145137
return (
146138
<ThreadDockSection placement="composer" collapsed={false} ariaLabel="Agent update available">
147139
<ThreadDockHeader
148-
icon={pending ? UpdatePendingIcon : Download}
140+
icon={Download}
149141
iconClassName="text-foreground"
150142
title="Update available"
151143
actions={
@@ -159,7 +151,7 @@ export function ThreadAgentUpdateDock(props: {
159151
onPress={() => void handleUpdate()}
160152
>
161153
{pending ? <PixelLoader size="xs" /> : <Download className="size-3.5" />}
162-
Update to v{resolvedLatest}
154+
Update
163155
</Button>
164156
</div>
165157
}

src/renderer/components/thread/ThreadDraftComposerArea.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { isHomeProjectId } from "@/shared/homeScope";
1010
import { readBridge } from "@/renderer/bridge";
1111
import {
1212
AttachmentBar,
13+
BrowserChip,
1314
ComposerAddMenu,
1415
ImageLightbox,
1516
MentionInput,
@@ -76,7 +77,7 @@ export function ThreadDraftComposerArea(props: {
7677
const [agentUpdating, setAgentUpdating] = useState(false);
7778
const mentionRef = useRef<MentionInputHandle>(null);
7879
const attachments = useAttachments();
79-
const inboxKey = props.paneId;
80+
const inboxKey = props.paneId ?? `draft:${props.project.id}`;
8081
const pendingPickedAttachments = useBrowserAttachInbox((s) =>
8182
inboxKey ? s.itemsByThread[inboxKey] : undefined,
8283
);
@@ -324,6 +325,11 @@ export function ThreadDraftComposerArea(props: {
324325
const idx = imageAttachments.findIndex((a) => a.id === att.id);
325326
if (idx >= 0) setLightboxIndex(idx);
326327
}}
328+
leading={
329+
props.config.browserMcp === true ? (
330+
<BrowserChip onRemove={() => props.onConfigChange({ browserMcp: false })} />
331+
) : undefined
332+
}
327333
/>
328334
}
329335
inputContent={

src/renderer/components/thread/ThreadDraftView.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33
import type { AgentStatus, Project } from "@/shared/contracts";
44
import { HOME_PROJECT_ID, HOME_PROJECT_NAME } from "@/shared/homeScope";
5+
import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore";
56
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
67

78
const { composerSpy } = vi.hoisted(() => ({
@@ -40,6 +41,18 @@ const project: Project = {
4041
createdAt: "2026-03-28T00:00:00.000Z",
4142
};
4243

44+
const wslProject: Project = {
45+
id: "project-wsl",
46+
name: "Repo WSL",
47+
location: {
48+
kind: "wsl",
49+
distro: "Ubuntu",
50+
linuxPath: "/home/demo/repo",
51+
uncPath: "\\\\wsl.localhost\\Ubuntu\\home\\demo\\repo",
52+
},
53+
createdAt: "2026-03-28T00:00:00.000Z",
54+
};
55+
4356
const homeProject: Project = {
4457
id: HOME_PROJECT_ID,
4558
name: HOME_PROJECT_NAME,
@@ -265,6 +278,15 @@ const singleEffortMultiContextCursorStatus: AgentStatus = {
265278
describe("ThreadDraftView", () => {
266279
beforeEach(() => {
267280
composerSpy.mockClear();
281+
useAgentStatusesStore.setState({
282+
agentStatuses: [],
283+
wslAgentStatuses: [],
284+
windowsLoaded: false,
285+
wslLoaded: false,
286+
inFirstLaunchDiscovery: false,
287+
discoveryScope: undefined,
288+
discoveredAgents: [],
289+
});
268290
useSharedSettings.setState({
269291
providerConfigs: {},
270292
hiddenModels: {},
@@ -315,6 +337,24 @@ describe("ThreadDraftView", () => {
315337
expect(screen.queryByText("No supported agents detected")).not.toBeInTheDocument();
316338
});
317339

340+
it("shows the discovery reveal for a WSL project while its distro is probing", () => {
341+
const onStart = vi.fn<(input: unknown) => void>();
342+
useAgentStatusesStore.getState().beginFirstLaunchDiscovery({ kind: "wsl", distro: "Ubuntu" });
343+
344+
render(
345+
<ThreadDraftView
346+
project={wslProject}
347+
agentStatuses={[]}
348+
isDetectingAgents
349+
onStart={onStart}
350+
/>,
351+
);
352+
353+
expect(screen.getByText("Discovering coding agents…")).toBeInTheDocument();
354+
expect(screen.getByText(/Scanning Ubuntu/)).toBeInTheDocument();
355+
expect(screen.queryByText("No supported agents detected")).not.toBeInTheDocument();
356+
});
357+
318358
it("keeps auth-missing agents selectable but blocks launching from the draft composer", () => {
319359
const onStart = vi.fn<(input: unknown) => void>();
320360
render(

0 commit comments

Comments
 (0)