Skip to content

Commit b00cb7b

Browse files
OpenSource03claude
andcommitted
feat: scoped permission rules, local settings source, scroll sync, and git status fix
- Add scoped "always allow" dropdown to permission prompt with per-destination rule persistence (session, local, project, user settings) - Forward updatedPermissions through SDK permission bridge for rule saving - Include "local" in settingSources for all SDK query paths - Extract top scroll progress to a pure helper and sync it on programmatic scrolls - Fix useGitStatus loading flag race with separate tracking ref - Refine glass morphism gradient tuning (lighter titlebar, softer top fade) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dd32b80 commit b00cb7b

14 files changed

Lines changed: 280 additions & 67 deletions

File tree

electron/src/ipc/claude-sessions.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ function fileCheckpointOptions(): Record<string, unknown> {
2222
}
2323

2424
type PermissionResult =
25-
| { behavior: "allow"; updatedInput?: Record<string, unknown> }
25+
| { behavior: "allow"; updatedInput?: Record<string, unknown>; updatedPermissions?: unknown[] }
2626
| { behavior: "deny"; message: string };
2727

2828
interface PendingPermission {
@@ -338,7 +338,7 @@ async function revalidateClaudeModelsCache(cwd?: string): Promise<{ models: Arra
338338
cwd: cwd?.trim() || os.homedir(),
339339
includePartialMessages: true,
340340
thinking: buildThinkingConfig(),
341-
settingSources: ["user", "project"],
341+
settingSources: ["user", "project", "local"],
342342
pathToClaudeCodeExecutable: attempt.cliPath,
343343
...fileCheckpointOptions(),
344344
stderr: (data: string) => {
@@ -490,7 +490,7 @@ async function restartSession(
490490
includePartialMessages: true,
491491
thinking: buildThinkingConfig(),
492492
canUseTool,
493-
settingSources: ["user", "project"],
493+
settingSources: ["user", "project", "local"],
494494
pathToClaudeCodeExecutable: cliPath,
495495
...fileCheckpointOptions(),
496496
resume: sessionId,
@@ -571,6 +571,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
571571
requestId,
572572
toolUseId: context.toolUseID,
573573
reason: context.decisionReason,
574+
hasSuggestions: Array.isArray(context.suggestions) && context.suggestions.length > 0,
574575
});
575576
safeSend(getMainWindow,"claude:permission_request", {
576577
_sessionId: sessionId,
@@ -591,7 +592,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
591592
includePartialMessages: true,
592593
thinking: buildThinkingConfig(),
593594
canUseTool,
594-
settingSources: ["user", "project"],
595+
settingSources: ["user", "project", "local"],
595596
pathToClaudeCodeExecutable: cliPath,
596597
...fileCheckpointOptions(),
597598
stderr: (data: string) => {
@@ -673,14 +674,15 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
673674
});
674675

675676
ipcMain.handle("claude:permission_response", async (_event, {
676-
sessionId, requestId, behavior, toolInput, newPermissionMode,
677+
sessionId, requestId, behavior, toolInput, newPermissionMode, updatedPermissions,
677678
}: {
678679
sessionId: string;
679680
requestId: string;
680681
behavior: string;
681682
toolUseId: string;
682683
toolInput: Record<string, unknown> | undefined;
683684
newPermissionMode?: string;
685+
updatedPermissions?: unknown[];
684686
}) => {
685687
const session = sessions.get(sessionId);
686688
if (!session) {
@@ -693,7 +695,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
693695
return { error: "No pending permission request" };
694696
}
695697
session.pendingPermissions.delete(requestId);
696-
log("PERMISSION_RESPONSE", `session=${sessionId.slice(0, 8)} behavior=${behavior} requestId=${requestId} newMode=${newPermissionMode ?? "none"}`);
698+
log("PERMISSION_RESPONSE", `session=${sessionId.slice(0, 8)} behavior=${behavior} requestId=${requestId} newMode=${newPermissionMode ?? "none"} hasUpdatedPermissions=${!!updatedPermissions?.length}`);
697699

698700
if (newPermissionMode && session.queryHandle) {
699701
try {
@@ -705,7 +707,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
705707
}
706708

707709
if (behavior === "allow") {
708-
pending.resolve({ behavior: "allow", updatedInput: toolInput });
710+
pending.resolve({ behavior: "allow", updatedInput: toolInput, updatedPermissions });
709711
} else {
710712
// Pass user-provided rejection reason (from plan feedback) to the SDK so the model can adjust
711713
const denyMsg = toolInput?.denyMessage;

electron/src/ipc/title-gen.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ export function register(): void {
304304
model: "haiku",
305305
extraOptions: {
306306
systemPrompt: { type: "preset", preset: "claude_code" },
307-
settingSources: ["project", "user"],
307+
settingSources: ["project", "user", "local"],
308308
},
309309
});
310310
return { message: result, error };

electron/src/lib/__tests__/chat-scroll.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
BOTTOM_LOCK_THRESHOLD_PX,
4+
TOP_SCROLL_FADE_RANGE_PX,
45
getDistanceFromBottom,
6+
getTopScrollProgress,
57
isWithinBottomLockThreshold,
68
shouldUnlockBottomLock,
79
} from "../../../../src/lib/chat-scroll";
@@ -48,4 +50,13 @@ describe("chat scroll helpers", () => {
4850
clientHeight: 300,
4951
})).toBe(true);
5052
});
53+
54+
it("keeps the top shadow hidden at the top and fully visible at the fade range", () => {
55+
expect(getTopScrollProgress(0)).toBe(0);
56+
expect(getTopScrollProgress(TOP_SCROLL_FADE_RANGE_PX)).toBe(1);
57+
});
58+
59+
it("eases the top shadow progress through the fade ramp", () => {
60+
expect(getTopScrollProgress(TOP_SCROLL_FADE_RANGE_PX / 2)).toBe(0.5);
61+
});
5162
});

