Skip to content

Commit b585230

Browse files
BunsDevCopilot
andauthored
Surface stale workspace paths in the UI (#473)
* Show stale workspace paths in the UI - add a path-exists websocket API and shared contracts - surface missing-on-disk badges for projects, branches, and worktrees - poll path existence in sidebar and branch toolbar * Add streaming code preview cards (#472) - Render live code preview chrome for streaming assistant fences - Show fallback plain-code state when highlighting fails - Add shared metadata helpers and tests * Harden Capacitor local notification patching - Update the patch script to preserve required notification validation - Handle empty arrays explicitly for cancel and remove flows - Switch type extraction to the newer Capacitor call API * Add attachment diff previews in chat (#477) - Preview file attachments as diff or text in a modal - Reuse shared patch parsing and diff tree helpers - Serve persisted attachment ids with diff MIME types * Scope preview open state to thread (#476) - Persist preview visibility per thread instead of per project - Update preview toggles, layout actions, and app-browser open flow - Bump preview state storage and add migration coverage * Show missing-on-disk indicators across file views - Add stale path badges in the code viewer, workspace tree, terminal links, and PR review tabs - Broaden file refresh invalidation so deleted or moved files update consistently * fix: always show missing-path badge in branch toolbar env selector mode Agent-Logs-Url: https://github.com/OpenKnots/okcode/sessions/ca974bc9-c599-48b4-ac62-43ea2cc1f192 Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent c2e546d commit b585230

17 files changed

Lines changed: 441 additions & 88 deletions

apps/server/src/wsServer.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2055,6 +2055,31 @@ describe("WebSocket Server", () => {
20552055
});
20562056
});
20572057

2058+
it("reports whether a workspace path exists on disk", async () => {
2059+
const workspace = makeTempDir("okcode-ws-path-exists-");
2060+
const missingPath = path.join(workspace, "missing-worktree");
2061+
2062+
const { cwd } = makeWorkspaceFixture("test");
2063+
server = await createTestServer({ cwd });
2064+
const addr = server.address();
2065+
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
2066+
2067+
const [ws] = await connectAndAwaitWelcome(port);
2068+
connections.push(ws);
2069+
2070+
const existingResponse = await sendRequest(ws, WS_METHODS.projectsPathExists, {
2071+
path: workspace,
2072+
});
2073+
expect(existingResponse.error).toBeUndefined();
2074+
expect(existingResponse.result).toEqual({ exists: true });
2075+
2076+
const missingResponse = await sendRequest(ws, WS_METHODS.projectsPathExists, {
2077+
path: missingPath,
2078+
});
2079+
expect(missingResponse.error).toBeUndefined();
2080+
expect(missingResponse.result).toEqual({ exists: false });
2081+
});
2082+
20582083
it("supports projects.writeFile within the workspace root", async () => {
20592084
const workspace = makeTempDir("okcode-ws-write-file-");
20602085

apps/server/src/wsServer.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99
import fs from "node:fs";
1010
import http, { type IncomingMessage } from "node:http";
11+
import path from "node:path";
1112
import type { Duplex } from "node:stream";
1213

1314
import Mime from "@effect/platform-node/Mime";
@@ -119,6 +120,10 @@ function testOpenclawGateway(input: import("@okcode/contracts").TestOpenclawGate
119120
});
120121
}
121122

