fix: harden and de-slop the merged sidebar/chat/settings batch
Post-merge follow-ups for #2740 #2735 #2734 #2690 #2676 #2738 #2684 #2689 #2733 #2739 #2462 #2687 #2736 #2618 #2697, plus three regressions found while reviewing them: - ctrl/cmd+digit while typing no longer switches session tabs (#2503 was still open in practice: the guard only covered the mod+alt surface binding) - Shiki template-call sanitizer now covers every bundled grammar, including the js/ts aliases and embedding grammars; timed-out highlight requests are memoized and no longer cancel unrelated in-flight requests - settings flush on suspend uses keepalive and also fires on Capacitor appStateChange; keeps the selected model persisted across mode switches - remote-only branches fetch before checkout; range helpers fail clearly - git status invalidation now fires for runtime adapters too - settings number inputs and select triggers size in ch so they scale with the interface font - recent-activity timestamps tick from one list-level ticker - Markdown preview find goes through the shared find_in_file keybind with containment, no longer counts its own bar, and debounces observer runs - #2676 reverted; #2524 fixed by fading the sticky header's own background instead of overlaying the content below it - sticky group headers in the model picker and sidebar render again (oc-sticky-fade-scroller class restored after9b9d7069c) - project switcher names are left-aligned again (wrapper lost in26dbc2f30) - tool card quick-open icon is always visible and opens the same line as the expanded card's button - tautological tests replaced or removed; new oxlint findings fixed
This commit is contained in:
@@ -74,4 +74,4 @@ make every row observe unrelated streaming updates.
|
||||
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
|
||||
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
|
||||
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
|
||||
- Pending-permission/question row badges fade with the same hover/menu-open rule as the date label, except on always-visible-actions rows, which reserve permanent padding and keep the badges shown (`selectRowBadgeVisibilityClass` in `sessions/sessionNodeItemUtils.ts`).
|
||||
- Pending-permission/question row badges fade with the same hover/menu-open rule as the date label, except on non-VS Code always-visible-actions rows, which reserve permanent padding and keep the badges shown. VS Code hover-reveals its actions over the row's right edge even under `alwaysShowActions`, so its badges keep fading (`selectRowBadgeVisibilityClass` in `sessions/sessionNodeItemUtils.ts`).
|
||||
|
||||
@@ -248,7 +248,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
hideTopScrollShadow={!enableStickyFade}
|
||||
scrollShadowSize={96}
|
||||
outerClassName="flex-1 min-h-0"
|
||||
className="oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]"
|
||||
className="oc-sidebar-scroller oc-sticky-fade-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]"
|
||||
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
|
||||
>
|
||||
{model.topContent}
|
||||
|
||||
@@ -295,7 +295,9 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
<DropdownMenuContent align="start" className="max-h-[70vh] min-w-[220px] overflow-y-auto">
|
||||
{projectPickerOptions?.map((option) => (
|
||||
<DropdownMenuItem key={option.id} onClick={() => onProjectSelect?.(option.id)} className="flex items-center justify-between gap-3" title={option.projectDescription}>
|
||||
<ProjectHeaderIdentity {...option} />
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ProjectHeaderIdentity {...option} />
|
||||
</span>
|
||||
{option.id === id ? <Icon name="check" className="h-4 w-4 flex-shrink-0 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
@@ -70,6 +70,27 @@ type RenderExtras = SessionNodeRenderExtras;
|
||||
|
||||
const MAX_VISIBLE_RECENT_SESSIONS = 7;
|
||||
|
||||
const RELATIVE_TIME_TICK_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* One ticker for the whole Recent list. The rows render their compact
|
||||
* timestamp ("5m") at render time, and the row memo only re-renders on
|
||||
* session changes, so without a tick the label freezes at the value it had
|
||||
* when the row mounted. A single minute interval per list keeps every
|
||||
* visible row current at a cost independent of the row count — never one
|
||||
* interval per row.
|
||||
*/
|
||||
const useRelativeTimeTick = (): number => {
|
||||
const [tick, setTick] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTick((previous) => previous + 1);
|
||||
}, RELATIVE_TIME_TICK_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
return tick;
|
||||
};
|
||||
|
||||
export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
const {
|
||||
sections,
|
||||
@@ -119,6 +140,8 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
});
|
||||
}, [batchSize]);
|
||||
|
||||
const relativeTimeTick = useRelativeTimeTick();
|
||||
|
||||
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
|
||||
const subtreeContainsEditing = new Set<string>();
|
||||
collectSubtreeContainingId(nodes, props.editingId, subtreeContainsEditing);
|
||||
@@ -134,6 +157,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '',
|
||||
relativeTimeTick,
|
||||
childRenderExtrasFor,
|
||||
});
|
||||
|
||||
@@ -141,9 +165,10 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
|
||||
relativeTimeTick,
|
||||
childRenderExtrasFor,
|
||||
});
|
||||
}, [props.editingId, props.openSidebarMenuKey]);
|
||||
}, [props.editingId, props.openSidebarMenuKey, relativeTimeTick]);
|
||||
|
||||
const visibleSections = sections.filter((section) => section.items.length > 0 || section.key === 'chats');
|
||||
if (visibleSections.length === 0) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const source = readFileSync(new URL('./SessionNodeItem.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('SessionNodeItem recent-activity timestamp', () => {
|
||||
test('the recent activity rows render the compact timestamp in the inline metadata slot', () => {
|
||||
// The right-slot guard must open for recent rows even when no activity,
|
||||
// goal glyph, or branch marker is present.
|
||||
const guard = source.indexOf("showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent'");
|
||||
expect(guard).toBeGreaterThan(-1);
|
||||
// The recent-only block sits inside that slot…
|
||||
const guardOpen = source.indexOf("{renderContext === 'recent' ? (", guard);
|
||||
expect(guardOpen).toBeGreaterThan(guard);
|
||||
// …and the compact label rendered there is the first one after it.
|
||||
const label = source.indexOf('{sessionCompactUpdatedLabel}', guardOpen);
|
||||
expect(label).toBeGreaterThan(guardOpen);
|
||||
// The only later occurrence is the pre-existing row tooltip (which shows
|
||||
// the full date), not a second inline render.
|
||||
const tooltipLabel = source.indexOf('{sessionCompactUpdatedLabel}', label + 1);
|
||||
expect(tooltipLabel).toBeGreaterThan(label);
|
||||
expect(source.indexOf('title={sessionUpdatedLabel}', tooltipLabel - 80)).toBeGreaterThan(-1);
|
||||
});
|
||||
|
||||
test('the timestamp shares the hover-fade of the other metadata so revealed actions never overlap it', () => {
|
||||
const guard = source.indexOf("showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent'");
|
||||
// The slot content fades out while the row is hovered (hideOnHoverClass)
|
||||
// and while the row menu is open — the same span that now carries the
|
||||
// recent timestamp.
|
||||
const hideOnHover = source.indexOf('hideOnHoverClass', guard);
|
||||
expect(hideOnHover).toBeGreaterThan(guard);
|
||||
expect(hideOnHover).toBeLessThan(source.indexOf("{renderContext === 'recent' ? (", guard));
|
||||
});
|
||||
|
||||
test('the compact label uses the existing i18n-backed relative time helper', () => {
|
||||
// formatSessionCompactDateLabel (already used by touch runtimes and the
|
||||
// row tooltip) is the source of the label — no new formatting code.
|
||||
expect(source.indexOf('const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);')).toBeGreaterThan(-1);
|
||||
expect(source.indexOf('{sessionCompactUpdatedLabel}')).toBeGreaterThan(-1);
|
||||
});
|
||||
});
|
||||
@@ -117,6 +117,12 @@ export type SessionNodeItemProps = {
|
||||
* if no menu is open. Only one row can have its menu open at a time.
|
||||
*/
|
||||
menuOpenSessionId: string | null;
|
||||
/**
|
||||
* Bumped once a minute by the Recent list so the compact relative
|
||||
* timestamp rendered below recomputes instead of freezing at the value it
|
||||
* had when the row first mounted.
|
||||
*/
|
||||
relativeTimeTick?: number;
|
||||
/**
|
||||
* Precomputed structural key for this node. Encodes the IDs and child
|
||||
* counts of all descendants so a reference-only change to `node` (e.g.
|
||||
@@ -1381,7 +1387,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
||||
{alwaysShowActions ? (
|
||||
// Touch runtimes have no hover tooltip, so the compact
|
||||
// date stays inline there.
|
||||
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 text-[0.72rem] text-muted-foreground/75">
|
||||
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 typography-micro text-muted-foreground/75">
|
||||
{showActivityDuration ? (
|
||||
<SessionActivityDuration sessionId={session.id} running={isStreaming} />
|
||||
) : (
|
||||
@@ -1410,7 +1416,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
||||
<SessionActivityDuration
|
||||
sessionId={session.id}
|
||||
running={isStreaming}
|
||||
className="text-[0.72rem]"
|
||||
className="typography-micro"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -1430,7 +1436,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
||||
them, so the revealed row actions never
|
||||
overlap it. */}
|
||||
{renderContext === 'recent' ? (
|
||||
<span className="flex-shrink-0 text-[0.72rem] leading-none text-muted-foreground/75 tabular-nums">
|
||||
<span className="flex-shrink-0 typography-micro leading-none text-muted-foreground/75 tabular-nums">
|
||||
{sessionCompactUpdatedLabel}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -1716,6 +1722,7 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
|
||||
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
|
||||
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
|
||||
if (prev.nodeStructureKey !== next.nodeStructureKey) return false;
|
||||
if (prev.relativeTimeTick !== next.relativeTimeTick) return false;
|
||||
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
|
||||
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
|
||||
|
||||
|
||||
@@ -178,6 +178,7 @@ export function SessionTreeItem({
|
||||
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_CONTAINS_EDITING}
|
||||
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
|
||||
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
|
||||
relativeTimeTick={renderExtras?.relativeTimeTick}
|
||||
>
|
||||
{node.children.map((child) => (
|
||||
<SessionTreeItem
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('selectFolderRootNodes', () => {
|
||||
describe('selectRowBadgeVisibilityClass', () => {
|
||||
const hideOnHoverClass = 'group-hover:opacity-0 group-focus-within:opacity-0';
|
||||
|
||||
test('hides the badge while hover-revealed actions are shown, like the date label (#2284)', () => {
|
||||
test('defers to the caller hover rule so the badge fades with the date label (#2284)', () => {
|
||||
const className = selectRowBadgeVisibilityClass({
|
||||
actionsAlwaysVisible: false,
|
||||
menuOpen: false,
|
||||
@@ -178,18 +178,17 @@ describe('selectRowBadgeVisibilityClass', () => {
|
||||
});
|
||||
|
||||
expect(className).toContain(hideOnHoverClass);
|
||||
expect(className).toContain('transition-opacity');
|
||||
});
|
||||
|
||||
test('hides the badge while the row menu keeps the actions visible without hover', () => {
|
||||
test('hides the badge unconditionally while the row menu keeps the actions visible without hover', () => {
|
||||
const className = selectRowBadgeVisibilityClass({
|
||||
actionsAlwaysVisible: false,
|
||||
menuOpen: true,
|
||||
hideOnHoverClass,
|
||||
});
|
||||
|
||||
expect(className).toContain('opacity-0');
|
||||
expect(className).not.toContain('group-hover');
|
||||
expect(className).not.toBe('');
|
||||
expect(className).not.toContain(hideOnHoverClass);
|
||||
});
|
||||
|
||||
test('keeps the badge always visible when actions have reserved permanent padding', () => {
|
||||
|
||||
@@ -19,6 +19,12 @@ export type SessionNodeChildRenderExtras = {
|
||||
subtreeContainsEditing: Set<string>;
|
||||
menuOpenSessionId: string | null;
|
||||
nodeStructureKey: string;
|
||||
/**
|
||||
* Bumped once a minute by the owning list so rows that render a relative
|
||||
* timestamp ("5m") re-render and recompute it. Only the Recent list
|
||||
* supplies it; elsewhere the rows carry no time-dependent label.
|
||||
*/
|
||||
relativeTimeTick?: number;
|
||||
};
|
||||
|
||||
export type SessionNodeRenderExtras<TNode = SessionNode> = SessionNodeChildRenderExtras & {
|
||||
|
||||
Reference in New Issue
Block a user