electron/src/preload.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ contextBridge.exposeInMainWorld("claude", {
6969
ipcRenderer.on("claude:permission_request", listener);
7070
return () => ipcRenderer.removeListener("claude:permission_request", listener);
7171
},
72-
respondPermission: (sessionId: string, requestId: string, behavior: string, toolUseId: string, toolInput: unknown, newPermissionMode?: string) =>
73-
ipcRenderer.invoke("claude:permission_response", { sessionId, requestId, behavior, toolUseId, toolInput, newPermissionMode }),
72+
respondPermission: (sessionId: string, requestId: string, behavior: string, toolUseId: string, toolInput: unknown, newPermissionMode?: string, updatedPermissions?: unknown[]) =>
73+
ipcRenderer.invoke("claude:permission_response", { sessionId, requestId, behavior, toolUseId, toolInput, newPermissionMode, updatedPermissions }),
7474
setPermissionMode: (sessionId: string, permissionMode: string) =>
7575
ipcRenderer.invoke("claude:set-permission-mode", { sessionId, permissionMode }),
7676
setModel: (sessionId: string, model: string) =>

shared/types/engine.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,15 @@ export type AppPermissionBehavior = "allow" | "deny" | "allowForSession";
4343
/**
4444
* Canonical signature for responding to a tool permission prompt.
4545
* All engines must implement this — unused params can be ignored.
46+
*
47+
* `updatedPermissions` is forwarded to the SDK to persist allow rules
48+
* to the chosen settings file (session / local / project / user).
4649
*/
4750
export type RespondPermissionFn = (
4851
behavior: AppPermissionBehavior,
4952
updatedInput?: Record<string, unknown>,
5053
newPermissionMode?: string,
54+
updatedPermissions?: unknown[],
5155
) => Promise<void>;
5256

5357
/**

src/components/AppLayout.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useRef, useEffect, useState } from "react";
1+
import { useCallback, useRef, useEffect, useLayoutEffect, useState } from "react";
22
import { PanelLeft } from "lucide-react";
33
import { Button } from "@/components/ui/button";
44
import { normalizeRatios } from "@/hooks/useSettings";
@@ -299,14 +299,16 @@ Link: ${issue.url}`;
299299
const chatIslandRef = useRef<HTMLDivElement>(null);
300300
const lastTopScrollProgressRef = useRef(0);
301301

302-
// Reset on session change — new/blank chats start at scroll 0.
303302
useEffect(() => {
304-
lastTopScrollProgressRef.current = 0;
305-
chatIslandRef.current?.style.setProperty("--chat-top-progress", "0");
306303
// Grabbed elements are session-specific context — discard on switch
307304
setGrabbedElements([]);
308305
}, [manager.activeSessionId]);
309306

307+
useLayoutEffect(() => {
308+
lastTopScrollProgressRef.current = 0;
309+
chatIslandRef.current?.style.setProperty("--chat-top-progress", "0");
310+
}, [manager.activeSessionId]);
311+
310312
const handleTopScrollProgress = useCallback((progress: number) => {
311313
const clamped = Math.max(0, Math.min(1, progress));
312314
if (Math.abs(lastTopScrollProgressRef.current - clamped) < 0.005) return;
@@ -339,16 +341,16 @@ Link: ${issue.url}`;
339341
: "var(--background)";
340342
// Keep titlebar veil/shadow behavior consistent across island and non-island layouts.
341343
const titlebarOpacity = isLightGlass
342-
? Math.round(74 + 18 * spaceOpacity)
343-
: Math.round(30 + 50 * spaceOpacity); // 30–80% or brighter in light glass
344+
? Math.round(69 + 14 * spaceOpacity)
345+
: Math.round(23 + 35 * spaceOpacity);
344346
const topFadeShadowOpacity = isLightGlass
345-
? Math.round(18 + 16 * spaceOpacity)
346-
: Math.round(28 + 28 * spaceOpacity);
347+
? Math.round(13 + 15 * spaceOpacity)
348+
: Math.round(21 + 26 * spaceOpacity);
347349
const titlebarSurfaceColor =
348-
`linear-gradient(to bottom, color-mix(in oklab, ${chatSurfaceColor} ${titlebarOpacity}%, transparent) 0%, color-mix(in oklab, ${chatSurfaceColor} ${Math.max(titlebarOpacity - 8, 24)}%, transparent) 52%, color-mix(in oklab, ${chatSurfaceColor} ${Math.max(titlebarOpacity - 22, 12)}%, transparent) 76%, transparent 100%)`;
350+
`linear-gradient(to bottom, color-mix(in oklab, ${chatSurfaceColor} ${titlebarOpacity}%, transparent) 0%, color-mix(in oklab, ${chatSurfaceColor} ${Math.max(titlebarOpacity - 3, 23)}%, transparent) 34%, color-mix(in oklab, ${chatSurfaceColor} ${Math.max(titlebarOpacity - 14, 11)}%, transparent) 68%, transparent 100%)`;
349351
const topFadeBackground = isIsland
350-
? `linear-gradient(to bottom, color-mix(in oklab, ${chatSurfaceColor} 100%, black 9%) 0%, color-mix(in oklab, ${chatSurfaceColor} 97%, black 4%) 20%, color-mix(in oklab, ${chatSurfaceColor} 92%, transparent) 54%, transparent 100%), radial-gradient(140% 92% at 50% 0%, color-mix(in srgb, black ${topFadeShadowOpacity}%, transparent) 0%, transparent 74%)`
351-
: `linear-gradient(to bottom, ${chatSurfaceColor} 0%, ${chatSurfaceColor} 42%, color-mix(in oklab, ${chatSurfaceColor} 90%, transparent) 68%, transparent 100%), radial-gradient(145% 96% at 50% 0%, color-mix(in srgb, black ${topFadeShadowOpacity}%, transparent) 0%, transparent 76%)`;
352+
? `linear-gradient(to bottom, color-mix(in oklab, ${chatSurfaceColor} 100%, black 4.5%) 0%, color-mix(in oklab, ${chatSurfaceColor} 97.5%, black 1.75%) 18%, color-mix(in oklab, ${chatSurfaceColor} 93.5%, transparent) 48%, transparent 100%), radial-gradient(138% 88% at 50% 0%, color-mix(in srgb, black ${topFadeShadowOpacity}%, transparent) 0%, transparent 70%)`
353+
: `linear-gradient(to bottom, ${chatSurfaceColor} 0%, ${chatSurfaceColor} 34%, color-mix(in oklab, ${chatSurfaceColor} 90.5%, transparent) 60%, transparent 100%), radial-gradient(142% 92% at 50% 0%, color-mix(in srgb, black ${topFadeShadowOpacity}%, transparent) 0%, transparent 72%)`;
352354
const bottomFadeBackground = `linear-gradient(to top, ${chatSurfaceColor}, transparent)`;
353355

354356
const { activeTools } = settings;
@@ -462,7 +464,7 @@ Link: ${issue.url}`;
462464
{/* Island: gradient starts at top-0 (behind header, subtle bleed). Flat: starts at top-10 (right below header) so full gradient is visible and strong. */}
463465
<div
464466
className={`pointer-events-none absolute inset-x-0 top-0 z-[5] ${
465-
isIsland ? "h-24" : "h-28"
467+
isIsland ? "h-20" : "h-24"
466468
}`}
467469
style={{
468470
opacity: "calc(var(--chat-fade-strength, 1) * var(--chat-top-progress, 0))",

src/components/ChatView.tsx

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { TextShimmer } from "@/components/ui/text-shimmer";
1717
import {
1818
BOTTOM_LOCK_THRESHOLD_PX,
1919
USER_SCROLL_INTENT_WINDOW_MS,
20+
getTopScrollProgress,
2021
isWithinBottomLockThreshold,
2122
shouldUnlockBottomLock,
2223
} from "@/lib/chat-scroll";
@@ -82,8 +83,6 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
8283
onScrolledFromTopRef.current = onScrolledFromTop;
8384
const onTopScrollProgressRef = useRef(onTopScrollProgress);
8485
onTopScrollProgressRef.current = onTopScrollProgress;
85-
const topProgressRafRef = useRef<number | null>(null);
86-
const pendingTopProgressRef = useRef(0);
8786
const lastTopProgressRef = useRef(-1);
8887
const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
8988
const lastSessionIdRef = useRef<string | undefined | null>(sessionId);
@@ -94,6 +93,9 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
9493
);
9594

9695
const getViewport = useCallback(() => {
96+
if (viewportRef.current && !viewportRef.current.isConnected) {
97+
viewportRef.current = null;
98+
}
9799
if (!viewportRef.current) {
98100
viewportRef.current = scrollAreaRef.current?.querySelector<HTMLElement>(
99101
"[data-radix-scroll-area-viewport]",
@@ -167,6 +169,22 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
167169
};
168170
}, [expandRenderedHistory, messages.length, visibleStartIndex]);
169171

172+
const publishTopProgress = useCallback((progress: number) => {
173+
const clamped = Math.max(0, Math.min(1, progress));
174+
const last = lastTopProgressRef.current;
175+
if (last < 0 || Math.abs(clamped - last) >= 0.01 || clamped === 0 || clamped === 1) {
176+
lastTopProgressRef.current = clamped;
177+
onTopScrollProgressRef.current?.(clamped);
178+
}
179+
}, []);
180+
181+
const syncViewportState = useCallback((viewport: HTMLElement) => {
182+
const { scrollTop, scrollHeight, clientHeight } = viewport;
183+
onScrolledFromTopRef.current?.(scrollTop > 4);
184+
publishTopProgress(getTopScrollProgress(scrollTop));
185+
return { scrollTop, scrollHeight, clientHeight };
186+
}, [publishTopProgress]);
187+
170188
useLayoutEffect(() => {
171189
const anchor = prependAnchorRef.current;
172190
if (!anchor) return;
@@ -176,8 +194,9 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
176194

177195
const delta = viewport.scrollHeight - anchor.scrollHeight;
178196
viewport.scrollTop = anchor.scrollTop + delta;
197+
syncViewportState(viewport);
179198
prependAnchorRef.current = null;
180-
}, [getViewport, visibleStartIndex]);
199+
}, [getViewport, syncViewportState, visibleStartIndex]);
181200

