Skip to content

Commit bfa4744

Browse files
committed
🤖 feat: auto-refresh review panel for local bases
1 parent 94f96bd commit bfa4744

File tree

8 files changed

+463
-34
lines changed

8 files changed

+463
-34
lines changed

Makefile

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,13 @@ dev: node_modules/.installed build-main ## Start development server (Vite + node
133133
# https://github.com/oven-sh/bun/issues/18275
134134
@MUX_DISABLE_TELEMETRY=$(or $(MUX_DISABLE_TELEMETRY),1) NODE_OPTIONS="--max-old-space-size=4096" npm x concurrently -k --raw \
135135
"bun x nodemon --watch src --watch tsconfig.main.json --watch tsconfig.json --ext ts,tsx,json --ignore dist --ignore node_modules --exec node scripts/build-main-watch.js" \
136-
"npx esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --watch" \
136+
"npx esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --external:@parcel/watcher --watch" \
137137
"vite"
138138
else
139139
dev: node_modules/.installed build-main build-preload ## Start development server (Vite + tsgo watcher for 10x faster type checking)
140140
@MUX_DISABLE_TELEMETRY=$(or $(MUX_DISABLE_TELEMETRY),1) bun x concurrently -k \
141141
"bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \
142-
"bun x esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --watch" \
142+
"bun x esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --external:@parcel/watcher --watch" \
143143
"vite"
144144
endif
145145

@@ -153,7 +153,7 @@ dev-server: node_modules/.installed build-main ## Start server mode with hot rel
153153
@# On Windows, use npm run because bunx doesn't correctly pass arguments
154154
@MUX_DISABLE_TELEMETRY=$(or $(MUX_DISABLE_TELEMETRY),1) npmx concurrently -k \
155155
"npmx nodemon --watch src --watch tsconfig.main.json --watch tsconfig.json --ext ts,tsx,json --ignore dist --ignore node_modules --exec node scripts/build-main-watch.js" \
156-
"npx esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --watch" \
156+
"npx esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --external:@parcel/watcher --watch" \
157157
"npmx nodemon --watch dist/cli/index.js --watch dist/cli/server.js --delay 500ms --exec \"node dist/cli/index.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)\"" \
158158
"$(SHELL) -lc \"MUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) MUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite\""
159159
else
@@ -165,7 +165,7 @@ dev-server: node_modules/.installed build-main ## Start server mode with hot rel
165165
@echo "For remote access: make dev-server VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0"
166166
@MUX_DISABLE_TELEMETRY=$(or $(MUX_DISABLE_TELEMETRY),1) bun x concurrently -k \
167167
"bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \
168-
"bun x esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --watch" \
168+
"bun x esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --external:@parcel/watcher --watch" \
169169
"bun x nodemon --watch dist/cli/index.js --watch dist/cli/server.js --delay 500ms --exec 'NODE_ENV=development node dist/cli/index.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)'" \
170170
"MUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) MUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite"
171171
endif
@@ -196,7 +196,8 @@ dist/cli/api.mjs: src/cli/api.ts src/cli/proxifyOrpc.ts $(TS_SOURCES)
196196
--outfile=dist/cli/api.mjs \
197197
--external:zod \
198198
--external:commander \
199-
--external:@trpc/server
199+
--external:@trpc/server \
200+
--external:@parcel/watcher
200201

201202
build-preload: node_modules/.installed dist/preload.js ## Build preload script
202203

bun.lock