123+
const resolveCheckPath = Effect.fn(function* (input: string) {
124+
return path.resolve(yield* expandHomePath(input.trim()));
125+
});
126+
122127
/**
123128
* Returns true if `a` is a strictly higher semver than `b`.
124129
* Only handles `major.minor.patch` numeric segments; pre-release suffixes
@@ -1113,6 +1118,19 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
11131118
});
11141119
}
11151120

1121+
case WS_METHODS.projectsPathExists: {
1122+
const body = stripRequestTag(request.body);
1123+
return yield* Effect.gen(function* () {
1124+
const resolvedPath = yield* resolveCheckPath(body.path);
1125+
const fileInfo = yield* fileSystem
1126+
.stat(resolvedPath)
1127+
.pipe(Effect.catch(() => Effect.succeed(null)));
1128+
return {
1129+
exists: fileInfo !== null,
1130+
};
1131+
});
1132+
}
1133+
11161134
case WS_METHODS.projectsWriteFile: {
11171135
const body = stripRequestTag(request.body);
11181136
const target = yield* resolveWorkspaceWritePath({

apps/web/src/components/BranchToolbar.tsx

Lines changed: 51 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
gitStatusQueryOptions,
1111
invalidateGitQueries,
1212
} from "../lib/gitReactQuery";
13+
import { projectPathExistsQueryOptions } from "../lib/projectReactQuery";
1314
import { newCommandId } from "../lib/utils";
1415
import { readNativeApi } from "../nativeApi";
1516
import { useComposerDraftStore } from "../composerDraftStore";
@@ -26,6 +27,7 @@ import { Button } from "./ui/button";
2627
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
2728
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
2829
import { toastManager } from "./ui/toast";
30+
import { MissingOnDiskBadge } from "./MissingOnDiskBadge";
2931

3032
const envModeItems = [
3133
{ value: "local", label: "Local" },
@@ -62,6 +64,11 @@ export default function BranchToolbar({
6264
const activeWorktreeBaseBranch =
6365
serverThread?.worktreeBaseBranch ?? draftThread?.worktreeBaseBranch ?? null;
6466
const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null;
67+
const pathExistsQuery = useQuery({
68+
...projectPathExistsQueryOptions({ path: branchCwd }),
69+
staleTime: 30_000,
70+
refetchInterval: 60_000,
71+
});
6572
const hasServerThread = serverThread !== undefined;
6673
const effectiveEnvMode = resolveEffectiveEnvMode({
6774
activeWorktreePath,
@@ -130,6 +137,7 @@ export default function BranchToolbar({
130137
const needsSync = behindCount > 0 && !hasServerThread;
131138
const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
132139
const stopPullMutation = useMutation(gitStopActionMutationOptions({ cwd: gitCwd, queryClient }));
140+
const isMissingOnDisk = branchCwd !== null && pathExistsQuery.data?.exists === false;
133141

134142
// Force a fresh git-status fetch when a draft thread mounts so we catch
135143
// upstream changes immediately instead of waiting for the next poll cycle.
@@ -195,50 +203,53 @@ export default function BranchToolbar({
195203

196204
return (
197205
<div className="mx-auto flex w-full max-w-7xl items-end justify-between px-5 pb-3 pt-1">
198-
{envLocked || activeWorktreePath ? (
199-
<span className="inline-flex items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">
200-
{activeWorktreePath ? (
201-
<>
202-
<GitForkIcon className="size-3" />
203-
Worktree
204-
</>
205-
) : (
206-
<>
207-
<FolderIcon className="size-3" />
208-
Local
209-
</>
210-
)}
211-
</span>
212-
) : (
213-
<Select
214-
value={effectiveEnvMode}
215-
onValueChange={(value) => onEnvModeChange(value as EnvMode)}
216-
items={envModeItems}
217-
>
218-
<SelectTrigger variant="ghost" size="xs" className="font-medium">
219-
{effectiveEnvMode === "worktree" ? (
220-
<GitForkIcon className="size-3" />
206+
<div className="flex items-center gap-1.5">
207+
{envLocked || activeWorktreePath ? (
208+
<span className="inline-flex items-center gap-1.5 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">
209+
{activeWorktreePath ? (
210+
<>
211+
<GitForkIcon className="size-3" />
212+
Worktree
213+
</>
221214
) : (
222-
<FolderIcon className="size-3" />
223-
)}
224-
<SelectValue />
225-
</SelectTrigger>
226-
<SelectPopup>
227-
<SelectItem value="local">
228-
<span className="inline-flex items-center gap-1.5">
215+
<>
229216
<FolderIcon className="size-3" />
230217
Local
231-
</span>
232-
</SelectItem>
233-
<SelectItem value="worktree">
234-
<span className="inline-flex items-center gap-1.5">
218+
</>
219+
)}
220+
</span>
221+
) : (
222+
<Select
223+
value={effectiveEnvMode}
224+
onValueChange={(value) => onEnvModeChange(value as EnvMode)}
225+
items={envModeItems}
226+
>
227+
<SelectTrigger variant="ghost" size="xs" className="font-medium">
228+
{effectiveEnvMode === "worktree" ? (
235229
<GitForkIcon className="size-3" />
236-
New worktree
237-
</span>
238-
</SelectItem>
239-
</SelectPopup>
240-
</Select>
241-
)}
230+
) : (
231+
<FolderIcon className="size-3" />
232+
)}
233+
<SelectValue />
234+
</SelectTrigger>
235+
<SelectPopup>
236+
<SelectItem value="local">
237+
<span className="inline-flex items-center gap-1.5">
238+
<FolderIcon className="size-3" />
239+
Local
240+
</span>
241+
</SelectItem>
242+
<SelectItem value="worktree">
243+
<span className="inline-flex items-center gap-1.5">
244+
<GitForkIcon className="size-3" />
245+
New worktree
246+
</span>
247+
</SelectItem>
248+
</SelectPopup>
249+
</Select>
250+
)}
251+
{isMissingOnDisk ? <MissingOnDiskBadge path={branchCwd} /> : null}
252+
</div>
242253

243254
<div className="flex flex-col items-end gap-1">
244255
<div className="flex items-center gap-1.5">

apps/web/src/components/CodeViewerPanel.tsx

Lines changed: 69 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,17 @@ import {
2121
} from "~/codeViewerStore";
2222
import { useTheme } from "~/hooks/useTheme";
2323
import { isElectron } from "~/env";
24-
import { projectQueryKeys, projectReadFileQueryOptions } from "~/lib/projectReactQuery";
24+
import {
25+
projectPathExistsQueryOptions,
26+
projectQueryKeys,
27+
projectReadFileQueryOptions,
28+
} from "~/lib/projectReactQuery";
2529
import { cn } from "~/lib/utils";
2630
import { isMarkdownPreviewFilePath } from "~/markdownPreview";
2731
import { readNativeApi } from "~/nativeApi";
32+
import { resolvePathLinkTarget } from "~/terminal-links";
2833
import { type CodeContextSelection, CodeMirrorViewer } from "./CodeMirrorViewer";
34+
import { MissingOnDiskBadge } from "./MissingOnDiskBadge";
2935
import { MarkdownPreview } from "./MarkdownPreview";
3036
import { Button } from "./ui/button";
3137
import { toastManager } from "./ui/toast";
@@ -143,46 +149,74 @@ export function CodeViewerTabStrip(props: {
143149
return (
144150
<div className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto [-webkit-app-region:no-drag]">
145151
{props.tabs.map((tab) => {
146-
const isActive = tab.tabId === props.activeTabId;
147152
return (
148-
<div
153+
<CodeViewerTabStripItem
149154
key={tab.tabId}
150-
className={cn(
151-
"group flex max-w-[200px] shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] transition-colors",
152-
isActive
153-
? "border-border bg-accent text-accent-foreground"
154-
: "border-transparent text-muted-foreground/70 hover:border-border/60 hover:text-foreground/80",
155-
)}
156-
>
157-
<button
158-
type="button"
159-
className="min-w-0 flex-1 truncate text-left font-mono"
160-
onClick={() => props.onSelectTab(tab.tabId)}
161-
title={tab.relativePath}
162-
>
163-
<span className="truncate">{tab.label}</span>
164-
{tab.isDirty ? (
165-
<span className="ml-1 text-amber-600 dark:text-amber-300"></span>
166-
) : null}
167-
</button>
168-
<button
169-
type="button"
170-
className="shrink-0 rounded-sm p-0.5 opacity-0 transition-opacity hover:bg-accent/80 group-hover:opacity-100"
171-
onClick={(event) => {
172-
event.stopPropagation();
173-
void props.onCloseTab(tab.tabId);
174-
}}
175-
aria-label={`Close ${tab.label}`}
176-
>
177-
<XIcon className="size-3" />
178-
</button>
179-
</div>
155+
tab={tab}
156+
active={tab.tabId === props.activeTabId}
157+
onSelectTab={props.onSelectTab}
158+
onCloseTab={props.onCloseTab}
159+
/>
180160
);
181161
})}
182162
</div>
183163
);
184164
}
185165

166+
const CodeViewerTabStripItem = memo(function CodeViewerTabStripItem(props: {
167+
tab: CodeViewerTab;
168+
active: boolean;
169+
onSelectTab: (tabId: string) => void;
170+
onCloseTab: (tabId: string) => void | Promise<void>;
171+
}) {
172+
const absolutePath = resolvePathLinkTarget(props.tab.relativePath, props.tab.cwd);
173+
const pathExistsQuery = useQuery(
174+
projectPathExistsQueryOptions({
175+
path: absolutePath,
176+
}),
177+
);
178+
const isMissingOnDisk = pathExistsQuery.data?.exists === false;
179+
180+
return (
181+
<div
182+
className={cn(
183+
"group flex max-w-[200px] shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] transition-colors",
184+
props.active
185+
? "border-border bg-accent text-accent-foreground"
186+
: "border-transparent text-muted-foreground/70 hover:border-border/60 hover:text-foreground/80",
187+
)}
188+
>
189+
<button
190+
type="button"
191+
className="flex min-w-0 flex-1 items-center gap-1 text-left font-mono"
192+
onClick={() => props.onSelectTab(props.tab.tabId)}
193+
title={props.tab.relativePath}
194+
>
195+
<span className="min-w-0 truncate">{props.tab.label}</span>
196+
{props.tab.isDirty ? <span className="text-amber-600 dark:text-amber-300"></span> : null}
197+
{isMissingOnDisk ? (
198+
<MissingOnDiskBadge
199+
path={absolutePath}
200+
compact
201+
className="h-4 min-w-0 shrink-0 px-1 text-[10px]"
202+
/>
203+
) : null}
204+
</button>
205+
<button
206+
type="button"
207+
className="shrink-0 rounded-sm p-0.5 opacity-0 transition-opacity hover:bg-accent/80 group-hover:opacity-100"
208+
onClick={(event) => {
209+
event.stopPropagation();
210+
void props.onCloseTab(props.tab.tabId);
211+
}}
212+
aria-label={`Close ${props.tab.label}`}
213+
>
214+
<XIcon className="size-3" />
215+
</button>
216+
</div>
217+
);
218+
});
219+
186220
type CodeViewerFileContentProps = {
187221
cwd: string;
188222
relativePath: string;
@@ -347,11 +381,9 @@ export const CodeViewerFileContent = memo(function CodeViewerFileContent(
347381
if (payload.cwd !== props.cwd) {
348382
return;
349383
}
350-
void queryClient.invalidateQueries({
351-
queryKey: projectQueryKeys.readFile(props.cwd, props.relativePath),
352-
});
384+
void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all });
353385
});
354-
}, [props.cwd, props.relativePath, queryClient]);
386+
}, [props.cwd, queryClient]);
355387

356388
useEffect(() => {
357389
if (!settings.codeViewerAutosave || !tab?.isDirty || !editable || isSaving) {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { TriangleAlertIcon } from "lucide-react";
2+
3+
import { Badge } from "./ui/badge";
4+
import { cn } from "~/lib/utils";
5+
6+
export function MissingOnDiskBadge({
7+
path,
8+
className,
9+
compact = false,
10+
}: {
11+
path: string;
12+
className?: string;
13+
compact?: boolean;
14+
}) {
15+
return (
16+
<Badge
17+
variant="warning"
18+
size="sm"
19+
title={`Path no longer exists on disk: ${path}`}
20+
className={cn("gap-1.5 px-1.5", className)}
21+
>
22+
<TriangleAlertIcon className="size-3" />
23+
{compact ? "Missing" : "Missing on disk"}
24+
</Badge>
25+
);
26+
}

0 commit comments

Comments
 (0)