182201
const visibleMessages = useMemo(
183202
() => visibleStartIndex === 0 ? messages : messages.slice(visibleStartIndex),
@@ -196,6 +215,7 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
196215
if (shouldForce || Math.abs(targetViewport.scrollTop - targetScrollTop) > 1) {
197216
targetViewport.scrollTop = targetScrollTop;
198217
}
218+
syncViewportState(targetViewport);
199219
};
200220

201221
suppressScrollTrackingRef.current += 1;
@@ -205,7 +225,7 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
205225
if (nextViewport) applyBottom(nextViewport);
206226
suppressScrollTrackingRef.current = Math.max(0, suppressScrollTrackingRef.current - 1);
207227
});
208-
}, [getViewport]);
228+
}, [getViewport, syncViewportState]);
209229

210230
const clearSettleTimers = useCallback(() => {
211231
if (settleRafRef.current !== null) {
@@ -229,32 +249,21 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
229249
scheduleSettleToBottom();
230250
}, [messages.length, isProcessing, scheduleSettleToBottom]);
231251

252+
useLayoutEffect(() => {
253+
if (!sessionId) return;
254+
bottomLockedRef.current = true;
255+
userScrollIntentUntilRef.current = 0;
256+
lastTopProgressRef.current = -1;
257+
jumpToBottom({ force: true });
258+
}, [jumpToBottom, sessionId]);
259+
232260
// Track whether user is near the bottom; this drives sticky auto-follow behavior.
233261
useEffect(() => {
234262
const viewport = getViewport();
235263
if (!viewport) return;
236264

237-
const flushTopProgress = () => {
238-
topProgressRafRef.current = null;
239-
const progress = pendingTopProgressRef.current;
240-
const last = lastTopProgressRef.current;
241-
if (last < 0 || Math.abs(progress - last) >= 0.01 || progress === 0 || progress === 1) {
242-
lastTopProgressRef.current = progress;
243-
onTopScrollProgressRef.current?.(progress);
244-
}
245-
};
246-
247265
const updateAutoFollow = () => {
248-
const { scrollTop, scrollHeight, clientHeight } = viewport;
249-
// Always report scroll-from-top state (controls header shadow visibility)
250-
onScrolledFromTopRef.current?.(scrollTop > 4);
251-
// Smooth top ramp: slower range + smoothstep easing to avoid abrupt header/fade jumps.
252-
const normalized = Math.max(0, Math.min(1, scrollTop / 96));
253-
const easedProgress = normalized * normalized * (3 - 2 * normalized);
254-
pendingTopProgressRef.current = easedProgress;
255-
if (topProgressRafRef.current === null) {
256-
topProgressRafRef.current = window.requestAnimationFrame(flushTopProgress);
257-
}
266+
const { scrollTop, scrollHeight, clientHeight } = syncViewportState(viewport);
258267
// Auto-follow tracking is suppressed during programmatic scrolls to
259268
// prevent them from unlocking sticky follow mode
260269
if (suppressScrollTrackingRef.current > 0) {
@@ -322,14 +331,11 @@ export const ChatView = memo(function ChatView({ messages, isProcessing, showThi
322331
viewport.removeEventListener("pointerdown", markUserScrollIntent);
323332
viewport.removeEventListener("keydown", handleKeydown);
324333
viewport.removeEventListener("scroll", throttledUpdateAutoFollow);
325-
if (topProgressRafRef.current !== null) {
326-
window.cancelAnimationFrame(topProgressRafRef.current);
327-
topProgressRafRef.current = null;
328-
}
329334
};
330-
}, [messages.length, clearSettleTimers, expandRenderedHistory, getViewport, visibleStartIndex]);
335+
}, [messages.length, clearSettleTimers, expandRenderedHistory, getViewport, syncViewportState, visibleStartIndex]);
331336

332-
// Force-scroll to bottom on session switch, bypassing the proximity guard
337+
// Force-scroll to bottom again after session changes so late layout shifts
338+
// still settle at the bottom even after the immediate layout pass above.
333339
useEffect(() => {
334340
if (!sessionId) return;
335341
bottomLockedRef.current = true;

0 commit comments

Comments
 (0)