Lines changed: 66 additions & 26 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"@orpc/client": "^1.11.3",
6060
"@orpc/openapi": "^1.12.2",
6161
"@orpc/server": "^1.11.3",
62+
"@parcel/watcher": "^2.5.1",
6263
"@orpc/zod": "^1.11.3",
6364
"@radix-ui/react-checkbox": "^1.3.3",
6465
"@radix-ui/react-context-menu": "^2.2.16",
@@ -235,7 +236,8 @@
235236
"asarUnpack": [
236237
"dist/**/*.wasm",
237238
"dist/**/*.map",
238-
"**/node_modules/node-pty/build/**/*"
239+
"**/node_modules/node-pty/build/**/*",
240+
"**/node_modules/@parcel/watcher*/**/*"
239241
],
240242
"mac": {
241243
"category": "public.app-category.developer-tools",

src/browser/components/RightSidebar/CodeReview/ReviewControls.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ interface ReviewControlsProps {
1616
isLoading?: boolean;
1717
workspaceId: string;
1818
workspacePath: string;
19+
/** If set, show an auto-refresh countdown (e.g., for origin/* bases). */
20+
autoRefreshSecondsRemaining?: number | null;
1921
refreshTrigger?: number;
2022
}
2123

@@ -27,6 +29,7 @@ export const ReviewControls: React.FC<ReviewControlsProps> = ({
2729
isLoading = false,
2830
workspaceId,
2931
workspacePath,
32+
autoRefreshSecondsRemaining,
3033
refreshTrigger,
3134
}) => {
3235
// Local state for input value - only commit on blur/Enter
@@ -83,6 +86,11 @@ export const ReviewControls: React.FC<ReviewControlsProps> = ({
8386

8487
return (
8588
<div className="bg-separator border-border-light flex flex-wrap items-center gap-3 border-b px-3 py-2 text-[11px]">
89+
{autoRefreshSecondsRemaining != null && (
90+
<span className="text-muted whitespace-nowrap">
91+
Auto-refresh in: {autoRefreshSecondsRemaining}s
92+
</span>
93+
)}
8694
{onRefresh && <RefreshButton onClick={onRefresh} isLoading={isLoading} />}
8795
<label className="text-muted font-medium whitespace-nowrap">Base:</label>
8896
<input

src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,27 @@ function makeReviewPanelCacheKey(params: {
125125
}
126126

127127
type ExecuteBashResult = Awaited<ReturnType<APIClient["workspace"]["executeBash"]>>;
128+
129+
const ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS = 30_000;
130+
131+
function getOriginBranchForFetch(diffBase: string): string | null {
132+
const trimmed = diffBase.trim();
133+
if (!trimmed.startsWith("origin/")) return null;
134+
135+
const branch = trimmed.slice("origin/".length);
136+
137+
// Avoid shell injection; diffBase is user-controlled.
138+
if (!/^[0-9A-Za-z._/-]+$/.test(branch)) return null;
139+
140+
return branch;
141+
}
142+
143+
function shouldWatchReviewDiffBase(diffBase: string): boolean {
144+
// Base is considered local as long as it isn't a remote tracking ref.
145+
// Explicitly match the user requirement: skip when it starts with "origin/".
146+
return !diffBase.trim().startsWith("origin/");
147+
}
148+
128149
type ExecuteBashSuccess = Extract<ExecuteBashResult, { success: true }>;
129150

130151
async function executeWorkspaceBashAndCache<T extends ReviewPanelCacheValue>(params: {
@@ -219,13 +240,174 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
219240
[diffState]
220241
);
221242

243+
const [originAutoRefreshSecondsRemaining, setOriginAutoRefreshSecondsRemaining] = useState<
244+
number | null
245+
>(null);
246+
const originAutoRefreshDeadlineRef = useRef<number | null>(null);
247+
const originAutoRefreshInFlightRef = useRef(false);
222248
const [filters, setFilters] = useState<ReviewFiltersType>({
223249
showReadHunks: showReadHunks,
224250
diffBase: diffBase,
225251
includeUncommitted: includeUncommitted,
226252
});
227253

254+
// Auto-refresh diffs for local bases by watching the filesystem.
255+
//
256+
// This is intentionally scoped:
257+
// - Only runs when the base is local (not starting with "origin/")
258+
// - Uses @parcel/watcher (native backends) for low-latency refresh
259+
useEffect(() => {
260+
if (!api || isCreating) return;
261+
if (!shouldWatchReviewDiffBase(filters.diffBase)) return;
262+
263+
const abortController = new AbortController();
264+
const signal = abortController.signal;
265+
266+
let debounceTimer: NodeJS.Timeout | null = null;
267+
268+
(async () => {
269+
try {
270+
const iterator = await api.workspace.onFileChanges({ workspaceId }, { signal });
271+
for await (const _event of iterator) {
272+
if (signal.aborted) break;
273+
274+
if (debounceTimer) clearTimeout(debounceTimer);
275+
debounceTimer = setTimeout(() => {
276+
setRefreshTrigger((prev) => prev + 1);
277+
}, 75);
278+
}
279+
} catch {
280+
// Cancelled via abort signal (expected on cleanup)
281+
}
282+
})();
283+
284+
return () => {
285+
abortController.abort();
286+
if (debounceTimer) clearTimeout(debounceTimer);
287+
};
288+
}, [api, workspaceId, filters.diffBase, isCreating]);
289+
290+
// Auto-refresh remote origin/* bases every 30s (with a user-visible countdown).
291+
useEffect(() => {
292+
if (!api || isCreating) {
293+
originAutoRefreshDeadlineRef.current = null;
294+
originAutoRefreshInFlightRef.current = false;
295+
setOriginAutoRefreshSecondsRemaining(null);
296+
return;
297+
}
298+
299+
const originBranch = getOriginBranchForFetch(filters.diffBase);
300+
if (!originBranch) {
301+
originAutoRefreshDeadlineRef.current = null;
302+
originAutoRefreshInFlightRef.current = false;
303+
setOriginAutoRefreshSecondsRemaining(null);
304+
return;
305+
}
306+
307+
originAutoRefreshDeadlineRef.current = Date.now() + ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS;
308+
309+
const resetCountdown = () => {
310+
originAutoRefreshDeadlineRef.current = Date.now() + ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS;
311+
setOriginAutoRefreshSecondsRemaining(
312+
Math.ceil(ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS / 1000)
313+
);
314+
};
315+
316+
resetCountdown();
317+
318+
let lastRenderedSeconds: number | null = null;
319+
320+
const interval = setInterval(() => {
321+
const deadline = originAutoRefreshDeadlineRef.current;
322+
if (!deadline) return;
323+
324+
const secondsRemaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
325+
if (secondsRemaining !== lastRenderedSeconds) {
326+
lastRenderedSeconds = secondsRemaining;
327+
setOriginAutoRefreshSecondsRemaining(secondsRemaining);
328+
}
329+
330+
if (secondsRemaining > 0) return;
331+
if (originAutoRefreshInFlightRef.current) return;
332+
333+
originAutoRefreshInFlightRef.current = true;
334+
335+
// Reset early so we don't immediately re-fire if fetch takes time.
336+
resetCountdown();
337+
338+
api.workspace
339+
.executeBash({
340+
workspaceId,
341+
script: `git fetch origin ${originBranch} --quiet || true`,
342+
options: {
343+
timeout_secs: 30,
344+
},
345+
})
346+
.catch((err) => {
347+
console.debug("ReviewPanel origin fetch failed", err);
348+
})
349+
.finally(() => {
350+
originAutoRefreshInFlightRef.current = false;
351+
setRefreshTrigger((prev) => prev + 1);
352+
});
353+
}, 250);
354+
355+
return () => {
356+
clearInterval(interval);
357+
originAutoRefreshDeadlineRef.current = null;
358+
originAutoRefreshInFlightRef.current = false;
359+
setOriginAutoRefreshSecondsRemaining(null);
360+
};
361+
}, [api, workspaceId, filters.diffBase, isCreating]);
362+
228363
// Focus panel when focusTrigger changes (preserves current hunk selection)
364+
365+
const handleRefreshRef = useRef<() => void>(() => {
366+
console.debug("ReviewPanel handleRefreshRef called before init");
367+
});
368+
handleRefreshRef.current = () => {
369+
if (!api || isCreating) return;
370+
371+
const originBranch = getOriginBranchForFetch(filters.diffBase);
372+
if (originBranch) {
373+
// Reset countdown on manual refresh so the user doesn't see an immediate auto-refresh.
374+
originAutoRefreshDeadlineRef.current = Date.now() + ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS;
375+
setOriginAutoRefreshSecondsRemaining(
376+
Math.ceil(ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS / 1000)
377+
);
378+
379+
if (originAutoRefreshInFlightRef.current) {
380+
setRefreshTrigger((prev) => prev + 1);
381+
return;
382+
}
383+
384+
originAutoRefreshInFlightRef.current = true;
385+
386+
api.workspace
387+
.executeBash({
388+
workspaceId,
389+
script: `git fetch origin ${originBranch} --quiet || true`,
390+
options: {
391+
timeout_secs: 30,
392+
},
393+
})
394+
.catch((err) => {
395+
console.debug("ReviewPanel origin fetch failed", err);
396+
})
397+
.finally(() => {
398+
originAutoRefreshInFlightRef.current = false;
399+
setRefreshTrigger((prev) => prev + 1);
400+
});
401+
402+
return;
403+
}
404+
405+
setRefreshTrigger((prev) => prev + 1);
406+
};
407+
408+
const handleRefresh = () => {
409+
handleRefreshRef.current();
410+
};
229411
useEffect(() => {
230412
if (focusTrigger && focusTrigger > 0) {
231413
panelRef.current?.focus();
@@ -730,7 +912,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
730912
const handleKeyDown = (e: KeyboardEvent) => {
731913
if (matchesKeybind(e, KEYBINDS.REFRESH_REVIEW)) {
732914
e.preventDefault();
733-
setRefreshTrigger((prev) => prev + 1);
915+
handleRefreshRef.current();
734916
} else if (matchesKeybind(e, KEYBINDS.FOCUS_REVIEW_SEARCH)) {
735917
e.preventDefault();
736918
searchInputRef.current?.focus();
@@ -765,7 +947,8 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
765947
filters={filters}
766948
stats={stats}
767949
onFiltersChange={setFilters}
768-
onRefresh={() => setRefreshTrigger((prev) => prev + 1)}
950+
onRefresh={handleRefresh}
951+
autoRefreshSecondsRemaining={originAutoRefreshSecondsRemaining}
769952
isLoading={
770953
diffState.status === "loading" || diffState.status === "refreshing" || isLoadingTree
771954
}

src/common/orpc/schemas/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,14 @@ export const workspace = {
322322
})
323323
),
324324
},
325+
onFileChanges: {
326+
input: z.object({ workspaceId: z.string() }),
327+
output: eventIterator(
328+
z.object({
329+
paths: z.array(z.string()),
330+
})
331+
),
332+
},
325333
activity: {
326334
list: {
327335
input: z.void(),

src/node/orpc/router.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";
1616

1717
import { createRuntime } from "@/node/runtime/runtimeFactory";
1818
import { readPlanFile } from "@/node/utils/runtime/helpers";
19+
import { isSSHRuntime } from "@/common/types/runtime";
20+
import { workspaceFileWatcherService } from "@/node/services/workspaceFileWatcherService";
1921
import { createAsyncEventQueue } from "@/common/utils/asyncEventIterator";
2022

2123
export const router = (authToken?: string) => {
@@ -543,6 +545,30 @@ export const router = (authToken?: string) => {
543545
service.off("metadata", onMetadata);
544546
}
545547
}),
548+
onFileChanges: t
549+
.input(schemas.workspace.onFileChanges.input)
550+
.output(schemas.workspace.onFileChanges.output)
551+
.handler(async function* ({ context, input }) {
552+
const metadata = await context.workspaceService.getInfo(input.workspaceId);
553+
if (!metadata) return;
554+
if (isSSHRuntime(metadata.runtimeConfig)) return;
555+
556+
const { push, iterate, end } = createAsyncEventQueue<{ paths: string[] }>();
557+
558+
const unsubscribe = await workspaceFileWatcherService.watchWorkspace(
559+
metadata.namedWorkspacePath,
560+
(paths) => {
561+
push({ paths });
562+
}
563+
);
564+
565+
try {
566+
yield* iterate();
567+
} finally {
568+
end();
569+
unsubscribe();
570+
}
571+
}),
546572
activity: {
547573
list: t
548574
.input(schemas.workspace.activity.list.input)

0 commit comments

Comments
 (0)