mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Refine tool call summaries and scrolling (#2069)
* feat(app): refine tool call summaries and scrolling Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism. * refactor(app): reuse tool call height constant * fix(app): preserve tool call summary state * fix(app): refresh web tool groups on expand * fix(app): preserve grouped tool errors
This commit is contained in:
@@ -33,6 +33,24 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
||||
});
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
interface HistoryRowDisplayVariants {
|
||||
regular?: StreamItem;
|
||||
compact?: StreamItem;
|
||||
}
|
||||
|
||||
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
|
||||
|
||||
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
|
||||
let variants = historyRowDisplayVariants.get(item);
|
||||
if (!variants) {
|
||||
variants = {};
|
||||
historyRowDisplayVariants.set(item, variants);
|
||||
}
|
||||
const key = compact ? "compact" : "regular";
|
||||
variants[key] ??= { ...item };
|
||||
return variants[key];
|
||||
}
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
}
|
||||
@@ -41,6 +59,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const {
|
||||
agentId,
|
||||
segments,
|
||||
historyRowRevision,
|
||||
liveHeadRowRevision,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
@@ -73,12 +93,33 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
|
||||
const historyRows = useMemo(() => {
|
||||
const historyItems = useMemo(() => {
|
||||
if (segments.historyVirtualized.length === 0) {
|
||||
return segments.historyMounted;
|
||||
}
|
||||
return [...segments.historyVirtualized, ...segments.historyMounted];
|
||||
}, [segments.historyMounted, segments.historyVirtualized]);
|
||||
// Keep unchanged item identities intact so live updates only rerender rows
|
||||
// whose projected content or local display state actually changed. A rare
|
||||
// breakpoint change intentionally refreshes the whole history window.
|
||||
const globallyRevisedHistoryRows = useMemo(() => {
|
||||
const globalDisplayState = historyRowRevision?.globalDisplayState ?? false;
|
||||
return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState));
|
||||
}, [historyItems, historyRowRevision?.globalDisplayState]);
|
||||
const displayStateHistoryRows = useMemo(
|
||||
() =>
|
||||
globallyRevisedHistoryRows.map((item) =>
|
||||
historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item,
|
||||
),
|
||||
[globallyRevisedHistoryRows, historyRowRevision?.displayStateById],
|
||||
);
|
||||
const historyRows = useMemo(
|
||||
() =>
|
||||
displayStateHistoryRows.map((item) =>
|
||||
historyRowRevision?.contentById.has(item.id) ? { ...item } : item,
|
||||
),
|
||||
[displayStateHistoryRows, historyRowRevision?.contentById],
|
||||
);
|
||||
|
||||
const clearNativeViewportSettling = useCallback(() => {
|
||||
if (nativeViewportSettlingFrameIdRef.current !== null) {
|
||||
@@ -307,12 +348,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
|
||||
const renderItem = useStableEvent(
|
||||
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
|
||||
const rendered = renderHistoryMountedRow(item, index, historyRows);
|
||||
const rendered = renderHistoryMountedRow(item, index, historyItems);
|
||||
return (rendered ?? null) as ReactElement | null;
|
||||
},
|
||||
);
|
||||
|
||||
const liveHeaderContent = useMemo(() => {
|
||||
// Stable render events read the latest expansion state; this revision makes
|
||||
// the memo invoke them again when that state changes.
|
||||
void liveHeadRowRevision;
|
||||
const liveHeadRows = segments.liveHead.map((item, index) => (
|
||||
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
|
||||
));
|
||||
@@ -331,7 +375,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
{liveAuxiliary}
|
||||
</Fragment>
|
||||
);
|
||||
}, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]);
|
||||
}, [
|
||||
boundary,
|
||||
listEmptyComponent,
|
||||
liveHeadRowRevision,
|
||||
renderLiveAuxiliary,
|
||||
renderLiveHeadRow,
|
||||
segments.liveHead,
|
||||
]);
|
||||
|
||||
const historyFooterContent = useMemo(() => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
@@ -344,12 +395,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
);
|
||||
}, [isLoadingOlderHistory]);
|
||||
|
||||
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
|
||||
// data or the live header changes, preserving the row identities above.
|
||||
return (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={historyRows}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
strictMode
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
vi.hoisted(() => {
|
||||
@@ -25,8 +25,6 @@ vi.hoisted(() => {
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@/components/use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
|
||||
|
||||
function userMessage(index: number): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
@@ -148,6 +146,59 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
|
||||
});
|
||||
|
||||
it("rerenders a stable live-head row when its revision changes", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const liveHead = [userMessage(1)];
|
||||
let label = "collapsed";
|
||||
const renderLiveHeadRow = vi.fn(() => <div>{label}</div>);
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [],
|
||||
liveHead,
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: false,
|
||||
hasLiveHead: true,
|
||||
},
|
||||
renderers: {
|
||||
...createRenderers(vi.fn()),
|
||||
renderLiveHeadRow,
|
||||
},
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 0 }));
|
||||
});
|
||||
expect(container.textContent).toContain("collapsed");
|
||||
|
||||
label = "expanded";
|
||||
act(() => {
|
||||
root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 1 }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("expanded");
|
||||
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fires near-history-start when the user scrolls near the top", async () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
|
||||
@@ -25,7 +25,6 @@ const USER_SCROLL_DELTA_EPSILON = 1;
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||
|
||||
const historyStartSlotStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
@@ -95,6 +94,7 @@ function isScrollContainerOverscrolledPastBottom(
|
||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||
const {
|
||||
segments,
|
||||
liveHeadRowRevision,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
@@ -131,11 +131,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const showDesktopWebScrollbar = !isMobileBreakpoint;
|
||||
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
contentRef,
|
||||
});
|
||||
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
|
||||
const {
|
||||
renderHistoryVirtualizedRow,
|
||||
@@ -540,10 +535,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
));
|
||||
}, [renderHistoryMountedRow, segments.historyMounted]);
|
||||
const liveHeadRows = useMemo(() => {
|
||||
void liveHeadRowRevision;
|
||||
return segments.liveHead.map((item, index) => (
|
||||
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
|
||||
));
|
||||
}, [renderLiveHeadRow, segments.liveHead]);
|
||||
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
|
||||
const liveAuxiliary = useMemo(() => {
|
||||
return renderLiveAuxiliary();
|
||||
}, [renderLiveAuxiliary]);
|
||||
@@ -564,47 +560,40 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
!liveAuxiliary;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={handleScrollContainerRef}
|
||||
data-testid="agent-chat-scroll"
|
||||
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
|
||||
style={scrollContainerStyle}
|
||||
>
|
||||
<div ref={handleContentRef} style={contentContainerStyle}>
|
||||
{historyStartSlot}
|
||||
{shouldUseVirtualizer ? (
|
||||
<div style={virtualRowsContainerStyle}>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const item = segments.historyVirtualized[virtualRow.index];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
{renderHistoryVirtualizedRow(
|
||||
item,
|
||||
virtualRow.index,
|
||||
segments.historyVirtualized,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{mountedHistoryRows}
|
||||
{liveHeadRows}
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
<div
|
||||
ref={handleScrollContainerRef}
|
||||
data-testid="agent-chat-scroll"
|
||||
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
|
||||
style={scrollContainerStyle}
|
||||
>
|
||||
<div ref={handleContentRef} style={contentContainerStyle}>
|
||||
{historyStartSlot}
|
||||
{shouldUseVirtualizer ? (
|
||||
<div style={virtualRowsContainerStyle}>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const item = segments.historyVirtualized[virtualRow.index];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
{renderHistoryVirtualizedRow(item, virtualRow.index, segments.historyVirtualized)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{mountedHistoryRows}
|
||||
{liveHeadRows}
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
{scrollbarOverlay}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,9 +51,17 @@ export interface StreamSegmentRenderers {
|
||||
renderLiveAuxiliary: () => ReactNode;
|
||||
}
|
||||
|
||||
export interface StreamHistoryRowRevision {
|
||||
contentById: { has(id: string): boolean };
|
||||
displayStateById: { has(id: string): boolean };
|
||||
globalDisplayState: boolean;
|
||||
}
|
||||
|
||||
export interface StreamRenderInput {
|
||||
agentId: string;
|
||||
segments: StreamRenderSegments;
|
||||
historyRowRevision?: StreamHistoryRowRevision;
|
||||
liveHeadRowRevision?: unknown;
|
||||
boundary: StreamHistoryBoundary;
|
||||
renderers: StreamSegmentRenderers;
|
||||
listEmptyComponent: ReactNode;
|
||||
|
||||
@@ -57,8 +57,11 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { ToolCallGroup } from "@/components/tool-call-group";
|
||||
import { compactToolCallRuns } from "@/tool-calls/grouping";
|
||||
import {
|
||||
prepareToolCallHistory,
|
||||
projectToolCallDetailLevel,
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
@@ -259,6 +262,7 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
];
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
|
||||
|
||||
function buildChatHistoryAttachment(input: {
|
||||
draftId: string;
|
||||
@@ -544,25 +548,38 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
|
||||
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
|
||||
const compactedToolCalls = useMemo(
|
||||
// Keep retained history outside the 48ms live-head flush path.
|
||||
const preparedToolCallHistory = useMemo(
|
||||
() => prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems),
|
||||
[effectiveStreamItems, toolCallDetailLevel],
|
||||
);
|
||||
const projectedToolCalls = useMemo(
|
||||
() =>
|
||||
compactToolCallRuns({
|
||||
projectToolCallDetailLevel({
|
||||
level: toolCallDetailLevel,
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
enabled: toolCallDetailLevel !== "detailed",
|
||||
preparedHistory: preparedToolCallHistory,
|
||||
isTurnActive: context.status === "running",
|
||||
}),
|
||||
[effectiveStreamHead, effectiveStreamItems, toolCallDetailLevel],
|
||||
[
|
||||
context.status,
|
||||
effectiveStreamHead,
|
||||
effectiveStreamItems,
|
||||
preparedToolCallHistory,
|
||||
toolCallDetailLevel,
|
||||
],
|
||||
);
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
agentStatus: context.status,
|
||||
tail: compactedToolCalls.tail,
|
||||
head: compactedToolCalls.head,
|
||||
tail: projectedToolCalls.tail,
|
||||
head: projectedToolCalls.head,
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [context.status, isMobile, compactedToolCalls.head, compactedToolCalls.tail]);
|
||||
}, [context.status, isMobile, projectedToolCalls.head, projectedToolCalls.tail]);
|
||||
const streamLayout = useMemo(
|
||||
() =>
|
||||
layoutStream({
|
||||
@@ -691,7 +708,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const renderSingleToolCallItem = useCallback(
|
||||
(item: Extract<StreamItem, { kind: "tool_call" }>, isLastInSequence: boolean) => {
|
||||
(
|
||||
item: Extract<StreamItem, { kind: "tool_call" }>,
|
||||
isLastInSequence: boolean,
|
||||
maxDetailHeight?: number,
|
||||
) => {
|
||||
const { payload } = item;
|
||||
|
||||
if (payload.source === "agent") {
|
||||
@@ -720,6 +741,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
metadata={data.metadata}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -735,6 +757,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
status={data.status}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -743,32 +766,43 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const renderToolCallItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||
const group = compactedToolCalls.groupsByHostId.get(item.id);
|
||||
const group = projectedToolCalls.groupsByHostId.get(item.id);
|
||||
if (!group) {
|
||||
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||
}
|
||||
const expanded = expandedToolCallGroupIds.has(group.id);
|
||||
const expanded = expandedToolCallGroupIds.has(group.run.id);
|
||||
return (
|
||||
<ToolCallGroup
|
||||
<OverviewToolCallGroupView
|
||||
group={group}
|
||||
presentation={toolCallDetailLevel === "concise" ? "concise" : "overview"}
|
||||
expanded={expanded}
|
||||
isCompact={isMobile}
|
||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||
onExpandedChange={setToolCallGroupExpanded}
|
||||
cwd={context.cwd}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
>
|
||||
{group.calls.map((call, index) => (
|
||||
<React.Fragment key={call.id}>
|
||||
{renderSingleToolCallItem(call, index === group.calls.length - 1)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ToolCallGroup>
|
||||
{expanded
|
||||
? group.run.calls.map((call, index) => (
|
||||
<React.Fragment key={call.id}>
|
||||
{renderSingleToolCallItem(
|
||||
call,
|
||||
index === group.run.calls.length - 1,
|
||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
: null}
|
||||
</OverviewToolCallGroupView>
|
||||
);
|
||||
},
|
||||
[
|
||||
compactedToolCalls.groupsByHostId,
|
||||
projectedToolCalls.groupsByHostId,
|
||||
context.cwd,
|
||||
expandedToolCallGroupIds,
|
||||
handleToolCallOpenFile,
|
||||
isMobile,
|
||||
renderSingleToolCallItem,
|
||||
setToolCallGroupExpanded,
|
||||
toolCallDetailLevel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -960,6 +994,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const historyRowRevision = useMemo(
|
||||
() => ({
|
||||
contentById: projectedToolCalls.historyGroupUpdatesByHostId,
|
||||
displayStateById: expandedToolCallGroupIds,
|
||||
globalDisplayState: isMobile,
|
||||
}),
|
||||
[expandedToolCallGroupIds, isMobile, projectedToolCalls.historyGroupUpdatesByHostId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolCallSheetProvider>
|
||||
@@ -968,6 +1010,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
{streamRenderStrategy.render({
|
||||
agentId,
|
||||
segments: renderModel.segments,
|
||||
historyRowRevision,
|
||||
liveHeadRowRevision: expandedToolCallGroupIds,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
|
||||
@@ -88,6 +88,7 @@ import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
@@ -888,6 +889,8 @@ function RootAppTree() {
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
useEffect(() => installWebScrollbarStyles(), []);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<I18nProvider>
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
import { createControlGeometry } from "@/components/ui/control-geometry";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
// Horizontal indent token shared by the sheet header (title, back arrow,
|
||||
@@ -460,11 +459,6 @@ export interface AdaptiveModalSheetProps {
|
||||
desktopMaxWidth?: number;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
/**
|
||||
* Render the themed desktop-web scrollbar over the scroll area instead of the
|
||||
* native browser scrollbar. No-op on native and on the mobile bottom sheet.
|
||||
*/
|
||||
webScrollbar?: boolean;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -479,16 +473,11 @@ export function AdaptiveModalSheet({
|
||||
desktopMaxWidth,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
webScrollbar = false,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const desktopScrollRef = useRef<ScrollView>(null);
|
||||
const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, {
|
||||
enabled: webScrollbar && !isMobile,
|
||||
});
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const compactSafeAreaPadding = useMemo(
|
||||
() =>
|
||||
@@ -651,19 +640,13 @@ export function AdaptiveModalSheet({
|
||||
{scrollable ? (
|
||||
<View style={styles.desktopScrollContainer}>
|
||||
<ScrollView
|
||||
ref={desktopScrollRef}
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onLayout={desktopScrollbar.onLayout}
|
||||
onScroll={desktopScrollbar.onScroll}
|
||||
onContentSizeChange={desktopScrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!webScrollbar}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
{desktopScrollbar.overlay}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
interface DiffScrollProps {
|
||||
children: React.ReactNode;
|
||||
@@ -16,8 +15,6 @@ export function DiffScroll({
|
||||
style,
|
||||
contentContainerStyle,
|
||||
}: DiffScrollProps) {
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const combinedStyle = useMemo(() => [style, webScrollbarStyle], [style, webScrollbarStyle]);
|
||||
const handleLayout = useCallback(
|
||||
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
|
||||
[onScrollViewWidthChange],
|
||||
@@ -28,7 +25,7 @@ export function DiffScroll({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={combinedStyle}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
onLayout={handleLayout}
|
||||
>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import type { DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { diffLinePrefix } from "@/utils/diff-highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
@@ -127,7 +126,6 @@ export function DiffViewer({
|
||||
const { t } = useTranslation();
|
||||
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
||||
const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty");
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const handleInnerLayout = React.useCallback(
|
||||
(e: { nativeEvent: { layout: { width: number } } }) =>
|
||||
setScrollViewWidth(e.nativeEvent.layout.width),
|
||||
@@ -139,9 +137,8 @@ export function DiffViewer({
|
||||
styles.verticalScroll,
|
||||
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
|
||||
fillAvailableHeight && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
],
|
||||
[maxHeight, fillAvailableHeight, webScrollbarStyle],
|
||||
[maxHeight, fillAvailableHeight],
|
||||
);
|
||||
const linesContainerStyle = React.useMemo(
|
||||
() => [
|
||||
@@ -180,7 +177,6 @@ export function DiffViewer({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.horizontalContent}
|
||||
onLayout={handleInnerLayout}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,6 @@ export function DraggableList<T>({
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false,
|
||||
scrollEnabled = true,
|
||||
useDragHandle: _useDragHandle = false,
|
||||
refreshing,
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface DraggableListProps<T> {
|
||||
ListHeaderComponent?: ReactElement | null;
|
||||
ListEmptyComponent?: ReactElement | null;
|
||||
showsVerticalScrollIndicator?: boolean;
|
||||
enableDesktopWebScrollbar?: boolean;
|
||||
/** When false, disables internal scrolling (use outer list to scroll). */
|
||||
scrollEnabled?: boolean;
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
|
||||
import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
@@ -133,7 +132,6 @@ export function DraggableList<T>({
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar = false,
|
||||
scrollEnabled = true,
|
||||
extraData: _extraData,
|
||||
useDragHandle = false,
|
||||
@@ -147,11 +145,6 @@ export function DraggableList<T>({
|
||||
onDragEnd,
|
||||
onDragBegin,
|
||||
});
|
||||
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
|
||||
enabled: showCustomScrollbar,
|
||||
});
|
||||
const pointerActivationConstraint = getPointerActivationConstraint(
|
||||
useDragHandle,
|
||||
POINTER_ACTIVATION_CONFIG,
|
||||
@@ -183,15 +176,10 @@ export function DraggableList<T>({
|
||||
<View style={wrapperStyle}>
|
||||
{scrollEnabled ? (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
testID={testID}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
onScroll={scrollbar.onScroll}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
>
|
||||
{ListHeaderComponent}
|
||||
{items.length === 0 && ListEmptyComponent}
|
||||
@@ -254,7 +242,6 @@ export function DraggableList<T>({
|
||||
{ListFooterComponent}
|
||||
</>
|
||||
)}
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
@@ -46,8 +45,6 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
{ value: "name" },
|
||||
@@ -224,8 +221,6 @@ export function FileExplorerPane({
|
||||
onOpenFile,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
|
||||
const daemons = useHosts();
|
||||
const daemonProfile = useMemo(
|
||||
@@ -283,9 +278,6 @@ export function FileExplorerPane({
|
||||
);
|
||||
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
});
|
||||
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
@@ -482,9 +474,7 @@ export function FileExplorerPane({
|
||||
treeRows={treeRows}
|
||||
currentSortLabel={currentSortLabel}
|
||||
isRefreshFetching={isRefreshFetching}
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
treeListRef={treeListRef}
|
||||
scrollbar={scrollbar}
|
||||
renderTreeRow={renderTreeRow}
|
||||
handleSortCycle={handleSortCycle}
|
||||
handleToggleHiddenFiles={handleToggleHiddenFiles}
|
||||
@@ -505,9 +495,7 @@ interface FileExplorerPaneContentProps {
|
||||
treeRows: TreeRow[];
|
||||
currentSortLabel: string;
|
||||
isRefreshFetching: boolean;
|
||||
showDesktopWebScrollbar: boolean;
|
||||
treeListRef: RefObject<FlatList<TreeRow> | null>;
|
||||
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
|
||||
renderTreeRow: (info: ListRenderItemInfo<TreeRow>) => ReactElement;
|
||||
handleSortCycle: () => void;
|
||||
handleToggleHiddenFiles: () => void;
|
||||
@@ -528,9 +516,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
treeRows,
|
||||
currentSortLabel,
|
||||
isRefreshFetching,
|
||||
showDesktopWebScrollbar,
|
||||
treeListRef,
|
||||
scrollbar,
|
||||
renderTreeRow,
|
||||
handleSortCycle,
|
||||
handleToggleHiddenFiles,
|
||||
@@ -645,17 +631,12 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
keyExtractor={treeRowKeyExtractor}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
showsVerticalScrollIndicator
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
)}
|
||||
{treeRows.length > 0 ? scrollbar.overlay : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,15 +13,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||
@@ -43,7 +40,6 @@ interface CodeLineProps {
|
||||
interface FilePreviewBodyProps {
|
||||
preview: ExplorerFile | null;
|
||||
isLoading: boolean;
|
||||
showDesktopWebScrollbar: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
imagePreviewUri: string | null;
|
||||
@@ -192,7 +188,6 @@ const codeLineStyles = StyleSheet.create((theme) => ({
|
||||
function FilePreviewBody({
|
||||
preview,
|
||||
isLoading,
|
||||
showDesktopWebScrollbar,
|
||||
isMobile,
|
||||
location,
|
||||
imagePreviewUri,
|
||||
@@ -204,10 +199,6 @@ function FilePreviewBody({
|
||||
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
||||
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
});
|
||||
|
||||
const highlightedLines = useMemo(() => {
|
||||
if (!preview || preview.kind !== "text" || isMarkdownFile) {
|
||||
@@ -276,15 +267,10 @@ function FilePreviewBody({
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewMarkdownScrollContent}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<MarkdownRenderer text={preview.content ?? ""} />
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -318,11 +304,7 @@ function FilePreviewBody({
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
{isMobile ? (
|
||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
||||
@@ -331,14 +313,12 @@ function FilePreviewBody({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
{codeLines}
|
||||
</RNScrollView>
|
||||
)}
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -359,11 +339,7 @@ function FilePreviewBody({
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewImageScrollContent}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<RNImage
|
||||
source={imageSource ?? undefined}
|
||||
@@ -371,7 +347,6 @@ function FilePreviewBody({
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -395,7 +370,6 @@ export function FilePane({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
@@ -463,7 +437,6 @@ export function FilePane({
|
||||
<FilePreviewBody
|
||||
preview={query.data?.file ?? null}
|
||||
isLoading={query.isFetching}
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
|
||||
@@ -60,6 +60,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
@@ -115,6 +116,7 @@ import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||||
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
export type { AssistantForkTarget };
|
||||
|
||||
@@ -1273,7 +1275,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
chevron: {
|
||||
flexShrink: 0,
|
||||
transform: [{ scale: 1.3 }],
|
||||
},
|
||||
openFileButton: {
|
||||
marginLeft: theme.spacing[1],
|
||||
@@ -1285,9 +1286,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
chevronExpanded: {
|
||||
transform: [{ scale: 1.3 }, { rotate: "90deg" }],
|
||||
},
|
||||
detailWrapper: {
|
||||
borderBottomLeftRadius: theme.borderRadius.lg,
|
||||
borderBottomRightRadius: theme.borderRadius.lg,
|
||||
@@ -1302,11 +1300,16 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
...(isWeb ? { cursor: "auto" as const, userSelect: "text" as const } : {}),
|
||||
},
|
||||
pressableExpanded: {
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
pressableExpandedAttached: {
|
||||
borderColor: theme.colors.border,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
detailWrapperBorderless: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
shimmerOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
@@ -1358,9 +1361,14 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer(
|
||||
durationSeconds,
|
||||
gradientId,
|
||||
}: NativeExpandableBadgeShimmerProps) {
|
||||
const isPanelActive = useRetainedPanelActive();
|
||||
const shimmerTranslateX = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanelActive) {
|
||||
cancelAnimation(shimmerTranslateX);
|
||||
return;
|
||||
}
|
||||
const startPosition = -peakWidth;
|
||||
const endPosition = rowWidth + peakWidth;
|
||||
shimmerTranslateX.value = startPosition;
|
||||
@@ -1375,7 +1383,7 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer(
|
||||
return () => {
|
||||
cancelAnimation(shimmerTranslateX);
|
||||
};
|
||||
}, [durationSeconds, peakWidth, rowWidth, shimmerTranslateX]);
|
||||
}, [durationSeconds, isPanelActive, peakWidth, rowWidth, shimmerTranslateX]);
|
||||
|
||||
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: shimmerTranslateX.value }],
|
||||
@@ -2366,6 +2374,7 @@ interface ExpandableBadgeProps {
|
||||
isError?: boolean;
|
||||
isLastInSequence?: boolean;
|
||||
disableOuterSpacing?: boolean;
|
||||
borderlessWhenExpanded?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
@@ -2604,7 +2613,9 @@ function renderExpandableBadgeIconSlot({
|
||||
}): ReactNode {
|
||||
if (showChevron) {
|
||||
return (
|
||||
<ThemedChevronRightIcon size={12} style={chevronStyle} uniProps={foregroundColorMapping} />
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevronRightIcon size={12} uniProps={foregroundColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return iconNode;
|
||||
@@ -2695,7 +2706,7 @@ function buildShimmerTextStyle(input: {
|
||||
offsetX: number;
|
||||
}): object | null {
|
||||
if (!input.isWebShimmer) return null;
|
||||
return {
|
||||
return inlineUnistylesStyle({
|
||||
opacity: 1,
|
||||
color: "transparent",
|
||||
backgroundImage: SHIMMER_GRADIENT,
|
||||
@@ -2707,10 +2718,10 @@ function buildShimmerTextStyle(input: {
|
||||
animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${input.shimmerDuration}s linear infinite`,
|
||||
"--paseo-shimmer-start": `${input.webShimmerTrackStart - input.offsetX}px`,
|
||||
"--paseo-shimmer-end": `${input.webShimmerTrackEnd - input.offsetX}px`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const ExpandableBadge = memo(function ExpandableBadge({
|
||||
export const ExpandableBadge = memo(function ExpandableBadge({
|
||||
label,
|
||||
style,
|
||||
secondaryLabel,
|
||||
@@ -2724,6 +2735,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
isError = false,
|
||||
isLastInSequence = false,
|
||||
disableOuterSpacing,
|
||||
borderlessWhenExpanded = false,
|
||||
testID,
|
||||
}: ExpandableBadgeProps) {
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
@@ -2894,8 +2906,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
expandableBadgeStylesheet.pressable,
|
||||
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
|
||||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||||
isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached,
|
||||
],
|
||||
[isExpanded, isInteractive, isPressed],
|
||||
[borderlessWhenExpanded, isExpanded, isInteractive, isPressed],
|
||||
);
|
||||
|
||||
const detailWrapperStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.detailWrapper,
|
||||
borderlessWhenExpanded && expandableBadgeStylesheet.detailWrapperBorderless,
|
||||
],
|
||||
[borderlessWhenExpanded],
|
||||
);
|
||||
|
||||
const accessibilityState = useMemo(
|
||||
@@ -2944,8 +2965,10 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const chevronStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.chevron,
|
||||
isExpanded && expandableBadgeStylesheet.chevronExpanded,
|
||||
LUCIDE_CHEVRON_NUDGE_LEFT,
|
||||
inlineUnistylesStyle({
|
||||
transform: isExpanded ? [{ scale: 1.3 }, { rotate: "90deg" }] : [{ scale: 1.3 }],
|
||||
}),
|
||||
],
|
||||
[isExpanded],
|
||||
);
|
||||
@@ -2953,7 +2976,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]);
|
||||
const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon });
|
||||
const iconSlotNode = renderExpandableBadgeIconSlot({
|
||||
showChevron: isInteractive && isHovered,
|
||||
showChevron: isInteractive && (isHovered || isExpanded),
|
||||
chevronStyle,
|
||||
iconNode,
|
||||
});
|
||||
@@ -3012,7 +3035,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
{detailContent ? (
|
||||
<Pressable
|
||||
ref={detailWrapperRef}
|
||||
style={expandableBadgeStylesheet.detailWrapper}
|
||||
style={detailWrapperStyle}
|
||||
onHoverIn={handleDetailHoverIn}
|
||||
onHoverOut={handleDetailHoverOut}
|
||||
>
|
||||
@@ -3033,6 +3056,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
if (previous.isError !== next.isError) return false;
|
||||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||||
if (previous.borderlessWhenExpanded !== next.borderlessWhenExpanded) return false;
|
||||
if (previous.testID !== next.testID) return false;
|
||||
if (previous.onToggle !== next.onToggle) return false;
|
||||
if (previous.onOpenFile !== next.onOpenFile) return false;
|
||||
@@ -3057,6 +3081,7 @@ interface ToolCallProps {
|
||||
onOpenFilePath?: (filePath: string) => void;
|
||||
defaultExpanded?: boolean;
|
||||
forceInline?: boolean;
|
||||
maxDetailHeight?: number;
|
||||
}
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
@@ -3075,6 +3100,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
onOpenFilePath,
|
||||
defaultExpanded,
|
||||
forceInline = false,
|
||||
maxDetailHeight = 400,
|
||||
}: ToolCallProps) {
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
|
||||
@@ -3175,11 +3201,17 @@ export const ToolCall = memo(function ToolCall({
|
||||
<ToolCallDetailsContent
|
||||
detail={effectiveDetail}
|
||||
errorText={presentation.errorText}
|
||||
maxHeight={400}
|
||||
maxHeight={maxDetailHeight}
|
||||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||||
/>
|
||||
);
|
||||
}, [shouldRenderInline, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
|
||||
}, [
|
||||
shouldRenderInline,
|
||||
effectiveDetail,
|
||||
presentation.errorText,
|
||||
presentation.isLoadingDetails,
|
||||
maxDetailHeight,
|
||||
]);
|
||||
|
||||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||||
return (
|
||||
@@ -3224,5 +3256,6 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
||||
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
|
||||
if (previous.defaultExpanded !== next.defaultExpanded) return false;
|
||||
if (previous.forceInline !== next.forceInline) return false;
|
||||
if (previous.maxDetailHeight !== next.maxDetailHeight) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -432,7 +432,6 @@ function OpenScheduleFormSheet({
|
||||
onClose={onClose}
|
||||
onDismiss={onDismiss}
|
||||
footer={footer}
|
||||
webScrollbar
|
||||
testID="schedule-form-sheet"
|
||||
>
|
||||
<ScheduleFormFields
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type CSSProperties,
|
||||
type DragEvent as ReactDragEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import type { DOMProps } from "expo/dom";
|
||||
@@ -31,10 +30,6 @@ import type {
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
import { openExternalUrl } from "../utils/open-external-url";
|
||||
import { focusWithRetries } from "../utils/web-focus";
|
||||
import {
|
||||
computeScrollOffsetFromDragDelta,
|
||||
computeVerticalScrollbarGeometry,
|
||||
} from "./web-desktop-scrollbar.math";
|
||||
import {
|
||||
extractTerminalDropPaths,
|
||||
isTerminalDragLeaveOutside,
|
||||
@@ -51,20 +46,6 @@ export interface TerminalEmulatorHandle {
|
||||
blur: () => void;
|
||||
}
|
||||
|
||||
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
|
||||
const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9;
|
||||
const SCROLLBAR_HANDLE_GRAB_WIDTH = 18;
|
||||
const SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING = 8;
|
||||
const SCROLLBAR_HANDLE_OPACITY_VISIBLE = 0.62;
|
||||
const SCROLLBAR_HANDLE_OPACITY_HOVERED = 0.78;
|
||||
const SCROLLBAR_HANDLE_OPACITY_DRAGGING = 0.9;
|
||||
const SCROLLBAR_HANDLE_FADE_DURATION_MS = 220;
|
||||
const SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
|
||||
const SCROLLBAR_HANDLE_TRAVEL_DURATION_MS = 90;
|
||||
const SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS = 1_200;
|
||||
const SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS = 110;
|
||||
const WEBKIT_SCROLLBAR_STYLE_ID = "terminal-emulator-webkit-scrollbar-style";
|
||||
|
||||
const HOST_DIV_STYLE: CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
@@ -79,25 +60,6 @@ const HOST_DIV_STYLE: CSSProperties = {
|
||||
paddingRight: 0,
|
||||
};
|
||||
|
||||
const SCROLLBAR_CONTAINER_STYLE: CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
zIndex: 10,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
interface ViewportMetrics {
|
||||
offset: number;
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
}
|
||||
|
||||
function buildXtermThemeKey(theme: ITheme): string {
|
||||
const values: Array<string> = [
|
||||
theme.background,
|
||||
@@ -169,10 +131,6 @@ declare global {
|
||||
interface Window {}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function isTerminalState(value: unknown): value is TerminalState {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
@@ -183,30 +141,6 @@ function isTerminalState(value: unknown): value is TerminalState {
|
||||
);
|
||||
}
|
||||
|
||||
function ensureTerminalScrollbarStyle(): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (document.getElementById(WEBKIT_SCROLLBAR_STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.id = WEBKIT_SCROLLBAR_STYLE_ID;
|
||||
styleElement.textContent = `
|
||||
[data-terminal-scrollbar-root="true"] .xterm-viewport {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
|
||||
export default function TerminalEmulator({
|
||||
ref,
|
||||
streamKey,
|
||||
@@ -246,13 +180,6 @@ export default function TerminalEmulator({
|
||||
scrollbackLinesRef.current = scrollbackLines;
|
||||
fontFamilyRef.current = fontFamily;
|
||||
fontSizeRef.current = fontSize;
|
||||
const viewportRef = useRef<HTMLElement | null>(null);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const dragStartClientYRef = useRef(0);
|
||||
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastObservedOffsetRef = useRef<number | null>(null);
|
||||
const lastMetricsRef = useRef({ offset: 0, viewportSize: 0, contentSize: 0 });
|
||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||
const xtermThemeRef = useRef(xtermTheme);
|
||||
xtermThemeRef.current = xtermTheme;
|
||||
@@ -280,30 +207,8 @@ export default function TerminalEmulator({
|
||||
initialSnapshotRef.current = initialSnapshot;
|
||||
const pendingModifiersRef = useRef(pendingModifiers);
|
||||
pendingModifiersRef.current = pendingModifiers;
|
||||
const [viewportMetrics, setViewportMetrics] = useState<ViewportMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
});
|
||||
const [isHandleHovered, setIsHandleHovered] = useState(false);
|
||||
const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false);
|
||||
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
||||
const [isScrollActive, setIsScrollActive] = useState(false);
|
||||
const [isDropActive, setIsDropActive] = useState(false);
|
||||
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const updateViewportMetricsState = useCallback((metrics: ViewportMetrics) => {
|
||||
const lastMetrics = lastMetricsRef.current;
|
||||
if (
|
||||
metrics.offset === lastMetrics.offset &&
|
||||
metrics.viewportSize === lastMetrics.viewportSize &&
|
||||
metrics.contentSize === lastMetrics.contentSize
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastMetricsRef.current = metrics;
|
||||
setViewportMetrics(metrics);
|
||||
}, []);
|
||||
|
||||
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
|
||||
useDOMImperativeHandle(
|
||||
@@ -366,10 +271,6 @@ export default function TerminalEmulator({
|
||||
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
|
||||
}, [scrollbackLines]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureTerminalScrollbarStyle();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !swipeGesturesEnabled) {
|
||||
@@ -580,188 +481,6 @@ export default function TerminalEmulator({
|
||||
runtimeRef.current?.resize({ force: true, shouldClaim: true });
|
||||
}, [resizeRequestToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
||||
if (!viewportElement) {
|
||||
viewportRef.current = null;
|
||||
updateViewportMetricsState({ offset: 0, viewportSize: 0, contentSize: 0 });
|
||||
return () => {};
|
||||
}
|
||||
|
||||
viewportRef.current = viewportElement;
|
||||
|
||||
const updateViewportMetrics = () => {
|
||||
const offset = Math.max(0, viewportElement.scrollTop);
|
||||
const viewportSize = Math.max(0, viewportElement.clientHeight);
|
||||
const contentSize = Math.max(0, viewportElement.scrollHeight);
|
||||
updateViewportMetricsState({ offset, viewportSize, contentSize });
|
||||
};
|
||||
|
||||
updateViewportMetrics();
|
||||
|
||||
let scrollRafId: number | null = null;
|
||||
const handleViewportScroll = () => {
|
||||
if (scrollRafId !== null) {
|
||||
return;
|
||||
}
|
||||
scrollRafId = requestAnimationFrame(() => {
|
||||
scrollRafId = null;
|
||||
updateViewportMetrics();
|
||||
});
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateViewportMetrics();
|
||||
});
|
||||
resizeObserver.observe(viewportElement);
|
||||
const scrollAreaElement = host.querySelector<HTMLElement>(".xterm-scroll-area");
|
||||
if (scrollAreaElement) {
|
||||
resizeObserver.observe(scrollAreaElement);
|
||||
}
|
||||
|
||||
viewportElement.addEventListener("scroll", handleViewportScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
if (scrollRafId !== null) {
|
||||
cancelAnimationFrame(scrollRafId);
|
||||
scrollRafId = null;
|
||||
}
|
||||
viewportElement.removeEventListener("scroll", handleViewportScroll);
|
||||
resizeObserver.disconnect();
|
||||
if (viewportRef.current === viewportElement) {
|
||||
viewportRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [streamKey, updateViewportMetricsState]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize);
|
||||
const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset);
|
||||
if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) {
|
||||
setIsScrollVisible(false);
|
||||
setIsScrollActive(false);
|
||||
lastObservedOffsetRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOffset = lastObservedOffsetRef.current;
|
||||
lastObservedOffsetRef.current = normalizedOffset;
|
||||
if (previousOffset === null || Math.abs(previousOffset - normalizedOffset) <= 0.5) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsScrollVisible(true);
|
||||
if (scrollVisibilityTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollVisibilityTimeoutRef.current);
|
||||
}
|
||||
scrollVisibilityTimeoutRef.current = setTimeout(() => {
|
||||
setIsScrollVisible(false);
|
||||
scrollVisibilityTimeoutRef.current = null;
|
||||
}, SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS);
|
||||
|
||||
setIsScrollActive(true);
|
||||
if (scrollActiveTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollActiveTimeoutRef.current);
|
||||
}
|
||||
scrollActiveTimeoutRef.current = setTimeout(() => {
|
||||
setIsScrollActive(false);
|
||||
scrollActiveTimeoutRef.current = null;
|
||||
}, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS);
|
||||
}, [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollVisibilityTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollVisibilityTimeoutRef.current);
|
||||
}
|
||||
if (scrollActiveTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollActiveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollbarGeometry = useMemo(
|
||||
() =>
|
||||
computeVerticalScrollbarGeometry({
|
||||
viewportSize: viewportMetrics.viewportSize,
|
||||
contentSize: viewportMetrics.contentSize,
|
||||
offset: viewportMetrics.offset,
|
||||
}),
|
||||
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingScrollbar) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const dragDelta = event.clientY - dragStartClientYRef.current;
|
||||
const nextOffset = computeScrollOffsetFromDragDelta({
|
||||
startOffset: dragStartOffsetRef.current,
|
||||
dragDelta,
|
||||
maxScrollOffset: scrollbarGeometry.maxScrollOffset,
|
||||
maxHandleOffset: scrollbarGeometry.maxHandleOffset,
|
||||
});
|
||||
const viewportElement = viewportRef.current;
|
||||
if (!viewportElement) {
|
||||
return;
|
||||
}
|
||||
viewportElement.scrollTop = nextOffset;
|
||||
updateViewportMetricsState({
|
||||
offset: nextOffset,
|
||||
viewportSize: Math.max(0, viewportElement.clientHeight),
|
||||
contentSize: Math.max(0, viewportElement.scrollHeight),
|
||||
});
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
setIsDraggingScrollbar(false);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopDragging);
|
||||
window.addEventListener("pointercancel", stopDragging);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopDragging);
|
||||
window.removeEventListener("pointercancel", stopDragging);
|
||||
};
|
||||
}, [
|
||||
isDraggingScrollbar,
|
||||
scrollbarGeometry.maxHandleOffset,
|
||||
scrollbarGeometry.maxScrollOffset,
|
||||
updateViewportMetricsState,
|
||||
]);
|
||||
|
||||
const handleVisible =
|
||||
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
|
||||
let handleOpacity: number;
|
||||
if (isDraggingScrollbar) handleOpacity = SCROLLBAR_HANDLE_OPACITY_DRAGGING;
|
||||
else if (isHandleHovered) handleOpacity = SCROLLBAR_HANDLE_OPACITY_HOVERED;
|
||||
else if (isScrollVisible) handleOpacity = SCROLLBAR_HANDLE_OPACITY_VISIBLE;
|
||||
else handleOpacity = 0;
|
||||
const handleWidth =
|
||||
isDraggingScrollbar || isHandleHovered
|
||||
? SCROLLBAR_HANDLE_WIDTH_ACTIVE
|
||||
: SCROLLBAR_HANDLE_WIDTH_IDLE;
|
||||
const thumbRegionOffset = Math.max(
|
||||
0,
|
||||
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING,
|
||||
);
|
||||
const thumbRegionHeight = Math.min(
|
||||
viewportMetrics.viewportSize - thumbRegionOffset,
|
||||
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2,
|
||||
);
|
||||
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
|
||||
const handleTravelDurationMs =
|
||||
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
|
||||
const showTerminalContextMenu = useCallback(() => {
|
||||
const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
|
||||
if (typeof showContextMenu !== "function") {
|
||||
@@ -788,29 +507,6 @@ export default function TerminalEmulator({
|
||||
[showTerminalContextMenu],
|
||||
);
|
||||
|
||||
const scrollbarMaxOffset = scrollbarGeometry.maxScrollOffset;
|
||||
const handleScrollbarPointerDown = useCallback(
|
||||
(event: ReactPointerEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragStartOffsetRef.current = clamp(viewportMetrics.offset, 0, scrollbarMaxOffset);
|
||||
dragStartClientYRef.current = event.clientY;
|
||||
setIsDraggingScrollbar(true);
|
||||
},
|
||||
[scrollbarMaxOffset, viewportMetrics.offset],
|
||||
);
|
||||
|
||||
const handleScrollbarPointerEnter = useCallback(() => {
|
||||
if (!isScrollVisible && !isDraggingScrollbar) {
|
||||
return;
|
||||
}
|
||||
setIsHandleHovered(true);
|
||||
}, [isScrollVisible, isDraggingScrollbar]);
|
||||
|
||||
const handleScrollbarPointerLeave = useCallback(() => {
|
||||
setIsHandleHovered(false);
|
||||
}, []);
|
||||
|
||||
const clearDropActiveTimeout = useCallback(() => {
|
||||
if (dropActiveTimeoutRef.current === null) {
|
||||
return;
|
||||
@@ -944,46 +640,6 @@ export default function TerminalEmulator({
|
||||
}),
|
||||
[isDropActive],
|
||||
);
|
||||
const handleContainerStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: -3,
|
||||
width: SCROLLBAR_HANDLE_GRAB_WIDTH,
|
||||
height: thumbRegionHeight,
|
||||
transform: `translateY(${thumbRegionOffset}px)`,
|
||||
cursor: isDraggingScrollbar ? "grabbing" : "grab",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
transitionProperty: "transform",
|
||||
transitionDuration: `${handleTravelDurationMs}ms`,
|
||||
transitionTimingFunction: "linear",
|
||||
pointerEvents: handleVisible ? "auto" : "none",
|
||||
}),
|
||||
[
|
||||
thumbRegionHeight,
|
||||
thumbRegionOffset,
|
||||
isDraggingScrollbar,
|
||||
handleTravelDurationMs,
|
||||
handleVisible,
|
||||
],
|
||||
);
|
||||
const handleInnerStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
marginTop: handleInsetTop,
|
||||
height: scrollbarGeometry.handleSize,
|
||||
width: handleWidth,
|
||||
borderRadius: 999,
|
||||
alignSelf: "center",
|
||||
backgroundColor: "rgba(113, 113, 122, 1)",
|
||||
opacity: handleOpacity,
|
||||
transitionProperty: "opacity, width, background-color",
|
||||
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
|
||||
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
|
||||
}),
|
||||
[handleInsetTop, scrollbarGeometry.handleSize, handleWidth, handleOpacity],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -996,18 +652,6 @@ export default function TerminalEmulator({
|
||||
>
|
||||
<div ref={hostRef} style={HOST_DIV_STYLE} />
|
||||
<div style={dropOverlayStyle} />
|
||||
{scrollbarGeometry.isVisible ? (
|
||||
<div style={SCROLLBAR_CONTAINER_STYLE}>
|
||||
<div
|
||||
style={handleContainerStyle}
|
||||
onPointerDown={handleScrollbarPointerDown}
|
||||
onPointerEnter={handleScrollbarPointerEnter}
|
||||
onPointerLeave={handleScrollbarPointerLeave}
|
||||
>
|
||||
<div style={handleInnerStyle} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { highlightDiffLines } from "@/utils/diff-highlight";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache";
|
||||
@@ -46,7 +45,6 @@ interface DetailStyles {
|
||||
jsonScrollErrorCombined: StyleProp<ViewStyle>;
|
||||
fullBleedContainerStyle: StyleProp<ViewStyle>;
|
||||
loadingContainerStyle: StyleProp<ViewStyle>;
|
||||
webScrollbarStyle: StyleProp<ViewStyle>;
|
||||
resolvedMaxHeight: number | undefined;
|
||||
shouldFill: boolean;
|
||||
isFullBleed: boolean;
|
||||
@@ -70,7 +68,6 @@ function useDetailStyles(
|
||||
resolvedMaxHeight: number | undefined,
|
||||
fillAvailableHeight: boolean,
|
||||
): DetailStyles {
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const isFullBleed = resolveIsFullBleed(detail);
|
||||
const shouldFill = resolveShouldFill(detail, fillAvailableHeight);
|
||||
const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer;
|
||||
@@ -88,35 +85,26 @@ function useDetailStyles(
|
||||
styles.codeVerticalScroll,
|
||||
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
],
|
||||
[resolvedMaxHeight, shouldFill, webScrollbarStyle],
|
||||
[resolvedMaxHeight, shouldFill],
|
||||
);
|
||||
const scrollAreaFillStyle = useMemo(
|
||||
() => [
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
],
|
||||
[resolvedMaxHeight, shouldFill, webScrollbarStyle],
|
||||
[resolvedMaxHeight, shouldFill],
|
||||
);
|
||||
const scrollAreaStyle = useMemo(
|
||||
() => [
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
|
||||
webScrollbarStyle,
|
||||
],
|
||||
[resolvedMaxHeight, webScrollbarStyle],
|
||||
);
|
||||
const jsonScrollCombined = useMemo(
|
||||
() => [styles.jsonScroll, webScrollbarStyle],
|
||||
[webScrollbarStyle],
|
||||
);
|
||||
const jsonScrollErrorCombined = useMemo(
|
||||
() => [styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle],
|
||||
[webScrollbarStyle],
|
||||
[resolvedMaxHeight],
|
||||
);
|
||||
const jsonScrollCombined = styles.jsonScroll;
|
||||
const jsonScrollErrorCombined = [styles.jsonScroll, styles.jsonScrollError];
|
||||
const fullBleedContainerStyle = useMemo(
|
||||
() => [
|
||||
isFullBleed ? styles.fullBleedContainer : styles.paddedContainer,
|
||||
@@ -139,7 +127,6 @@ function useDetailStyles(
|
||||
jsonScrollErrorCombined,
|
||||
fullBleedContainerStyle,
|
||||
loadingContainerStyle,
|
||||
webScrollbarStyle,
|
||||
resolvedMaxHeight,
|
||||
shouldFill,
|
||||
isFullBleed,
|
||||
@@ -179,7 +166,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
@@ -224,7 +210,6 @@ function WorktreeSetupDetailSection({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
@@ -389,7 +374,6 @@ function SubAgentDetailSection({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
@@ -466,12 +450,7 @@ function ScrollableTextSection({
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={true}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
|
||||
{keyedLines ? (
|
||||
<HighlightedLines lines={keyedLines} startLine={startLine} />
|
||||
) : (
|
||||
@@ -501,12 +480,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{result ? `${url}\n\n${result}` : url}
|
||||
</Text>
|
||||
@@ -545,12 +519,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{detail.content}
|
||||
</Text>
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
import { memo, useCallback, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, TriangleAlert, Wrench } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type {
|
||||
CompactToolCallGroup as CompactToolCallGroupModel,
|
||||
ToolCallCategorySummary,
|
||||
} from "@/tool-calls/grouping";
|
||||
import { componentForToolCallIcon } from "@/utils/tool-call-icon";
|
||||
|
||||
interface ToolCallGroupProps {
|
||||
group: CompactToolCallGroupModel;
|
||||
presentation: "overview" | "concise";
|
||||
expanded: boolean;
|
||||
onExpandedChange: (groupId: string, expanded: boolean) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function CategoryStatus({ category }: { category: ToolCallCategorySummary }) {
|
||||
const { t } = useTranslation();
|
||||
if (category.failedCount > 0) {
|
||||
return (
|
||||
<View style={styles.categoryStatus}>
|
||||
<TriangleAlert size={12} color={styles.error.color} />
|
||||
<Text style={styles.error}>
|
||||
{t("toolCallGroup.failed", { count: category.failedCount })}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (category.runningCount > 0) {
|
||||
return <ActivityIndicator size={12} color={styles.muted.color} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function CategoryRow({
|
||||
category,
|
||||
resourceLimit,
|
||||
}: {
|
||||
category: ToolCallCategorySummary;
|
||||
resourceLimit: number;
|
||||
}) {
|
||||
const Icon = componentForToolCallIcon(category.iconName);
|
||||
const visibleResources = category.resources.slice(0, resourceLimit);
|
||||
const hiddenResourceCount = category.resources.length - visibleResources.length;
|
||||
const resourceText = [
|
||||
...visibleResources,
|
||||
...(hiddenResourceCount > 0 ? [`+${hiddenResourceCount}`] : []),
|
||||
].join(", ");
|
||||
|
||||
return (
|
||||
<View style={styles.categoryRow}>
|
||||
<View style={styles.categoryIcon}>
|
||||
<Icon size={12} color={styles.muted.color} />
|
||||
</View>
|
||||
<Text style={styles.categoryCount}>×{category.callCount}</Text>
|
||||
<Text style={styles.categoryLabel}>{category.label}</Text>
|
||||
<CategoryStatus category={category} />
|
||||
{resourceText ? (
|
||||
<Text style={styles.resources} numberOfLines={1}>
|
||||
{resourceText}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupHeaderIcon({
|
||||
group,
|
||||
compact,
|
||||
}: {
|
||||
group: CompactToolCallGroupModel;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const size = compact ? 11 : 12;
|
||||
if (group.failedCount > 0) {
|
||||
return <TriangleAlert size={size} color={styles.error.color} />;
|
||||
}
|
||||
if (group.isRunning) {
|
||||
return <ActivityIndicator size={size} color={styles.foreground.color} />;
|
||||
}
|
||||
return <Wrench size={size} color={styles.muted.color} />;
|
||||
}
|
||||
|
||||
function joinSummaryParts(parts: string[], conjunction: string): string {
|
||||
if (parts.length === 0) {
|
||||
return "";
|
||||
}
|
||||
let joined: string;
|
||||
if (parts.length === 1) {
|
||||
joined = parts[0] ?? "";
|
||||
} else if (parts.length === 2) {
|
||||
joined = `${parts[0]} ${conjunction} ${parts[1]}`;
|
||||
} else {
|
||||
joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`;
|
||||
}
|
||||
const firstCharacter = joined[0];
|
||||
return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined;
|
||||
}
|
||||
|
||||
export const ToolCallGroup = memo(function ToolCallGroup({
|
||||
group,
|
||||
presentation,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
children,
|
||||
}: ToolCallGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isOverview = presentation === "overview";
|
||||
const resourceLimit = isCompact ? 2 : 3;
|
||||
const handlePress = useCallback(
|
||||
() => onExpandedChange(group.id, !expanded),
|
||||
[expanded, group.id, onExpandedChange],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||
const headerStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.header,
|
||||
!isOverview && styles.headerConcise,
|
||||
(pressed || hovered || expanded) && styles.headerActive,
|
||||
],
|
||||
[expanded, isOverview],
|
||||
);
|
||||
const summary = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
if (group.editedFileCount > 0) {
|
||||
parts.push(
|
||||
t(
|
||||
group.editedFileCount === 1
|
||||
? "toolCallGroup.editedFiles.one"
|
||||
: "toolCallGroup.editedFiles.other",
|
||||
{ count: group.editedFileCount },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (group.commandCount > 0) {
|
||||
parts.push(
|
||||
t(
|
||||
group.commandCount === 1 ? "toolCallGroup.commands.one" : "toolCallGroup.commands.other",
|
||||
{ count: group.commandCount },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (group.readFileCount > 0) {
|
||||
parts.push(
|
||||
t(
|
||||
group.readFileCount === 1
|
||||
? "toolCallGroup.readFiles.one"
|
||||
: "toolCallGroup.readFiles.other",
|
||||
{ count: group.readFileCount },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (group.searchCount > 0) {
|
||||
parts.push(
|
||||
t(group.searchCount === 1 ? "toolCallGroup.searches.one" : "toolCallGroup.searches.other", {
|
||||
count: group.searchCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (group.otherToolCount > 0) {
|
||||
parts.push(
|
||||
t(
|
||||
group.otherToolCount === 1
|
||||
? "toolCallGroup.otherTools.one"
|
||||
: "toolCallGroup.otherTools.other",
|
||||
{ count: group.otherToolCount },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (group.paseoCallCount > 0) {
|
||||
parts.push(
|
||||
t(
|
||||
group.paseoCallCount === 1
|
||||
? "toolCallGroup.paseoCalls.one"
|
||||
: "toolCallGroup.paseoCalls.other",
|
||||
{ count: group.paseoCallCount },
|
||||
),
|
||||
);
|
||||
}
|
||||
return joinSummaryParts(parts, t("toolCallGroup.and"));
|
||||
}, [group, t]);
|
||||
const accessibilityLabel = isOverview
|
||||
? summary
|
||||
: t("toolCallGroup.accessibilityLabel", { count: group.callCount });
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="tool-call-group">
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
style={headerStyle}
|
||||
>
|
||||
<View style={styles.headerIcon}>
|
||||
<GroupHeaderIcon group={group} compact={isOverview} />
|
||||
</View>
|
||||
{isOverview ? (
|
||||
<Text style={styles.summary} numberOfLines={1}>
|
||||
{summary}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.conciseTitle}>{t("toolCallGroup.title")}</Text>
|
||||
<Text style={styles.conciseCallCount}>×{group.callCount}</Text>
|
||||
</>
|
||||
)}
|
||||
{group.failedCount > 0 ? (
|
||||
<Text style={styles.error}>
|
||||
{t("toolCallGroup.failed", { count: group.failedCount })}
|
||||
</Text>
|
||||
) : null}
|
||||
<ChevronRight
|
||||
size={isOverview ? 12 : 14}
|
||||
color={styles.muted.color}
|
||||
style={expanded ? styles.chevronExpanded : undefined}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{expanded ? <View style={styles.expandedCalls}>{children}</View> : null}
|
||||
{!expanded && !isOverview ? (
|
||||
<View style={styles.categories}>
|
||||
{group.categories.map((category) => (
|
||||
<CategoryRow key={category.key} category={category} resourceLimit={resourceLimit} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginHorizontal: -theme.spacing[3],
|
||||
},
|
||||
header: {
|
||||
minHeight: 26,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
headerActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
headerConcise: {
|
||||
minHeight: 30,
|
||||
},
|
||||
headerIcon: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
summary: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
conciseTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
conciseCallCount: {
|
||||
flex: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
categories: {
|
||||
gap: theme.spacing[1],
|
||||
paddingLeft: theme.spacing[8],
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingTop: theme.spacing[1],
|
||||
},
|
||||
categoryRow: {
|
||||
minHeight: 20,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
categoryIcon: {
|
||||
width: 14,
|
||||
alignItems: "center",
|
||||
},
|
||||
categoryLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
minWidth: 64,
|
||||
},
|
||||
categoryCount: {
|
||||
width: theme.spacing[6],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
textAlign: "right",
|
||||
},
|
||||
categoryStatus: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
resources: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
},
|
||||
expandedCalls: {
|
||||
paddingTop: theme.spacing[1],
|
||||
marginHorizontal: theme.spacing[3],
|
||||
},
|
||||
chevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
foreground: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
muted: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
// Keep parity with dropdown-menu action statuses.
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
@@ -389,7 +388,6 @@ export function ContextMenuContent({
|
||||
const { t } = useTranslation();
|
||||
const context = useContextMenuContext("ContextMenuContent");
|
||||
const { theme } = useUnistyles();
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const useMobileSheet = isMobile && mobileMode === "sheet";
|
||||
const { open, setOpen, triggerRef, anchorRect } = context;
|
||||
@@ -579,7 +577,6 @@ export function ContextMenuContent({
|
||||
<FloatingScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -31,7 +31,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||
|
||||
@@ -448,7 +447,6 @@ export function DropdownMenuContent({
|
||||
useDropdownMenuContext("DropdownMenuContent");
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const surfaceNativeID = useId();
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<Size | null>(null);
|
||||
@@ -603,8 +601,8 @@ export function DropdownMenuContent({
|
||||
align,
|
||||
]);
|
||||
const scrollViewportStyle = useMemo(
|
||||
() => [webScrollbarStyle, visibleContentSize ? { height: visibleContentSize.height } : null],
|
||||
[visibleContentSize, webScrollbarStyle],
|
||||
() => [visibleContentSize ? { height: visibleContentSize.height } : null],
|
||||
[visibleContentSize],
|
||||
);
|
||||
|
||||
if (!modalVisible) return null;
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useCallback, useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
|
||||
import {
|
||||
type FlatList,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ScrollView,
|
||||
} from "react-native";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
type ScrollbarMetrics,
|
||||
} from "./web-desktop-scrollbar";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
|
||||
const METRICS_EPSILON = 0.5;
|
||||
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
|
||||
|
||||
function ensureHideScrollbarStyle(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = HIDE_SCROLLBAR_STYLE_ID;
|
||||
style.textContent = `
|
||||
[data-hide-scrollbar] {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
[data-hide-scrollbar]::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
[data-hide-scrollbar]::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function metricsChanged(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
|
||||
return (
|
||||
Math.abs(a.offset - b.offset) > METRICS_EPSILON ||
|
||||
Math.abs(a.viewportSize - b.viewportSize) > METRICS_EPSILON ||
|
||||
Math.abs(a.contentSize - b.contentSize) > METRICS_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// ── DOM element scrollbar ────────────────────────────────────────────
|
||||
// Fully automatic: listens to scroll/input/resize events on the element,
|
||||
// hides the native scrollbar, and returns a themed overlay or null.
|
||||
|
||||
export function useWebElementScrollbar(
|
||||
elementRef: RefObject<HTMLElement | null>,
|
||||
options?: {
|
||||
enabled?: boolean;
|
||||
contentRef?: RefObject<HTMLElement | null>;
|
||||
},
|
||||
): ReactNode {
|
||||
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
||||
const contentRef = options?.contentRef;
|
||||
|
||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return;
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
type ScrollbarStyle = CSSStyleDeclaration & {
|
||||
scrollbarWidth: string;
|
||||
msOverflowStyle: string;
|
||||
scrollbarGutter: string;
|
||||
};
|
||||
const style = element.style as ScrollbarStyle;
|
||||
const previousScrollbarWidth = style.scrollbarWidth;
|
||||
const previousMsOverflowStyle = style.msOverflowStyle;
|
||||
const previousScrollbarGutter = style.scrollbarGutter;
|
||||
|
||||
element.setAttribute("data-hide-scrollbar", "");
|
||||
style.scrollbarWidth = "none";
|
||||
style.msOverflowStyle = "none";
|
||||
style.scrollbarGutter = "auto";
|
||||
ensureHideScrollbarStyle();
|
||||
|
||||
function update() {
|
||||
const el = elementRef.current;
|
||||
if (!el) return;
|
||||
const next: ScrollbarMetrics = {
|
||||
offset: el.scrollTop,
|
||||
viewportSize: el.clientHeight,
|
||||
contentSize: el.scrollHeight,
|
||||
};
|
||||
setMetrics((prev) => (metricsChanged(prev, next) ? next : prev));
|
||||
}
|
||||
|
||||
element.addEventListener("scroll", update, { passive: true });
|
||||
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
resizeObserver.observe(element);
|
||||
const contentElement = contentRef?.current;
|
||||
if (contentElement) {
|
||||
resizeObserver.observe(contentElement);
|
||||
}
|
||||
|
||||
update();
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("scroll", update);
|
||||
resizeObserver.disconnect();
|
||||
element.removeAttribute("data-hide-scrollbar");
|
||||
style.scrollbarWidth = previousScrollbarWidth;
|
||||
style.msOverflowStyle = previousMsOverflowStyle;
|
||||
style.scrollbarGutter = previousScrollbarGutter;
|
||||
};
|
||||
}, [contentRef, elementRef, enabled]);
|
||||
|
||||
const onScrollToOffset = useCallback(
|
||||
(offset: number) => {
|
||||
elementRef.current?.scrollTo({ top: offset, behavior: "auto" });
|
||||
},
|
||||
[elementRef],
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />
|
||||
);
|
||||
}
|
||||
|
||||
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
|
||||
// Returns event handlers to wire onto your ScrollView/FlatList plus
|
||||
// a renderable overlay. The overlay is null when disabled.
|
||||
|
||||
interface WebScrollViewScrollbar {
|
||||
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
onContentSizeChange: (width: number, height: number) => void;
|
||||
overlay: ReactNode;
|
||||
}
|
||||
|
||||
export function useWebScrollViewScrollbar(
|
||||
scrollableRef: RefObject<ScrollView | FlatList | null>,
|
||||
options?: { enabled?: boolean },
|
||||
): WebScrollViewScrollbar {
|
||||
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
||||
const metricsHook = useWebDesktopScrollbarMetrics();
|
||||
|
||||
const onScrollToOffset = useCallback(
|
||||
(offset: number) => {
|
||||
const scrollable = scrollableRef.current;
|
||||
if (!scrollable) return;
|
||||
if ("scrollToOffset" in scrollable) {
|
||||
scrollable.scrollToOffset({ offset, animated: false });
|
||||
} else {
|
||||
scrollable.scrollTo({ y: offset, animated: false });
|
||||
}
|
||||
},
|
||||
[scrollableRef],
|
||||
);
|
||||
|
||||
const overlay: ReactNode = enabled ? (
|
||||
<WebDesktopScrollbarOverlay enabled metrics={metricsHook} onScrollToOffset={onScrollToOffset} />
|
||||
) : null;
|
||||
|
||||
return {
|
||||
onScroll: metricsHook.onScroll,
|
||||
onLayout: metricsHook.onLayout,
|
||||
onContentSizeChange: metricsHook.onContentSizeChange,
|
||||
overlay,
|
||||
};
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
const DEFAULT_MIN_HANDLE_SIZE = 36;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export interface VerticalScrollbarGeometryInput {
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
offset: number;
|
||||
minHandleSize?: number;
|
||||
}
|
||||
|
||||
export interface VerticalScrollbarGeometry {
|
||||
isVisible: boolean;
|
||||
maxScrollOffset: number;
|
||||
handleSize: number;
|
||||
handleOffset: number;
|
||||
maxHandleOffset: number;
|
||||
}
|
||||
|
||||
export function computeVerticalScrollbarGeometry(
|
||||
input: VerticalScrollbarGeometryInput,
|
||||
): VerticalScrollbarGeometry {
|
||||
const viewportSize = Number.isFinite(input.viewportSize) ? Math.max(0, input.viewportSize) : 0;
|
||||
const contentSize = Number.isFinite(input.contentSize) ? Math.max(0, input.contentSize) : 0;
|
||||
const minHandleSize = Number.isFinite(input.minHandleSize)
|
||||
? Math.max(0, input.minHandleSize ?? DEFAULT_MIN_HANDLE_SIZE)
|
||||
: DEFAULT_MIN_HANDLE_SIZE;
|
||||
|
||||
const maxScrollOffset = Math.max(0, contentSize - viewportSize);
|
||||
if (maxScrollOffset <= 0 || viewportSize <= 0 || contentSize <= 0) {
|
||||
return {
|
||||
isVisible: false,
|
||||
maxScrollOffset: 0,
|
||||
handleSize: 0,
|
||||
handleOffset: 0,
|
||||
maxHandleOffset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const rawHandleSize = (viewportSize * viewportSize) / contentSize;
|
||||
const handleSize = clamp(rawHandleSize, minHandleSize, viewportSize);
|
||||
const maxHandleOffset = Math.max(0, viewportSize - handleSize);
|
||||
const clampedOffset = clamp(input.offset, 0, maxScrollOffset);
|
||||
const handleOffset =
|
||||
maxScrollOffset > 0 ? (clampedOffset / maxScrollOffset) * maxHandleOffset : 0;
|
||||
|
||||
return {
|
||||
isVisible: true,
|
||||
maxScrollOffset,
|
||||
handleSize,
|
||||
handleOffset,
|
||||
maxHandleOffset,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScrollOffsetFromDragDeltaInput {
|
||||
startOffset: number;
|
||||
dragDelta: number;
|
||||
maxScrollOffset: number;
|
||||
maxHandleOffset: number;
|
||||
}
|
||||
|
||||
export function computeScrollOffsetFromDragDelta(input: ScrollOffsetFromDragDeltaInput): number {
|
||||
if (input.maxScrollOffset <= 0 || input.maxHandleOffset <= 0) {
|
||||
return clamp(input.startOffset, 0, Math.max(0, input.maxScrollOffset));
|
||||
}
|
||||
|
||||
const scrollPerPixel = input.maxScrollOffset / input.maxHandleOffset;
|
||||
const nextOffset = input.startOffset + input.dragDelta * scrollPerPixel;
|
||||
return clamp(nextOffset, 0, input.maxScrollOffset);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeScrollOffsetFromDragDelta,
|
||||
computeVerticalScrollbarGeometry,
|
||||
} from "./web-desktop-scrollbar.math";
|
||||
|
||||
describe("computeVerticalScrollbarGeometry", () => {
|
||||
it("returns hidden geometry when content does not overflow", () => {
|
||||
const geometry = computeVerticalScrollbarGeometry({
|
||||
viewportSize: 500,
|
||||
contentSize: 500,
|
||||
offset: 0,
|
||||
minHandleSize: 36,
|
||||
});
|
||||
|
||||
expect(geometry).toEqual({
|
||||
isVisible: false,
|
||||
maxScrollOffset: 0,
|
||||
handleSize: 0,
|
||||
handleOffset: 0,
|
||||
maxHandleOffset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("computes visible geometry when content overflows", () => {
|
||||
const geometry = computeVerticalScrollbarGeometry({
|
||||
viewportSize: 500,
|
||||
contentSize: 2000,
|
||||
offset: 375,
|
||||
minHandleSize: 36,
|
||||
});
|
||||
|
||||
expect(geometry).toEqual({
|
||||
isVisible: true,
|
||||
maxScrollOffset: 1500,
|
||||
handleSize: 125,
|
||||
handleOffset: 93.75,
|
||||
maxHandleOffset: 375,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps handle size to min and offset to bounds", () => {
|
||||
const geometry = computeVerticalScrollbarGeometry({
|
||||
viewportSize: 100,
|
||||
contentSize: 10000,
|
||||
offset: 99999,
|
||||
minHandleSize: 24,
|
||||
});
|
||||
|
||||
expect(geometry).toEqual({
|
||||
isVisible: true,
|
||||
maxScrollOffset: 9900,
|
||||
handleSize: 24,
|
||||
handleOffset: 76,
|
||||
maxHandleOffset: 76,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeScrollOffsetFromDragDelta", () => {
|
||||
it("maps drag distance proportionally to scroll offset", () => {
|
||||
const nextOffset = computeScrollOffsetFromDragDelta({
|
||||
startOffset: 250,
|
||||
dragDelta: 50,
|
||||
maxScrollOffset: 1000,
|
||||
maxHandleOffset: 200,
|
||||
});
|
||||
|
||||
expect(nextOffset).toBe(500);
|
||||
});
|
||||
|
||||
it("clamps to scroll bounds", () => {
|
||||
const nextOffset = computeScrollOffsetFromDragDelta({
|
||||
startOffset: 900,
|
||||
dragDelta: 1000,
|
||||
maxScrollOffset: 1000,
|
||||
maxHandleOffset: 200,
|
||||
});
|
||||
|
||||
expect(nextOffset).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -1,464 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
PanResponder,
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ViewStyle,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import {
|
||||
computeScrollOffsetFromDragDelta,
|
||||
computeVerticalScrollbarGeometry,
|
||||
} from "./web-desktop-scrollbar.math";
|
||||
|
||||
const METRICS_EPSILON = 0.5;
|
||||
const HANDLE_WIDTH_IDLE = 6;
|
||||
const HANDLE_WIDTH_ACTIVE = 9;
|
||||
const HANDLE_GRAB_WIDTH = 18;
|
||||
const HANDLE_GRAB_VERTICAL_PADDING = 8;
|
||||
const HANDLE_OPACITY_VISIBLE = 0.62;
|
||||
const HANDLE_OPACITY_HOVERED = 0.78;
|
||||
const HANDLE_OPACITY_DRAGGING = 0.9;
|
||||
const HANDLE_TRAVEL_TRANSITION_DURATION_MS = 90;
|
||||
const HANDLE_FADE_DURATION_MS = 220;
|
||||
const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
|
||||
const HANDLE_SCROLL_VISIBILITY_MS = 1200;
|
||||
const HANDLE_SCROLL_ACTIVE_MS = 110;
|
||||
|
||||
interface WebPointerStyle {
|
||||
cursor?: "grab" | "grabbing";
|
||||
touchAction?: "none";
|
||||
userSelect?: "none";
|
||||
transitionProperty?: string;
|
||||
transitionDuration?: string;
|
||||
transitionTimingFunction?: string;
|
||||
}
|
||||
|
||||
interface PointerLikeEvent {
|
||||
clientY?: number;
|
||||
pageY?: number;
|
||||
nativeEvent?: { clientY?: number; pageY?: number; preventDefault?: () => void };
|
||||
preventDefault?: () => void;
|
||||
stopPropagation?: () => void;
|
||||
}
|
||||
|
||||
function readClientY(event: PointerLikeEvent): number | null {
|
||||
const value =
|
||||
event?.nativeEvent?.clientY ?? event?.clientY ?? event?.nativeEvent?.pageY ?? event?.pageY;
|
||||
return typeof value === "number" ? value : null;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export interface ScrollbarMetrics {
|
||||
offset: number;
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
}
|
||||
|
||||
function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
|
||||
return (
|
||||
Math.abs(a.offset - b.offset) <= METRICS_EPSILON &&
|
||||
Math.abs(a.viewportSize - b.viewportSize) <= METRICS_EPSILON &&
|
||||
Math.abs(a.contentSize - b.contentSize) <= METRICS_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
interface WebDesktopScrollbarOverlayProps {
|
||||
enabled: boolean;
|
||||
metrics: ScrollbarMetrics;
|
||||
onScrollToOffset: (offset: number) => void;
|
||||
inverted?: boolean;
|
||||
}
|
||||
|
||||
export function useWebDesktopScrollbarMetrics() {
|
||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
});
|
||||
|
||||
const setMetricsIfChanged = useCallback((next: ScrollbarMetrics) => {
|
||||
setMetrics((previous) => (areMetricsEqual(previous, next) ? previous : next));
|
||||
}, []);
|
||||
|
||||
const onScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
|
||||
setMetricsIfChanged({
|
||||
offset: Math.max(0, contentOffset.y),
|
||||
viewportSize: Math.max(0, layoutMeasurement.height),
|
||||
contentSize: Math.max(0, contentSize.height),
|
||||
});
|
||||
},
|
||||
[setMetricsIfChanged],
|
||||
);
|
||||
|
||||
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const viewportSize = Math.max(0, event.nativeEvent.layout.height);
|
||||
setMetrics((previous) => {
|
||||
const next = { ...previous, viewportSize };
|
||||
return areMetricsEqual(previous, next) ? previous : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onContentSizeChange = useCallback((_width: number, height: number) => {
|
||||
const contentSize = Math.max(0, height);
|
||||
setMetrics((previous) => {
|
||||
const next = { ...previous, contentSize };
|
||||
return areMetricsEqual(previous, next) ? previous : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setOffset = useCallback((offset: number) => {
|
||||
const clampedOffset = Math.max(0, offset);
|
||||
setMetrics((previous) => {
|
||||
const next = { ...previous, offset: clampedOffset };
|
||||
return areMetricsEqual(previous, next) ? previous : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...metrics,
|
||||
onScroll,
|
||||
onLayout,
|
||||
onContentSizeChange,
|
||||
setOffset,
|
||||
};
|
||||
}
|
||||
|
||||
export function WebDesktopScrollbarOverlay({
|
||||
enabled,
|
||||
metrics,
|
||||
onScrollToOffset,
|
||||
inverted = false,
|
||||
}: WebDesktopScrollbarOverlayProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHandleHovered, setIsHandleHovered] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
||||
const [isScrollActive, setIsScrollActive] = useState(false);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const dragStartClientYRef = useRef(0);
|
||||
const scrollVisibilityTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastObservedOffsetRef = useRef<number | null>(null);
|
||||
const geometryRef = useRef({
|
||||
maxHandleOffset: 0,
|
||||
maxScrollOffset: 0,
|
||||
});
|
||||
const onScrollToOffsetRef = useRef(onScrollToOffset);
|
||||
|
||||
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
|
||||
const normalizedOffset = inverted
|
||||
? Math.max(0, maxScrollOffset - clamp(metrics.offset, 0, maxScrollOffset))
|
||||
: clamp(metrics.offset, 0, maxScrollOffset);
|
||||
const normalizedOffsetRef = useRef(normalizedOffset);
|
||||
|
||||
const geometry = useMemo(
|
||||
() =>
|
||||
computeVerticalScrollbarGeometry({
|
||||
viewportSize: metrics.viewportSize,
|
||||
contentSize: metrics.contentSize,
|
||||
offset: normalizedOffset,
|
||||
}),
|
||||
[metrics.contentSize, metrics.viewportSize, normalizedOffset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
geometryRef.current = {
|
||||
maxHandleOffset: geometry.maxHandleOffset,
|
||||
maxScrollOffset: geometry.maxScrollOffset,
|
||||
};
|
||||
}, [geometry.maxHandleOffset, geometry.maxScrollOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
onScrollToOffsetRef.current = onScrollToOffset;
|
||||
}, [onScrollToOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
normalizedOffsetRef.current = normalizedOffset;
|
||||
}, [normalizedOffset]);
|
||||
|
||||
const clearScrollVisibilityTimeout = useCallback(() => {
|
||||
if (scrollVisibilityTimeoutRef.current === null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(scrollVisibilityTimeoutRef.current);
|
||||
scrollVisibilityTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearScrollActiveTimeout = useCallback(() => {
|
||||
if (scrollActiveTimeoutRef.current === null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(scrollActiveTimeoutRef.current);
|
||||
scrollActiveTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const revealScrollbarFromScroll = useCallback(() => {
|
||||
setIsScrollVisible(true);
|
||||
clearScrollVisibilityTimeout();
|
||||
scrollVisibilityTimeoutRef.current = setTimeout(() => {
|
||||
setIsScrollVisible(false);
|
||||
scrollVisibilityTimeoutRef.current = null;
|
||||
}, HANDLE_SCROLL_VISIBILITY_MS);
|
||||
}, [clearScrollVisibilityTimeout]);
|
||||
|
||||
const markScrollActivity = useCallback(() => {
|
||||
setIsScrollActive(true);
|
||||
clearScrollActiveTimeout();
|
||||
scrollActiveTimeoutRef.current = setTimeout(() => {
|
||||
setIsScrollActive(false);
|
||||
scrollActiveTimeoutRef.current = null;
|
||||
}, HANDLE_SCROLL_ACTIVE_MS);
|
||||
}, [clearScrollActiveTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !geometry.isVisible) {
|
||||
setIsScrollVisible(false);
|
||||
setIsScrollActive(false);
|
||||
clearScrollVisibilityTimeout();
|
||||
clearScrollActiveTimeout();
|
||||
lastObservedOffsetRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOffset = lastObservedOffsetRef.current;
|
||||
lastObservedOffsetRef.current = normalizedOffset;
|
||||
if (previousOffset === null) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(normalizedOffset - previousOffset) <= METRICS_EPSILON) {
|
||||
return;
|
||||
}
|
||||
revealScrollbarFromScroll();
|
||||
markScrollActivity();
|
||||
}, [
|
||||
clearScrollActiveTimeout,
|
||||
clearScrollVisibilityTimeout,
|
||||
enabled,
|
||||
geometry.isVisible,
|
||||
markScrollActivity,
|
||||
normalizedOffset,
|
||||
revealScrollbarFromScroll,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearScrollActiveTimeout();
|
||||
clearScrollVisibilityTimeout();
|
||||
},
|
||||
[clearScrollActiveTimeout, clearScrollVisibilityTimeout],
|
||||
);
|
||||
|
||||
const applyDragDelta = useCallback(
|
||||
(dragDelta: number) => {
|
||||
const currentGeometry = geometryRef.current;
|
||||
const nextNormalizedOffset = computeScrollOffsetFromDragDelta({
|
||||
startOffset: dragStartOffsetRef.current,
|
||||
dragDelta,
|
||||
maxScrollOffset: currentGeometry.maxScrollOffset,
|
||||
maxHandleOffset: currentGeometry.maxHandleOffset,
|
||||
});
|
||||
const nextOffset = inverted
|
||||
? currentGeometry.maxScrollOffset - nextNormalizedOffset
|
||||
: nextNormalizedOffset;
|
||||
onScrollToOffsetRef.current(nextOffset);
|
||||
},
|
||||
[inverted],
|
||||
);
|
||||
|
||||
const panResponder = useMemo(() => {
|
||||
if (platformIsWeb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderTerminationRequest: () => false,
|
||||
onPanResponderGrant: (event: GestureResponderEvent) => {
|
||||
const clientY = readClientY(event);
|
||||
dragStartOffsetRef.current = normalizedOffsetRef.current;
|
||||
if (clientY !== null) {
|
||||
dragStartClientYRef.current = clientY;
|
||||
}
|
||||
setIsDragging(true);
|
||||
},
|
||||
onPanResponderMove: (_event, gestureState) => {
|
||||
applyDragDelta(gestureState.dy);
|
||||
},
|
||||
onPanResponderRelease: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
});
|
||||
}, [applyDragDelta]);
|
||||
|
||||
const startWebDrag = useCallback((event: PointerLikeEvent) => {
|
||||
if (!platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
const clientY = readClientY(event);
|
||||
if (clientY === null) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
event?.nativeEvent?.preventDefault?.();
|
||||
dragStartOffsetRef.current = normalizedOffsetRef.current;
|
||||
dragStartClientYRef.current = clientY;
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleGrabHoverIn = useCallback(() => {
|
||||
if (!isScrollVisible && !isDragging) {
|
||||
return;
|
||||
}
|
||||
setIsHandleHovered(true);
|
||||
}, [isDragging, isScrollVisible]);
|
||||
|
||||
const handleGrabHoverOut = useCallback(() => {
|
||||
setIsHandleHovered(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platformIsWeb || !isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const dragDelta = event.clientY - dragStartClientYRef.current;
|
||||
applyDragDelta(dragDelta);
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopDragging);
|
||||
window.addEventListener("pointercancel", stopDragging);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopDragging);
|
||||
window.removeEventListener("pointercancel", stopDragging);
|
||||
};
|
||||
}, [applyDragDelta, isDragging]);
|
||||
|
||||
const handleVisible = isDragging || isScrollVisible || isHandleHovered;
|
||||
let handleOpacity: number;
|
||||
if (isDragging) handleOpacity = HANDLE_OPACITY_DRAGGING;
|
||||
else if (isHandleHovered) handleOpacity = HANDLE_OPACITY_HOVERED;
|
||||
else if (isScrollVisible) handleOpacity = HANDLE_OPACITY_VISIBLE;
|
||||
else handleOpacity = 0;
|
||||
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
|
||||
const handleColor = theme.colors.scrollbarHandle;
|
||||
const handleCursor = isDragging ? "grabbing" : "grab";
|
||||
const handleTravelDurationMs =
|
||||
isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS;
|
||||
const thumbRegionOffset = Math.max(0, geometry.handleOffset - HANDLE_GRAB_VERTICAL_PADDING);
|
||||
const thumbRegionHeight = Math.min(
|
||||
metrics.viewportSize - thumbRegionOffset,
|
||||
geometry.handleSize + HANDLE_GRAB_VERTICAL_PADDING * 2,
|
||||
);
|
||||
const handleInsetTop = Math.max(0, (thumbRegionHeight - geometry.handleSize) / 2);
|
||||
|
||||
const thumbRegionStyle = useMemo(
|
||||
() => [
|
||||
styles.thumbRegion,
|
||||
inlineUnistylesStyle({
|
||||
height: thumbRegionHeight,
|
||||
transform: [{ translateY: thumbRegionOffset }],
|
||||
}),
|
||||
platformIsWeb &&
|
||||
inlineUnistylesStyle({
|
||||
cursor: handleCursor,
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
transitionProperty: "transform",
|
||||
transitionDuration: `${handleTravelDurationMs}ms`,
|
||||
transitionTimingFunction: "linear",
|
||||
} satisfies WebPointerStyle as unknown as ViewStyle),
|
||||
],
|
||||
[thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs],
|
||||
);
|
||||
|
||||
const handleStyle = useMemo(
|
||||
() => [
|
||||
styles.handle,
|
||||
inlineUnistylesStyle({
|
||||
marginTop: handleInsetTop,
|
||||
height: geometry.handleSize,
|
||||
width: handleWidth,
|
||||
backgroundColor: handleColor,
|
||||
opacity: handleOpacity,
|
||||
}),
|
||||
platformIsWeb &&
|
||||
inlineUnistylesStyle({
|
||||
transitionProperty: "opacity, width, background-color",
|
||||
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
|
||||
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
|
||||
} satisfies WebPointerStyle as unknown as ViewStyle),
|
||||
],
|
||||
[handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity],
|
||||
);
|
||||
|
||||
if (!enabled || !geometry.isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<View
|
||||
style={thumbRegionStyle}
|
||||
pointerEvents={handleVisible ? "auto" : "none"}
|
||||
{...(panResponder?.panHandlers ?? {})}
|
||||
{...(platformIsWeb
|
||||
? ({
|
||||
onPointerDown: startWebDrag,
|
||||
onMouseEnter: handleGrabHoverIn,
|
||||
onMouseLeave: handleGrabHoverOut,
|
||||
} as object)
|
||||
: null)}
|
||||
>
|
||||
<View style={handleStyle} pointerEvents="none" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
zIndex: 10,
|
||||
},
|
||||
handle: {
|
||||
width: HANDLE_WIDTH_IDLE,
|
||||
borderRadius: 999,
|
||||
alignSelf: "center",
|
||||
},
|
||||
thumbRegion: {
|
||||
position: "absolute",
|
||||
right: -3,
|
||||
width: HANDLE_GRAB_WIDTH,
|
||||
top: 0,
|
||||
},
|
||||
}));
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit";
|
||||
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||
@@ -1616,10 +1615,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
}
|
||||
}, [getWebTextArea]);
|
||||
|
||||
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
|
||||
enabled: isWeb,
|
||||
});
|
||||
|
||||
usePasteImagesEffect({
|
||||
getWebTextArea,
|
||||
isConnected,
|
||||
@@ -1842,7 +1837,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onSelectionChange={handleSelectionChange}
|
||||
autoFocus={isWeb && autoFocus}
|
||||
/>
|
||||
{inputScrollbar}
|
||||
<FocusHint
|
||||
visible={isWeb && isPaneFocused && !isInputFocused && !value}
|
||||
focusInputKeys={focusInputKeys}
|
||||
|
||||
@@ -85,7 +85,6 @@ import {
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
@@ -1536,8 +1535,6 @@ interface DiffBodyContentProps {
|
||||
diffListRef: RefObject<FlatList<DiffFlatItem> | null>;
|
||||
handleDiffListLayout: (event: LayoutChangeEvent) => void;
|
||||
handleDiffListScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
onContentSizeChange: (width: number, height: number) => void;
|
||||
showDesktopWebScrollbar: boolean;
|
||||
checkingRepositoryLabel: string;
|
||||
notRepositoryLabel: string;
|
||||
}
|
||||
@@ -1559,8 +1556,6 @@ function DiffBodyContent({
|
||||
diffListRef,
|
||||
handleDiffListLayout,
|
||||
handleDiffListScroll,
|
||||
onContentSizeChange,
|
||||
showDesktopWebScrollbar,
|
||||
checkingRepositoryLabel,
|
||||
notRepositoryLabel,
|
||||
}: DiffBodyContentProps) {
|
||||
@@ -1621,9 +1616,8 @@ function DiffBodyContent({
|
||||
testID="git-diff-scroll"
|
||||
onLayout={handleDiffListLayout}
|
||||
onScroll={handleDiffListScroll}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
showsVerticalScrollIndicator
|
||||
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
||||
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
|
||||
removeClippedSubviews={false}
|
||||
@@ -1737,7 +1731,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
const canUseSplitLayout = isWeb && !isMobile;
|
||||
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
||||
useChangesPreferences();
|
||||
@@ -1963,9 +1956,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
}
|
||||
void updateChangesPreferences({ viewMode: nextViewMode });
|
||||
}, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]);
|
||||
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
});
|
||||
const diffListScrollOffsetRef = useRef(0);
|
||||
const diffListViewportHeightRef = useRef(0);
|
||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||
@@ -2089,25 +2079,17 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
[getBodyHeightKey],
|
||||
);
|
||||
|
||||
const handleDiffListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
scrollbar.onScroll(event);
|
||||
},
|
||||
[scrollbar],
|
||||
);
|
||||
const handleDiffListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
|
||||
const handleDiffListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
const height = event.nativeEvent.layout.height;
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
scrollbar.onLayout(event);
|
||||
},
|
||||
[scrollbar],
|
||||
);
|
||||
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const height = event.nativeEvent.layout.height;
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
}, []);
|
||||
|
||||
// Offset of the first item matching `predicate`, walking the SAME flatItems
|
||||
// list getFlatItemLayout uses so folder rows are counted (single source of
|
||||
@@ -2379,8 +2361,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
diffListRef={diffListRef}
|
||||
handleDiffListLayout={handleDiffListLayout}
|
||||
handleDiffListScroll={handleDiffListScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
|
||||
notRepositoryLabel={t("workspace.git.diff.notRepository")}
|
||||
/>
|
||||
@@ -2479,10 +2459,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
|
||||
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
||||
|
||||
<View style={styles.diffContainer}>
|
||||
{bodyContent}
|
||||
{hasChanges ? scrollbar.overlay : null}
|
||||
</View>
|
||||
<View style={styles.diffContainer}>{bodyContent}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,14 +341,14 @@ describe("appearance settings", () => {
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
|
||||
});
|
||||
|
||||
it("loads an explicit tool call detail level", async () => {
|
||||
it("maps an unrecognized tool call detail level to overview", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "concise" }),
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("concise");
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
|
||||
});
|
||||
|
||||
it("clamps the UI font size into range and rejects non-numeric values", async () => {
|
||||
|
||||
@@ -12,16 +12,12 @@ export type SendBehavior = "interrupt" | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||
export type WorkspaceTitleSource = "title" | "branch";
|
||||
export type ToolCallDetailLevel = "overview" | "concise" | "detailed";
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
|
||||
const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>([
|
||||
"overview",
|
||||
"concise",
|
||||
"detailed",
|
||||
]);
|
||||
const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>(["overview", "detailed"]);
|
||||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
|
||||
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
|
||||
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||||
@@ -172,11 +168,16 @@ export function normalizeAppSettings(value: unknown): AppSettings {
|
||||
}
|
||||
|
||||
function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null {
|
||||
if (
|
||||
typeof stored.toolCallDetailLevel === "string" &&
|
||||
VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel)
|
||||
) {
|
||||
return stored.toolCallDetailLevel;
|
||||
if (stored.toolCallDetailLevel !== undefined) {
|
||||
if (
|
||||
typeof stored.toolCallDetailLevel === "string" &&
|
||||
VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel)
|
||||
) {
|
||||
return stored.toolCallDetailLevel;
|
||||
}
|
||||
// COMPAT(toolCallDetailLevelConcise): removed in v0.1.107; legacy "concise" values
|
||||
// deliberately follow the unknown-value fallback. Remove after 2027-01-14.
|
||||
return "overview";
|
||||
}
|
||||
if (typeof stored.compactToolCalls === "boolean") {
|
||||
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./use-web-scrollbar-style.web";
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { ViewStyle } from "react-native";
|
||||
|
||||
export function useWebScrollbarStyle(): ViewStyle | undefined {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
|
||||
// CSS scrollbar properties are supported by React Native Web at runtime
|
||||
// but are not included in React Native's ViewStyle type definition.
|
||||
interface WebScrollbarStyle extends ViewStyle {
|
||||
scrollbarColor: string;
|
||||
scrollbarWidth: string;
|
||||
}
|
||||
|
||||
export function useWebScrollbarStyle(): WebScrollbarStyle {
|
||||
const { theme } = useUnistyles();
|
||||
return useMemo(
|
||||
(): WebScrollbarStyle => ({
|
||||
scrollbarColor: `${theme.colors.scrollbarHandle} transparent`,
|
||||
scrollbarWidth: "thin",
|
||||
}),
|
||||
[theme.colors.scrollbarHandle],
|
||||
);
|
||||
}
|
||||
@@ -1393,8 +1393,6 @@ export const ar: TranslationResources = {
|
||||
output: "الإخراج",
|
||||
},
|
||||
toolCallGroup: {
|
||||
title: "الأدوات",
|
||||
accessibilityLabel: "الأدوات، {{count}} استدعاءات",
|
||||
editedFiles: {
|
||||
one: "حرّر {{count}} ملفًا",
|
||||
other: "حرّر {{count}} ملفات",
|
||||
@@ -1520,13 +1518,12 @@ export const ar: TranslationResources = {
|
||||
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "تفاصيل استدعاءات الأدوات",
|
||||
description: "كيفية ظهور نشاط الأدوات في الخط الزمني للوكيل",
|
||||
accessibilityLabel: "حدد مستوى تفاصيل الأدوات ({{value}})",
|
||||
label: "عرض استدعاءات الأدوات",
|
||||
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
||||
accessibilityLabel: "حدد عرض استدعاءات الأدوات ({{value}})",
|
||||
options: {
|
||||
overview: "نظرة عامة",
|
||||
concise: "موجز",
|
||||
detailed: "مفصل",
|
||||
overview: "ملخص",
|
||||
detailed: "التفاصيل الكاملة",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1401,8 +1401,6 @@ export const en = {
|
||||
output: "Output",
|
||||
},
|
||||
toolCallGroup: {
|
||||
title: "Tools",
|
||||
accessibilityLabel: "Tools, {{count}} calls",
|
||||
editedFiles: {
|
||||
one: "edited {{count}} file",
|
||||
other: "edited {{count}} files",
|
||||
@@ -1527,13 +1525,12 @@ export const en = {
|
||||
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Tool call detail",
|
||||
description: "How tool activity appears in agent timelines",
|
||||
accessibilityLabel: "Select tool call detail ({{value}})",
|
||||
label: "Tool call display",
|
||||
description: "How tool calls appear in the timeline",
|
||||
accessibilityLabel: "Select tool call display ({{value}})",
|
||||
options: {
|
||||
overview: "Overview",
|
||||
concise: "Concise",
|
||||
detailed: "Detailed",
|
||||
overview: "Summary",
|
||||
detailed: "Full detail",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1432,8 +1432,6 @@ export const es: TranslationResources = {
|
||||
output: "Producción",
|
||||
},
|
||||
toolCallGroup: {
|
||||
title: "Herramientas",
|
||||
accessibilityLabel: "Herramientas, {{count}} llamadas",
|
||||
editedFiles: {
|
||||
one: "editó {{count}} archivo",
|
||||
other: "editó {{count}} archivos",
|
||||
@@ -1561,13 +1559,12 @@ export const es: TranslationResources = {
|
||||
"Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Detalle de llamadas a herramientas",
|
||||
description: "Cómo aparece la actividad de herramientas en las cronologías del agente",
|
||||
accessibilityLabel: "Seleccionar detalle de herramientas ({{value}})",
|
||||
label: "Visualización de llamadas a herramientas",
|
||||
description: "Cómo aparecen las llamadas a herramientas en la cronología",
|
||||
accessibilityLabel: "Seleccionar visualización de llamadas a herramientas ({{value}})",
|
||||
options: {
|
||||
overview: "Resumen",
|
||||
concise: "Conciso",
|
||||
detailed: "Detallado",
|
||||
detailed: "Detalle completo",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1435,8 +1435,6 @@ export const fr: TranslationResources = {
|
||||
output: "Sortir",
|
||||
},
|
||||
toolCallGroup: {
|
||||
title: "Outils",
|
||||
accessibilityLabel: "Outils, {{count}} appels",
|
||||
editedFiles: {
|
||||
one: "a modifié {{count}} fichier",
|
||||
other: "a modifié {{count}} fichiers",
|
||||
@@ -1563,13 +1561,12 @@ export const fr: TranslationResources = {
|
||||
description: "Afficher le raisonnement de l'agent entièrement développé par défaut",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Détail des appels d’outils",
|
||||
description: "Affichage de l’activité des outils dans la chronologie de l’agent",
|
||||
accessibilityLabel: "Sélectionner le détail des outils ({{value}})",
|
||||
label: "Affichage des appels d’outils",
|
||||
description: "Comment les appels d’outils apparaissent dans la chronologie",
|
||||
accessibilityLabel: "Sélectionner l’affichage des appels d’outils ({{value}})",
|
||||
options: {
|
||||
overview: "Vue d’ensemble",
|
||||
concise: "Concis",
|
||||
detailed: "Détaillé",
|
||||
overview: "Résumé",
|
||||
detailed: "Détails complets",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1410,8 +1410,6 @@ export const ja: TranslationResources = {
|
||||
output: "出力",
|
||||
},
|
||||
toolCallGroup: {
|
||||
title: "ツール",
|
||||
accessibilityLabel: "ツール、{{count}}件の呼び出し",
|
||||
editedFiles: {
|
||||
one: "{{count}}個のファイルを編集",
|
||||
other: "{{count}}個のファイルを編集",
|
||||
@@ -1536,13 +1534,12 @@ export const ja: TranslationResources = {
|
||||
description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "ツール呼び出しの詳細",
|
||||
description: "エージェントのタイムラインでのツール活動の表示方法",
|
||||
accessibilityLabel: "ツール詳細を選択({{value}})",
|
||||
label: "ツール呼び出しの表示",
|
||||
description: "タイムラインでのツール呼び出しの表示方法",
|
||||
accessibilityLabel: "ツール呼び出しの表示を選択({{value}})",
|
||||
options: {
|
||||
overview: "概要",
|
||||
concise: "簡潔",
|
||||
detailed: "詳細",
|
||||
overview: "要約",
|
||||
detailed: "すべての詳細",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1418,8 +1418,6 @@ export const ptBR: TranslationResources = {
|
||||
output: "Saída",
|
||||
},
|
||||
toolCallGroup: {
|
||||
title: "Ferramentas",
|
||||
accessibilityLabel: "Ferramentas, {{count}} chamadas",
|
||||
editedFiles: {
|
||||
one: "editou {{count}} arquivo",
|
||||
other: "editou {{count}} arquivos",
|
||||
@@ -1546,13 +1544,12 @@ export const ptBR: TranslationResources = {
|
||||
"Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Detalhe das chamadas de ferramentas",
|
||||
description: "Como a atividade das ferramentas aparece na linha do tempo do agente",
|
||||
accessibilityLabel: "Selecionar detalhe das ferramentas ({{value}})",
|
||||
label: "Exibição de chamadas de ferramentas",
|
||||
description: "Como as chamadas de ferramentas aparecem na linha do tempo",
|
||||
accessibilityLabel: "Selecionar exibição de chamadas de ferramentas ({{value}})",
|
||||
options: {
|
||||
overview: "Visão geral",
|
||||
concise: "Conciso",
|
||||
detailed: "Detalhado",
|
||||
overview: "Resumo",
|
||||
detailed: "Detalhes completos",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1424,8 +1424,6 @@ export const ru: TranslationResources = {
|
||||
output: "Выход",
|
||||
},
|
||||
toolCallGroup: {
|
||||
title: "Инструменты",
|
||||
accessibilityLabel: "Инструменты, вызовов: {{count}}",
|
||||
editedFiles: {
|
||||
one: "изменён {{count}} файл",
|
||||
other: "изменено {{count}} файлов",
|
||||
@@ -1551,13 +1549,12 @@ export const ru: TranslationResources = {
|
||||
"По умолчанию показывать блоки размышлений и логики агента полностью развернутыми",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Детализация вызовов инструментов",
|
||||
description: "Отображение активности инструментов в хронологии агента",
|
||||
accessibilityLabel: "Выбрать детализацию инструментов ({{value}})",
|
||||
label: "Отображение вызовов инструментов",
|
||||
description: "Как вызовы инструментов отображаются на временной шкале",
|
||||
accessibilityLabel: "Выбрать отображение вызовов инструментов ({{value}})",
|
||||
options: {
|
||||
overview: "Обзор",
|
||||
concise: "Кратко",
|
||||
detailed: "Подробно",
|
||||
overview: "Сводка",
|
||||
detailed: "Полная детализация",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1377,8 +1377,6 @@ export const zhCN: TranslationResources = {
|
||||
output: "输出",
|
||||
},
|
||||
toolCallGroup: {
|
||||
title: "工具",
|
||||
accessibilityLabel: "工具,{{count}} 次调用",
|
||||
editedFiles: {
|
||||
one: "编辑了 {{count}} 个文件",
|
||||
other: "编辑了 {{count}} 个文件",
|
||||
@@ -1503,13 +1501,12 @@ export const zhCN: TranslationResources = {
|
||||
description: "默认情况下完全展开 AI 的思考和推理过程",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "工具调用详情",
|
||||
description: "工具活动在智能体时间线中的显示方式",
|
||||
accessibilityLabel: "选择工具调用详情({{value}})",
|
||||
label: "工具调用显示",
|
||||
description: "工具调用在时间线中的显示方式",
|
||||
accessibilityLabel: "选择工具调用显示方式({{value}})",
|
||||
options: {
|
||||
overview: "概览",
|
||||
concise: "简洁",
|
||||
detailed: "详细",
|
||||
overview: "摘要",
|
||||
detailed: "完整详情",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -103,7 +103,6 @@ import {
|
||||
type EnableBuiltInDaemonOption,
|
||||
useEnableBuiltInDaemonOption,
|
||||
} from "@/desktop/hooks/use-enable-built-in-daemon-option";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
@@ -1134,11 +1133,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insetBottomStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const scrollViewStyle = useMemo(
|
||||
() => [styles.scrollView, webScrollbarStyle],
|
||||
[webScrollbarStyle],
|
||||
);
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const sortedHosts = useSortedHosts(hosts, localServerId);
|
||||
@@ -1469,7 +1463,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title={t("settings.title")} onBack={handleBackToWorkspace} />
|
||||
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<SettingsSidebar
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
@@ -1500,7 +1494,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
titleAccessory={detailHeader?.titleAccessory}
|
||||
onBack={detailBackHandler}
|
||||
/>
|
||||
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</ScrollView>
|
||||
{addHostModals}
|
||||
@@ -1547,7 +1541,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
}
|
||||
leftStyle={desktopStyles.detailLeft}
|
||||
/>
|
||||
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -214,9 +214,8 @@ function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps
|
||||
|
||||
const TOOL_CALL_DETAIL_ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [
|
||||
"overview",
|
||||
"concise",
|
||||
"detailed",
|
||||
"overview",
|
||||
];
|
||||
|
||||
function getToolCallDetailLevelLabel(
|
||||
|
||||
@@ -20,7 +20,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
|
||||
interface StartupSplashScreenProps {
|
||||
@@ -300,15 +299,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const errorScrollViewStyle = useMemo(
|
||||
() => [styles.errorScrollView, webScrollbarStyle],
|
||||
[webScrollbarStyle],
|
||||
);
|
||||
const logsScrollStyle = useMemo(
|
||||
() => [styles.logsScroll, webScrollbarStyle],
|
||||
[webScrollbarStyle],
|
||||
);
|
||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||
const [logsError, setLogsError] = useState<string | null>(null);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
@@ -404,7 +394,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
<View style={styles.errorScreen}>
|
||||
<TitlebarDragRegion />
|
||||
<ScrollView
|
||||
style={errorScrollViewStyle}
|
||||
style={styles.errorScrollView}
|
||||
contentContainerStyle={styles.errorScrollContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
@@ -424,7 +414,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
|
||||
<View style={styles.logsContainer}>
|
||||
<ScrollView
|
||||
style={logsScrollStyle}
|
||||
style={styles.logsScroll}
|
||||
contentContainerStyle={styles.logsContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
|
||||
3
packages/app/src/styles/install-web-scrollbar-styles.ts
Normal file
3
packages/app/src/styles/install-web-scrollbar-styles.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function installWebScrollbarStyles(): () => void {
|
||||
return () => {};
|
||||
}
|
||||
43
packages/app/src/styles/install-web-scrollbar-styles.web.ts
Normal file
43
packages/app/src/styles/install-web-scrollbar-styles.web.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
WEB_SCROLLBAR_SIZE_PX,
|
||||
webScrollbarColor,
|
||||
webScrollbarThumbColor,
|
||||
WEB_SCROLLBAR_WIDTH,
|
||||
} from "@/styles/web-scrollbar";
|
||||
|
||||
const STYLE_ID = "paseo-web-scrollbar-styles";
|
||||
|
||||
export function installWebScrollbarStyles(): () => void {
|
||||
const existingStyle = document.getElementById(STYLE_ID);
|
||||
if (existingStyle) return () => {};
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
* {
|
||||
scrollbar-color: ${webScrollbarColor("var(--colors-scrollbar-handle)")};
|
||||
scrollbar-width: ${WEB_SCROLLBAR_WIDTH};
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: ${WEB_SCROLLBAR_SIZE_PX}px;
|
||||
height: ${WEB_SCROLLBAR_SIZE_PX}px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track,
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: ${webScrollbarThumbColor("var(--colors-scrollbar-handle)")};
|
||||
background-clip: content-box;
|
||||
}
|
||||
`;
|
||||
document.head.append(style);
|
||||
|
||||
return () => style.remove();
|
||||
}
|
||||
10
packages/app/src/styles/web-scrollbar.ts
Normal file
10
packages/app/src/styles/web-scrollbar.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const WEB_SCROLLBAR_WIDTH = "thin";
|
||||
export const WEB_SCROLLBAR_SIZE_PX = 8;
|
||||
|
||||
export function webScrollbarThumbColor(handleColor: string): string {
|
||||
return `color-mix(in srgb, ${handleColor} 62%, transparent)`;
|
||||
}
|
||||
|
||||
export function webScrollbarColor(handleColor: string): string {
|
||||
return `${webScrollbarThumbColor(handleColor)} transparent`;
|
||||
}
|
||||
228
packages/app/src/tool-calls/detail-level/grouping.ts
Normal file
228
packages/app/src/tool-calls/detail-level/grouping.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import type { StreamItem, ToolCallItem } from "@/types/stream";
|
||||
|
||||
export interface ToolCallDescriptor {
|
||||
detail: ToolCallDetail;
|
||||
name: string;
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
error: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolCallRun {
|
||||
id: string;
|
||||
calls: readonly ToolCallItem[];
|
||||
latest: ToolCallItem;
|
||||
isSealed: boolean;
|
||||
}
|
||||
|
||||
export interface GroupedHistory<TGroup> {
|
||||
tail: StreamItem[];
|
||||
groupsByHostId: Map<string, TGroup>;
|
||||
pendingCalls: readonly ToolCallItem[];
|
||||
}
|
||||
|
||||
export interface GroupedToolCalls<TGroup> {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
groupsByHostId: ToolCallGroupLookup<TGroup>;
|
||||
historyGroupUpdatesByHostId: ToolCallGroupLookup<TGroup>;
|
||||
}
|
||||
|
||||
export interface ToolCallGroupLookup<TGroup> {
|
||||
readonly size: number;
|
||||
get(id: string): TGroup | undefined;
|
||||
has(id: string): boolean;
|
||||
}
|
||||
|
||||
const EMPTY_GROUPS = new Map<string, never>();
|
||||
|
||||
export function describeToolCall(item: ToolCallItem): ToolCallDescriptor {
|
||||
if (item.payload.source === "agent") {
|
||||
const { data } = item.payload;
|
||||
return {
|
||||
detail: data.detail,
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
error: data.error,
|
||||
metadata: data.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
const { data } = item.payload;
|
||||
return {
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: data.arguments ?? null,
|
||||
output: data.result ?? null,
|
||||
},
|
||||
name: data.toolName,
|
||||
status: data.status,
|
||||
error: data.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function isGroupableToolCall(item: StreamItem): item is ToolCallItem {
|
||||
if (item.kind !== "tool_call") {
|
||||
return false;
|
||||
}
|
||||
const descriptor = describeToolCall(item);
|
||||
return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak";
|
||||
}
|
||||
|
||||
function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallRun {
|
||||
const first = calls[0];
|
||||
const latest = calls.at(-1);
|
||||
if (!first || !latest) {
|
||||
throw new Error("Cannot group an empty tool call run");
|
||||
}
|
||||
return { id: first.id, calls, latest, isSealed };
|
||||
}
|
||||
|
||||
function createHost(run: ToolCallRun): ToolCallItem {
|
||||
if (run.calls.length === 1) {
|
||||
return run.latest;
|
||||
}
|
||||
return { ...run.latest, id: run.id };
|
||||
}
|
||||
|
||||
function isRunning(call: ToolCallItem): boolean {
|
||||
const status = describeToolCall(call).status;
|
||||
return status === "running" || status === "executing";
|
||||
}
|
||||
|
||||
function appendRun<TGroup>(input: {
|
||||
calls: readonly ToolCallItem[];
|
||||
isSealed: boolean;
|
||||
output: StreamItem[];
|
||||
groups: Map<string, TGroup>;
|
||||
buildGroup: (run: ToolCallRun) => TGroup;
|
||||
}): void {
|
||||
if (input.calls.length === 0) {
|
||||
return;
|
||||
}
|
||||
const run = createRun(input.calls, input.isSealed);
|
||||
const host = createHost(run);
|
||||
input.output.push(host);
|
||||
input.groups.set(host.id, input.buildGroup(run));
|
||||
}
|
||||
|
||||
export function prepareGroupedHistory<TGroup>(input: {
|
||||
tail: StreamItem[];
|
||||
buildGroup: (run: ToolCallRun) => TGroup;
|
||||
}): GroupedHistory<TGroup> {
|
||||
const output: StreamItem[] = [];
|
||||
const groups = new Map<string, TGroup>();
|
||||
let pending: ToolCallItem[] = [];
|
||||
|
||||
for (const item of input.tail) {
|
||||
if (isGroupableToolCall(item)) {
|
||||
pending.push(item);
|
||||
continue;
|
||||
}
|
||||
appendRun({
|
||||
calls: pending,
|
||||
isSealed: true,
|
||||
output,
|
||||
groups,
|
||||
buildGroup: input.buildGroup,
|
||||
});
|
||||
pending = [];
|
||||
output.push(item);
|
||||
}
|
||||
|
||||
appendRun({
|
||||
calls: pending,
|
||||
isSealed: true,
|
||||
output,
|
||||
groups,
|
||||
buildGroup: input.buildGroup,
|
||||
});
|
||||
|
||||
return {
|
||||
tail: groups.size > 0 ? output : input.tail,
|
||||
groupsByHostId: groups,
|
||||
pendingCalls: pending,
|
||||
};
|
||||
}
|
||||
|
||||
export function groupLiveToolCalls<TGroup>(input: {
|
||||
history: GroupedHistory<TGroup>;
|
||||
head: StreamItem[];
|
||||
isTurnActive: boolean;
|
||||
buildGroup: (run: ToolCallRun) => TGroup;
|
||||
}): GroupedToolCalls<TGroup> {
|
||||
const head: StreamItem[] = [];
|
||||
const liveGroups = new Map<string, TGroup>();
|
||||
let pending = [...input.history.pendingCalls];
|
||||
let hostPlacement: "history" | "head" | null = pending.length > 0 ? "history" : null;
|
||||
let pendingIncludesHead = false;
|
||||
|
||||
const flush = (isSealed: boolean) => {
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
const run = createRun(pending, isSealed);
|
||||
if (hostPlacement === "head") {
|
||||
head.push(createHost(run));
|
||||
}
|
||||
if (hostPlacement === "head" || pendingIncludesHead || !isSealed) {
|
||||
liveGroups.set(run.id, input.buildGroup(run));
|
||||
}
|
||||
pending = [];
|
||||
hostPlacement = null;
|
||||
pendingIncludesHead = false;
|
||||
};
|
||||
|
||||
for (const item of input.head) {
|
||||
if (isGroupableToolCall(item)) {
|
||||
if (pending.length === 0) {
|
||||
hostPlacement = "head";
|
||||
}
|
||||
pending.push(item);
|
||||
pendingIncludesHead = true;
|
||||
continue;
|
||||
}
|
||||
flush(true);
|
||||
head.push(item);
|
||||
}
|
||||
// Tool calls live in retained tail rather than the streaming head. The agent
|
||||
// lifecycle snapshot can still be idle while a newly received tool call is
|
||||
// already running, so its direct timeline status is the authoritative start
|
||||
// signal. The lifecycle state continues to keep completed calls live between
|
||||
// sequential tool updates.
|
||||
const trailingRunIsActive = input.isTurnActive || pending.some(isRunning);
|
||||
flush(!trailingRunIsActive);
|
||||
|
||||
if (liveGroups.size === 0) {
|
||||
return {
|
||||
tail: input.history.tail,
|
||||
head: input.head,
|
||||
groupsByHostId: input.history.groupsByHostId,
|
||||
historyGroupUpdatesByHostId: EMPTY_GROUPS,
|
||||
};
|
||||
}
|
||||
if (input.history.groupsByHostId.size === 0) {
|
||||
return {
|
||||
tail: input.history.tail,
|
||||
head,
|
||||
groupsByHostId: liveGroups,
|
||||
historyGroupUpdatesByHostId: EMPTY_GROUPS,
|
||||
};
|
||||
}
|
||||
const groupsByHostId = new Map(input.history.groupsByHostId);
|
||||
let historyGroupUpdatesByHostId: Map<string, TGroup> | null = null;
|
||||
for (const [id, group] of liveGroups) {
|
||||
groupsByHostId.set(id, group);
|
||||
if (input.history.groupsByHostId.has(id)) {
|
||||
historyGroupUpdatesByHostId ??= new Map();
|
||||
historyGroupUpdatesByHostId.set(id, group);
|
||||
}
|
||||
}
|
||||
return {
|
||||
tail: input.history.tail,
|
||||
head,
|
||||
groupsByHostId,
|
||||
historyGroupUpdatesByHostId: historyGroupUpdatesByHostId ?? EMPTY_GROUPS,
|
||||
};
|
||||
}
|
||||
92
packages/app/src/tool-calls/detail-level/overview/model.ts
Normal file
92
packages/app/src/tool-calls/detail-level/overview/model.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
|
||||
import { describeToolCall, type ToolCallRun } from "../grouping";
|
||||
|
||||
const DIRECT_PASEO_TOOL_PREFIX = "paseo_";
|
||||
const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/;
|
||||
|
||||
export interface OverviewSummary {
|
||||
editedFileCount: number;
|
||||
commandCount: number;
|
||||
readFileCount: number;
|
||||
searchCount: number;
|
||||
otherToolCount: number;
|
||||
paseoCallCount: number;
|
||||
}
|
||||
|
||||
export interface OverviewToolCallGroup {
|
||||
mode: "overview";
|
||||
run: ToolCallRun;
|
||||
summary: OverviewSummary;
|
||||
failedCount: number;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export type OverviewHeader = { kind: "latest"; call: ToolCallRun["latest"] } | { kind: "summary" };
|
||||
|
||||
export function resolveOverviewHeader(
|
||||
group: OverviewToolCallGroup,
|
||||
expanded: boolean,
|
||||
): OverviewHeader {
|
||||
if (group.run.calls.length === 1) {
|
||||
return { kind: "latest", call: group.run.latest };
|
||||
}
|
||||
if (expanded || group.run.isSealed) {
|
||||
return { kind: "summary" };
|
||||
}
|
||||
return { kind: "latest", call: group.run.latest };
|
||||
}
|
||||
|
||||
function isPaseoCall(name: string, normalizedName: string): boolean {
|
||||
return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX);
|
||||
}
|
||||
|
||||
function isSearchCall(name: string): boolean {
|
||||
return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name);
|
||||
}
|
||||
|
||||
export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
|
||||
const editedFiles = new Set<string>();
|
||||
const readFiles = new Set<string>();
|
||||
let failedCount = 0;
|
||||
let isLoading = false;
|
||||
let commandCount = 0;
|
||||
let searchCount = 0;
|
||||
let otherToolCount = 0;
|
||||
let paseoCallCount = 0;
|
||||
|
||||
for (const call of run.calls) {
|
||||
const descriptor = describeToolCall(call);
|
||||
const normalizedName = descriptor.name.trim().toLowerCase();
|
||||
failedCount += descriptor.status === "failed" ? 1 : 0;
|
||||
isLoading ||= descriptor.status === "running" || descriptor.status === "executing";
|
||||
if (isPaseoCall(descriptor.name, normalizedName)) {
|
||||
paseoCallCount += 1;
|
||||
} else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") {
|
||||
editedFiles.add(descriptor.detail.filePath);
|
||||
} else if (descriptor.detail.type === "shell") {
|
||||
commandCount += 1;
|
||||
} else if (descriptor.detail.type === "read") {
|
||||
readFiles.add(descriptor.detail.filePath);
|
||||
} else if (descriptor.detail.type === "search" || isSearchCall(normalizedName)) {
|
||||
searchCount += 1;
|
||||
} else {
|
||||
otherToolCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
editedFileCount: editedFiles.size,
|
||||
commandCount,
|
||||
readFileCount: readFiles.size,
|
||||
searchCount,
|
||||
otherToolCount,
|
||||
paseoCallCount,
|
||||
};
|
||||
return {
|
||||
mode: "overview",
|
||||
run,
|
||||
failedCount,
|
||||
isLoading,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
173
packages/app/src/tool-calls/detail-level/overview/view.tsx
Normal file
173
packages/app/src/tool-calls/detail-level/overview/view.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { memo, useCallback, useMemo, useRef, type ReactNode } from "react";
|
||||
import { ScrollView } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wrench } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { useToolCallSheet } from "@/components/tool-call-sheet";
|
||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { describeToolCall } from "../grouping";
|
||||
import { resolveOverviewHeader, type OverviewSummary, type OverviewToolCallGroup } from "./model";
|
||||
|
||||
interface OverviewGroupProps {
|
||||
group: OverviewToolCallGroup;
|
||||
expanded: boolean;
|
||||
isCompact: boolean;
|
||||
isLastInSequence: boolean;
|
||||
onExpandedChange: (groupId: string, expanded: boolean) => void;
|
||||
cwd?: string;
|
||||
onOpenFilePath?: (filePath: string) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const TOOL_CALL_GROUP_MAX_HEIGHT = 400;
|
||||
|
||||
function joinSummaryParts(parts: string[], conjunction: string): string {
|
||||
if (parts.length === 0) {
|
||||
return "";
|
||||
}
|
||||
let joined = parts[0] ?? "";
|
||||
if (parts.length === 2) {
|
||||
joined = `${parts[0]} ${conjunction} ${parts[1]}`;
|
||||
} else if (parts.length > 2) {
|
||||
joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`;
|
||||
}
|
||||
const firstCharacter = joined[0];
|
||||
return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined;
|
||||
}
|
||||
|
||||
function useOverviewSummary(summary: OverviewSummary): string {
|
||||
const { t } = useTranslation();
|
||||
return useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
const entries = [
|
||||
[summary.editedFileCount, "toolCallGroup.editedFiles"],
|
||||
[summary.commandCount, "toolCallGroup.commands"],
|
||||
[summary.readFileCount, "toolCallGroup.readFiles"],
|
||||
[summary.searchCount, "toolCallGroup.searches"],
|
||||
[summary.otherToolCount, "toolCallGroup.otherTools"],
|
||||
[summary.paseoCallCount, "toolCallGroup.paseoCalls"],
|
||||
] as const;
|
||||
for (const [count, key] of entries) {
|
||||
if (count > 0) {
|
||||
parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count }));
|
||||
}
|
||||
}
|
||||
return joinSummaryParts(parts, t("toolCallGroup.and"));
|
||||
}, [summary, t]);
|
||||
}
|
||||
|
||||
export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({
|
||||
group,
|
||||
expanded,
|
||||
isCompact,
|
||||
isLastInSequence,
|
||||
onExpandedChange,
|
||||
cwd,
|
||||
onOpenFilePath,
|
||||
children,
|
||||
}: OverviewGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const aggregateSummary = useOverviewSummary(group.summary);
|
||||
const header = resolveOverviewHeader(group, expanded);
|
||||
const latestCall = header.kind === "latest" ? header.call : group.run.latest;
|
||||
const latest = useMemo(() => {
|
||||
const descriptor = describeToolCall(latestCall);
|
||||
return {
|
||||
detail: descriptor.detail,
|
||||
presentation: buildToolCallPresentation({
|
||||
toolName: descriptor.name,
|
||||
status: descriptor.status,
|
||||
error: descriptor.error,
|
||||
detail: descriptor.detail,
|
||||
metadata: descriptor.metadata,
|
||||
cwd,
|
||||
resolveIcon: resolveToolCallIcon,
|
||||
}),
|
||||
};
|
||||
}, [cwd, latestCall]);
|
||||
const showsLatest = header.kind === "latest";
|
||||
const opensSingleCallSheet = isCompact && group.run.calls.length === 1;
|
||||
const openLatestFile = useMemo(() => {
|
||||
const path = latest.presentation.openFilePath;
|
||||
if (!showsLatest || !path || !onOpenFilePath) {
|
||||
return undefined;
|
||||
}
|
||||
return () => onOpenFilePath(path);
|
||||
}, [showsLatest, latest.presentation.openFilePath, onOpenFilePath]);
|
||||
const failedSummary =
|
||||
group.failedCount > 0 ? t("toolCallGroup.failed", { count: group.failedCount }) : undefined;
|
||||
const scrollToLatest = useCallback(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
}, []);
|
||||
const toggle = useCallback(() => {
|
||||
if (opensSingleCallSheet) {
|
||||
openToolCall({
|
||||
displayName: latest.presentation.displayName,
|
||||
summary: latest.presentation.summary,
|
||||
detail: latest.detail,
|
||||
errorText: latest.presentation.errorText,
|
||||
icon: latest.presentation.icon,
|
||||
showLoadingSkeleton: latest.presentation.isLoadingDetails,
|
||||
});
|
||||
return;
|
||||
}
|
||||
onExpandedChange(group.run.id, !expanded);
|
||||
}, [expanded, group.run.id, latest, onExpandedChange, openToolCall, opensSingleCallSheet]);
|
||||
const renderDetails = useCallback(() => {
|
||||
if (group.run.calls.length === 1) {
|
||||
return (
|
||||
<ToolCallDetailsContent
|
||||
detail={latest.detail}
|
||||
errorText={latest.presentation.errorText}
|
||||
maxHeight={TOOL_CALL_GROUP_MAX_HEIGHT}
|
||||
showLoadingSkeleton={latest.presentation.isLoadingDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.content}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
onContentSizeChange={scrollToLatest}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}, [children, group.run.calls.length, latest, scrollToLatest]);
|
||||
const canExpand = group.run.calls.length > 1 || latest.presentation.canOpenDetails;
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-group"
|
||||
label={showsLatest ? latest.presentation.displayName : aggregateSummary}
|
||||
secondaryLabel={showsLatest ? latest.presentation.summary : failedSummary}
|
||||
icon={showsLatest ? latest.presentation.icon : Wrench}
|
||||
isLoading={group.isLoading}
|
||||
isError={group.failedCount > 0}
|
||||
isExpanded={opensSingleCallSheet ? false : expanded}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onToggle={canExpand ? toggle : undefined}
|
||||
onOpenFile={openLatestFile}
|
||||
renderDetails={canExpand && !opensSingleCallSheet ? renderDetails : undefined}
|
||||
borderlessWhenExpanded
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
scroll: {
|
||||
maxHeight: TOOL_CALL_GROUP_MAX_HEIGHT,
|
||||
},
|
||||
content: {
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingHorizontal: 13,
|
||||
},
|
||||
}));
|
||||
501
packages/app/src/tool-calls/detail-level/projection.test.ts
Normal file
501
packages/app/src/tool-calls/detail-level/projection.test.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import type { StreamItem, ToolCallItem } from "@/types/stream";
|
||||
import {
|
||||
prepareToolCallHistory,
|
||||
projectToolCallDetailLevel,
|
||||
type PreparedToolCallHistory,
|
||||
type ToolCallDetailLevel,
|
||||
} from "./projection";
|
||||
import { resolveOverviewHeader } from "./overview/model";
|
||||
|
||||
type AssistantMessageItem = Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
|
||||
function toolCall(
|
||||
id: string,
|
||||
detail: ToolCallDetail,
|
||||
options: {
|
||||
name?: string;
|
||||
status?: "running" | "completed" | "failed" | "canceled";
|
||||
} = {},
|
||||
): ToolCallItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`),
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: id,
|
||||
name: options.name ?? detail.type,
|
||||
status: options.status ?? "completed",
|
||||
error: options.status === "failed" ? "boom" : null,
|
||||
detail,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistant(id: string): AssistantMessageItem {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: new Date("2026-01-01T00:01:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function project(input: {
|
||||
level: ToolCallDetailLevel;
|
||||
tail?: StreamItem[];
|
||||
head?: StreamItem[];
|
||||
isTurnActive?: boolean;
|
||||
preparedHistory?: PreparedToolCallHistory | null;
|
||||
}) {
|
||||
const tail = input.tail ?? [];
|
||||
return projectToolCallDetailLevel({
|
||||
level: input.level,
|
||||
tail,
|
||||
head: input.head ?? [],
|
||||
preparedHistory: input.preparedHistory ?? prepareToolCallHistory(input.level, tail),
|
||||
isTurnActive: input.isTurnActive ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("tool call detail-level projection", () => {
|
||||
it("passes detailed timelines through without grouping work", () => {
|
||||
const tail = [toolCall("1", { type: "shell", command: "one" })];
|
||||
const head = [toolCall("2", { type: "shell", command: "two" })];
|
||||
|
||||
const prepared = prepareToolCallHistory("detailed", tail);
|
||||
const result = project({ level: "detailed", tail, head, preparedHistory: prepared });
|
||||
|
||||
expect(prepared).toBeNull();
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.head).toBe(head);
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps one stable overview host as a run grows", () => {
|
||||
const firstCall = toolCall("1", { type: "shell", command: "one" });
|
||||
const secondCall = toolCall("2", { type: "read", filePath: "/repo/a.ts" });
|
||||
const prepared = prepareToolCallHistory("overview", []);
|
||||
|
||||
const single = project({
|
||||
level: "overview",
|
||||
head: [firstCall],
|
||||
isTurnActive: true,
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
expect(single.head).toEqual([firstCall]);
|
||||
expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({
|
||||
calls: [firstCall],
|
||||
latest: firstCall,
|
||||
isSealed: false,
|
||||
});
|
||||
|
||||
const grouped = project({
|
||||
level: "overview",
|
||||
head: [firstCall, secondCall],
|
||||
isTurnActive: true,
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
expect(grouped.head).toEqual([
|
||||
expect.objectContaining({ id: firstCall.id, timestamp: secondCall.timestamp }),
|
||||
]);
|
||||
expect(grouped.groupsByHostId.get(firstCall.id)?.run).toMatchObject({
|
||||
calls: [firstCall, secondCall],
|
||||
latest: secondCall,
|
||||
isSealed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the latest call while collapsed and the summary while expanded", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "shell", command: "one" }),
|
||||
toolCall("2", { type: "read", filePath: "/repo/a.ts" }, { status: "running" }),
|
||||
];
|
||||
const result = project({
|
||||
level: "overview",
|
||||
head: calls,
|
||||
isTurnActive: true,
|
||||
});
|
||||
const group = result.groupsByHostId.get("1");
|
||||
if (!group) {
|
||||
throw new Error("Expected an overview group");
|
||||
}
|
||||
|
||||
expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call: calls[1] });
|
||||
expect(resolveOverviewHeader(group, true)).toEqual({ kind: "summary" });
|
||||
});
|
||||
|
||||
it("keeps a parallel group loading while any call is still running", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "shell", command: "slow" }, { status: "running" }),
|
||||
toolCall("2", { type: "shell", command: "done" }),
|
||||
];
|
||||
const result = project({ level: "overview", head: calls, isTurnActive: true });
|
||||
|
||||
expect(result.groupsByHostId.get("1")?.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a one-call run presented as its tool call", () => {
|
||||
const call = toolCall("1", { type: "shell", command: "one" });
|
||||
const result = project({ level: "overview", head: [call] });
|
||||
const group = result.groupsByHostId.get(call.id);
|
||||
if (!group) {
|
||||
throw new Error("Expected an overview group");
|
||||
}
|
||||
|
||||
expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call });
|
||||
expect(resolveOverviewHeader(group, true)).toEqual({ kind: "latest", call });
|
||||
});
|
||||
|
||||
it("keeps an active overview group on its latest call until a visible boundary arrives", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "shell", command: "one" }),
|
||||
toolCall("2", { type: "read", filePath: "/repo/a.ts" }),
|
||||
toolCall("3", { type: "read", filePath: "/repo/b.ts" }),
|
||||
toolCall("4", { type: "edit", filePath: "/repo/a.ts" }),
|
||||
];
|
||||
const prepared = prepareToolCallHistory("overview", []);
|
||||
const active = project({
|
||||
level: "overview",
|
||||
head: calls,
|
||||
isTurnActive: true,
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
const activeGroup = active.groupsByHostId.get("1");
|
||||
|
||||
expect(activeGroup).toMatchObject({
|
||||
mode: "overview",
|
||||
run: { id: "1", latest: calls[3], isSealed: false },
|
||||
});
|
||||
expect(resolveOverviewHeader(activeGroup!, false)).toEqual({
|
||||
kind: "latest",
|
||||
call: calls[3],
|
||||
});
|
||||
|
||||
const boundary = assistant("answer");
|
||||
const sealed = project({
|
||||
level: "overview",
|
||||
head: [...calls, boundary],
|
||||
isTurnActive: true,
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
expect(sealed.groupsByHostId.get("1")).toMatchObject({
|
||||
mode: "overview",
|
||||
run: { latest: calls[3], isSealed: true },
|
||||
summary: { editedFileCount: 1, readFileCount: 2, commandCount: 1 },
|
||||
});
|
||||
expect(resolveOverviewHeader(sealed.groupsByHostId.get("1")!, false)).toEqual({
|
||||
kind: "summary",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a running overview header live before the agent lifecycle catches up", () => {
|
||||
const calls = ["1", "2", "3", "4"].map((id) =>
|
||||
toolCall(id, { type: "shell", command: id }, { status: "running" }),
|
||||
);
|
||||
|
||||
const result = project({
|
||||
level: "overview",
|
||||
tail: calls,
|
||||
isTurnActive: false,
|
||||
});
|
||||
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
run: { latest: calls[3], isSealed: false },
|
||||
});
|
||||
expect(resolveOverviewHeader(result.groupsByHostId.get("1")!, false)).toEqual({
|
||||
kind: "latest",
|
||||
call: calls[3],
|
||||
});
|
||||
});
|
||||
|
||||
it("seals the trailing overview group only when the turn ends", () => {
|
||||
const calls = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id }));
|
||||
const prepared = prepareToolCallHistory("overview", []);
|
||||
|
||||
const betweenCalls = project({
|
||||
level: "overview",
|
||||
head: calls,
|
||||
isTurnActive: true,
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
const nextCall = toolCall("5", { type: "read", filePath: "/repo/a.ts" });
|
||||
const continued = project({
|
||||
level: "overview",
|
||||
head: [...calls, nextCall],
|
||||
isTurnActive: true,
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
const ended = project({
|
||||
level: "overview",
|
||||
head: [...calls, nextCall],
|
||||
isTurnActive: false,
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
|
||||
expect(betweenCalls.groupsByHostId.get("1")?.run.isSealed).toBe(false);
|
||||
expect(resolveOverviewHeader(betweenCalls.groupsByHostId.get("1")!, false)).toEqual({
|
||||
kind: "latest",
|
||||
call: calls[3],
|
||||
});
|
||||
expect(continued.groupsByHostId.get("1")?.run).toMatchObject({
|
||||
latest: nextCall,
|
||||
isSealed: false,
|
||||
});
|
||||
expect(resolveOverviewHeader(continued.groupsByHostId.get("1")!, false)).toEqual({
|
||||
kind: "latest",
|
||||
call: nextCall,
|
||||
});
|
||||
expect(ended.groupsByHostId.get("1")?.run.isSealed).toBe(true);
|
||||
expect(resolveOverviewHeader(ended.groupsByHostId.get("1")!, false)).toEqual({
|
||||
kind: "summary",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds overview summaries without category-specific presentation data", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }),
|
||||
toolCall("2", { type: "read", filePath: "/repo/src/b.ts" }),
|
||||
toolCall("3", { type: "shell", command: "npm test" }),
|
||||
toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }, { status: "failed" }),
|
||||
];
|
||||
|
||||
const overview = project({ level: "overview", head: calls });
|
||||
|
||||
expect(overview.groupsByHostId.get("1")).toEqual({
|
||||
mode: "overview",
|
||||
run: expect.any(Object),
|
||||
failedCount: 1,
|
||||
isLoading: false,
|
||||
summary: {
|
||||
editedFileCount: 1,
|
||||
commandCount: 1,
|
||||
readFileCount: 2,
|
||||
searchCount: 0,
|
||||
otherToolCount: 0,
|
||||
paseoCallCount: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes reads, searches, and other tools in overview", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }),
|
||||
toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }),
|
||||
toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }),
|
||||
toolCall(
|
||||
"4",
|
||||
{ type: "search", query: "paseo", toolName: "web_search" },
|
||||
{ status: "failed" },
|
||||
),
|
||||
toolCall("5", { type: "fetch", url: "not a url" }),
|
||||
];
|
||||
|
||||
const result = project({ level: "overview", head: calls });
|
||||
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
failedCount: 1,
|
||||
summary: {
|
||||
editedFileCount: 0,
|
||||
commandCount: 0,
|
||||
readFileCount: 2,
|
||||
searchCount: 1,
|
||||
otherToolCount: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("counts unique edited files and every shell command in overview", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "edit", filePath: "/repo/a.ts" }),
|
||||
toolCall("2", { type: "edit", filePath: "/repo/a.ts" }),
|
||||
toolCall("3", { type: "write", filePath: "/repo/b.ts" }),
|
||||
toolCall("4", { type: "shell", command: "npm test" }),
|
||||
toolCall("5", { type: "shell", command: "npm run lint" }),
|
||||
toolCall("6", { type: "read", filePath: "/repo/c.ts" }),
|
||||
];
|
||||
|
||||
const result = project({ level: "overview", head: calls });
|
||||
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
summary: {
|
||||
editedFileCount: 2,
|
||||
commandCount: 2,
|
||||
readFileCount: 1,
|
||||
otherToolCount: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("counts Paseo calls separately from other tools", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }),
|
||||
toolCall(
|
||||
"2",
|
||||
{ type: "unknown", input: null, output: null },
|
||||
{ name: "mcp__paseo__list_worktrees" },
|
||||
),
|
||||
toolCall("3", { type: "fetch", url: "https://paseo.sh" }),
|
||||
toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }),
|
||||
];
|
||||
|
||||
const result = project({ level: "overview", head: calls });
|
||||
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
summary: { otherToolCount: 2, paseoCallCount: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies direct Brave search and Paseo runtime tool names", () => {
|
||||
const unknownDetail = { type: "unknown" as const, input: null, output: null };
|
||||
const calls = [
|
||||
toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }),
|
||||
toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }),
|
||||
toolCall("3", unknownDetail, { name: "paseo_list_providers" }),
|
||||
toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }),
|
||||
toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }),
|
||||
toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }),
|
||||
];
|
||||
|
||||
const result = project({ level: "overview", head: calls });
|
||||
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses prepared history and sealed group models across live-head updates", () => {
|
||||
const historicalCalls = ["1", "2", "3", "4"].map((id) =>
|
||||
toolCall(id, { type: "shell", command: id }),
|
||||
);
|
||||
const tail = [...historicalCalls, assistant("boundary")];
|
||||
const prepared = prepareToolCallHistory("overview", tail);
|
||||
if (!prepared) {
|
||||
throw new Error("Overview history must be prepared");
|
||||
}
|
||||
expect(prepared.grouped.tail).toEqual([
|
||||
expect.objectContaining({ id: "1", timestamp: historicalCalls[3]?.timestamp }),
|
||||
tail[4],
|
||||
]);
|
||||
const first = project({
|
||||
level: "overview",
|
||||
tail,
|
||||
head: [toolCall("5", { type: "read", filePath: "/repo/a.ts" })],
|
||||
isTurnActive: true,
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
const second = project({
|
||||
level: "overview",
|
||||
tail,
|
||||
head: [
|
||||
toolCall("5", { type: "read", filePath: "/repo/a.ts" }),
|
||||
toolCall("6", { type: "read", filePath: "/repo/b.ts" }),
|
||||
],
|
||||
isTurnActive: true,
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
|
||||
expect(first.tail).toBe(prepared.grouped.tail);
|
||||
expect(second.tail).toBe(prepared.grouped.tail);
|
||||
expect(first.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1"));
|
||||
expect(second.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1"));
|
||||
expect(first.historyGroupUpdatesByHostId.size).toBe(0);
|
||||
expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId);
|
||||
expect(second.groupsByHostId.get("5")?.run.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves projected history identity during assistant-only head updates", () => {
|
||||
const trailingCalls = [
|
||||
toolCall("1", { type: "shell", command: "one" }),
|
||||
toolCall("2", { type: "read", filePath: "/repo/a.ts" }),
|
||||
];
|
||||
const tail = [assistant("before"), ...trailingCalls];
|
||||
const prepared = prepareToolCallHistory("overview", tail);
|
||||
if (!prepared) {
|
||||
throw new Error("Overview history must be prepared");
|
||||
}
|
||||
|
||||
const firstHead = [assistant("answer")];
|
||||
const secondHead = [{ ...firstHead[0], text: "answer grows" }];
|
||||
const first = project({
|
||||
level: "overview",
|
||||
tail,
|
||||
head: firstHead,
|
||||
isTurnActive: true,
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
const second = project({
|
||||
level: "overview",
|
||||
tail,
|
||||
head: secondHead,
|
||||
isTurnActive: true,
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
|
||||
expect(first.tail).toBe(prepared.grouped.tail);
|
||||
expect(second.tail).toBe(prepared.grouped.tail);
|
||||
expect(first.groupsByHostId).toBe(prepared.grouped.groupsByHostId);
|
||||
expect(second.groupsByHostId).toBe(prepared.grouped.groupsByHostId);
|
||||
expect(first.historyGroupUpdatesByHostId.size).toBe(0);
|
||||
expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId);
|
||||
});
|
||||
|
||||
it("forms one group across the retained-history and live-head boundary", () => {
|
||||
const tail = [
|
||||
assistant("before"),
|
||||
toolCall("1", { type: "shell", command: "one" }),
|
||||
toolCall("2", { type: "shell", command: "two" }),
|
||||
];
|
||||
const head = [
|
||||
toolCall("3", { type: "read", filePath: "/repo/a.ts" }),
|
||||
toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }),
|
||||
];
|
||||
|
||||
const result = project({ level: "overview", tail, head, isTurnActive: true });
|
||||
|
||||
expect(result.tail).toEqual([
|
||||
tail[0],
|
||||
expect.objectContaining({ id: "1", timestamp: tail[2]?.timestamp }),
|
||||
]);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.groupsByHostId.get("1")?.run).toMatchObject({
|
||||
calls: [...tail.slice(1), ...head],
|
||||
latest: head[1],
|
||||
isSealed: false,
|
||||
});
|
||||
expect(result.historyGroupUpdatesByHostId.get("1")).toBe(result.groupsByHostId.get("1"));
|
||||
});
|
||||
|
||||
it("keeps a trailing history-only group in the retained segment", () => {
|
||||
const tail = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id }));
|
||||
|
||||
const result = project({ level: "overview", tail, isTurnActive: false });
|
||||
|
||||
expect(result.tail).toEqual([
|
||||
expect.objectContaining({ id: "1", timestamp: tail[3]?.timestamp }),
|
||||
]);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.groupsByHostId.get("1")?.run.isSealed).toBe(true);
|
||||
});
|
||||
|
||||
it("hosts single calls while leaving plans and spoken messages ungrouped", () => {
|
||||
const singleCall = toolCall("1", { type: "shell", command: "one" });
|
||||
const plan = toolCall("2", { type: "plan", text: "Plan" });
|
||||
const speak = toolCall(
|
||||
"3",
|
||||
{ type: "unknown", input: "Hello", output: null },
|
||||
{ name: "speak" },
|
||||
);
|
||||
|
||||
const result = project({ level: "overview", head: [singleCall, plan, speak] });
|
||||
|
||||
expect(result.head).toEqual([singleCall, plan, speak]);
|
||||
expect(result.groupsByHostId.get(singleCall.id)?.run.calls).toEqual([singleCall]);
|
||||
expect(result.groupsByHostId.size).toBe(1);
|
||||
});
|
||||
});
|
||||
60
packages/app/src/tool-calls/detail-level/projection.ts
Normal file
60
packages/app/src/tool-calls/detail-level/projection.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
|
||||
import {
|
||||
groupLiveToolCalls,
|
||||
prepareGroupedHistory,
|
||||
type GroupedHistory,
|
||||
type GroupedToolCalls,
|
||||
} from "./grouping";
|
||||
import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model";
|
||||
|
||||
export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
|
||||
export type ToolCallDetailGroup = OverviewToolCallGroup;
|
||||
|
||||
export interface PreparedToolCallHistory {
|
||||
mode: "overview";
|
||||
grouped: GroupedHistory<ToolCallDetailGroup>;
|
||||
}
|
||||
|
||||
export interface ToolCallDetailProjection extends GroupedToolCalls<ToolCallDetailGroup> {}
|
||||
|
||||
const EMPTY_TOOL_CALL_GROUPS = new Map<string, ToolCallDetailGroup>();
|
||||
|
||||
export function prepareToolCallHistory(
|
||||
level: ToolCallDetailLevel,
|
||||
tail: StreamItem[],
|
||||
): PreparedToolCallHistory | null {
|
||||
if (level === "detailed") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mode: "overview",
|
||||
grouped: prepareGroupedHistory({ tail, buildGroup: buildOverviewGroup }),
|
||||
};
|
||||
}
|
||||
|
||||
export function projectToolCallDetailLevel(input: {
|
||||
level: ToolCallDetailLevel;
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
preparedHistory: PreparedToolCallHistory | null;
|
||||
isTurnActive: boolean;
|
||||
}): ToolCallDetailProjection {
|
||||
if (input.level === "detailed") {
|
||||
return {
|
||||
tail: input.tail,
|
||||
head: input.head,
|
||||
groupsByHostId: EMPTY_TOOL_CALL_GROUPS,
|
||||
historyGroupUpdatesByHostId: EMPTY_TOOL_CALL_GROUPS,
|
||||
};
|
||||
}
|
||||
if (!input.preparedHistory || input.preparedHistory.mode !== input.level) {
|
||||
throw new Error(`Missing prepared ${input.level} tool call history`);
|
||||
}
|
||||
return groupLiveToolCalls({
|
||||
history: input.preparedHistory.grouped,
|
||||
head: input.head,
|
||||
isTurnActive: input.isTurnActive,
|
||||
buildGroup: buildOverviewGroup,
|
||||
});
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import type { StreamItem, ToolCallItem } from "@/types/stream";
|
||||
import { compactToolCallRuns } from "./grouping";
|
||||
|
||||
function toolCall(
|
||||
id: string,
|
||||
detail: ToolCallDetail,
|
||||
options: {
|
||||
name?: string;
|
||||
status?: "running" | "completed" | "failed" | "canceled";
|
||||
} = {},
|
||||
): ToolCallItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`),
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: id,
|
||||
name: options.name ?? detail.type,
|
||||
status: options.status ?? "completed",
|
||||
error: options.status === "failed" ? "boom" : null,
|
||||
detail,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistant(id: string): StreamItem {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: new Date("2026-01-01T00:01:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("compactToolCallRuns", () => {
|
||||
it("preserves the original arrays when compaction is disabled", () => {
|
||||
const tail = [toolCall("1", { type: "shell", command: "one" })];
|
||||
const head = [toolCall("2", { type: "shell", command: "two" })];
|
||||
|
||||
const result = compactToolCallRuns({ tail, head, enabled: false });
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.head).toBe(head);
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("replaces four contiguous calls with a stable first-call host and latest timestamp", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "shell", command: "one" }),
|
||||
toolCall("2", { type: "shell", command: "two" }),
|
||||
toolCall("3", { type: "read", filePath: "/repo/src/a.ts" }),
|
||||
toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }),
|
||||
];
|
||||
|
||||
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
|
||||
|
||||
expect(result.tail).toHaveLength(1);
|
||||
expect(result.tail[0]).toMatchObject({ id: "1", timestamp: calls[3]?.timestamp });
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.groupsByHostId.get("1")?.id).toBe("1");
|
||||
expect(result.groupsByHostId.get("1")?.calls).toEqual(calls);
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
editedFileCount: 1,
|
||||
commandCount: 2,
|
||||
readFileCount: 1,
|
||||
otherToolCount: 0,
|
||||
categories: [
|
||||
{ key: "shell", label: "Shell", callCount: 2, resources: [] },
|
||||
{ key: "read", label: "Read", callCount: 1, resources: ["a.ts"] },
|
||||
{ key: "edit", label: "Edit", callCount: 1, resources: ["a.ts"] },
|
||||
],
|
||||
});
|
||||
|
||||
const nextCall = toolCall("5", { type: "shell", command: "five" }, { status: "running" });
|
||||
const updated = compactToolCallRuns({
|
||||
tail: [...calls, nextCall],
|
||||
head: [],
|
||||
enabled: true,
|
||||
});
|
||||
expect(updated.tail[0]).toMatchObject({ id: "1", timestamp: nextCall.timestamp });
|
||||
expect(updated.groupsByHostId.get("1")?.id).toBe("1");
|
||||
});
|
||||
|
||||
it("does not compact short runs and stops at visible content boundaries", () => {
|
||||
const firstRun = [
|
||||
toolCall("1", { type: "shell", command: "one" }),
|
||||
toolCall("2", { type: "shell", command: "two" }),
|
||||
toolCall("3", { type: "shell", command: "three" }),
|
||||
];
|
||||
const boundary = assistant("assistant");
|
||||
const secondRun = [
|
||||
toolCall("4", { type: "read", filePath: "/repo/a.ts" }),
|
||||
toolCall("5", { type: "read", filePath: "/repo/b.ts" }),
|
||||
toolCall("6", { type: "read", filePath: "/repo/c.ts" }),
|
||||
toolCall("7", { type: "read", filePath: "/repo/d.ts" }),
|
||||
];
|
||||
|
||||
const result = compactToolCallRuns({
|
||||
tail: [...firstRun, boundary, ...secondRun],
|
||||
head: [],
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(result.tail.slice(0, -1)).toEqual([...firstRun, boundary]);
|
||||
expect(result.tail.at(-1)).toMatchObject({ id: "4", timestamp: secondRun[3]?.timestamp });
|
||||
expect([...result.groupsByHostId.keys()]).toEqual(["4"]);
|
||||
});
|
||||
|
||||
it("forms one group across the history and live-head boundary", () => {
|
||||
const tail = [
|
||||
assistant("assistant"),
|
||||
toolCall("1", { type: "shell", command: "one" }),
|
||||
toolCall("2", { type: "shell", command: "two" }),
|
||||
];
|
||||
const head = [
|
||||
toolCall("3", { type: "read", filePath: "/repo/a.ts" }),
|
||||
toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }),
|
||||
];
|
||||
|
||||
const result = compactToolCallRuns({ tail, head, enabled: true });
|
||||
|
||||
expect(result.tail).toEqual([tail[0]]);
|
||||
expect(result.head).toHaveLength(1);
|
||||
expect(result.head[0]).toMatchObject({ id: "1", timestamp: head[1]?.timestamp });
|
||||
expect(result.groupsByHostId.get("1")?.calls).toEqual([...tail.slice(1), ...head]);
|
||||
expect(result.groupsByHostId.get("1")?.isRunning).toBe(true);
|
||||
});
|
||||
|
||||
it("separates reads and searches from other tools", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }),
|
||||
toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }),
|
||||
toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }),
|
||||
toolCall(
|
||||
"4",
|
||||
{ type: "search", query: "paseo", toolName: "web_search" },
|
||||
{ status: "failed" },
|
||||
),
|
||||
toolCall("5", { type: "fetch", url: "not a url" }),
|
||||
];
|
||||
|
||||
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
|
||||
const group = result.groupsByHostId.get("1");
|
||||
|
||||
expect(group).toMatchObject({
|
||||
failedCount: 1,
|
||||
isRunning: false,
|
||||
editedFileCount: 0,
|
||||
commandCount: 0,
|
||||
readFileCount: 2,
|
||||
searchCount: 1,
|
||||
otherToolCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts unique edited files while counting each shell command", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "edit", filePath: "/repo/a.ts" }),
|
||||
toolCall("2", { type: "edit", filePath: "/repo/a.ts" }),
|
||||
toolCall("3", { type: "write", filePath: "/repo/b.ts" }),
|
||||
toolCall("4", { type: "shell", command: "npm test" }),
|
||||
toolCall("5", { type: "shell", command: "npm run lint" }),
|
||||
toolCall("6", { type: "read", filePath: "/repo/c.ts" }),
|
||||
];
|
||||
|
||||
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
|
||||
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
editedFileCount: 2,
|
||||
commandCount: 2,
|
||||
readFileCount: 1,
|
||||
otherToolCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts Paseo calls separately from other tools", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }),
|
||||
toolCall(
|
||||
"2",
|
||||
{ type: "unknown", input: null, output: null },
|
||||
{ name: "mcp__paseo__list_worktrees" },
|
||||
),
|
||||
toolCall("3", { type: "fetch", url: "https://paseo.sh" }),
|
||||
toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }),
|
||||
];
|
||||
|
||||
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
|
||||
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
otherToolCount: 2,
|
||||
paseoCallCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies direct Brave and Paseo runtime tool names", () => {
|
||||
const unknownDetail = { type: "unknown" as const, input: null, output: null };
|
||||
const calls = [
|
||||
toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }),
|
||||
toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }),
|
||||
toolCall("3", unknownDetail, { name: "paseo_list_providers" }),
|
||||
toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }),
|
||||
toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }),
|
||||
toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }),
|
||||
];
|
||||
|
||||
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
|
||||
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
searchCount: 3,
|
||||
otherToolCount: 0,
|
||||
paseoCallCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps plan and spoken-message calls outside compact groups", () => {
|
||||
const shellCalls = ["1", "2", "3", "4"].map((id) =>
|
||||
toolCall(id, { type: "shell", command: id }),
|
||||
);
|
||||
const plan = toolCall("5", { type: "plan", text: "Plan" });
|
||||
const speak = toolCall(
|
||||
"6",
|
||||
{ type: "unknown", input: "Hello", output: null },
|
||||
{ name: "speak" },
|
||||
);
|
||||
|
||||
const result = compactToolCallRuns({
|
||||
tail: [...shellCalls, plan, speak],
|
||||
head: [],
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(result.tail[0]).toMatchObject({ id: "1", timestamp: shellCalls[3]?.timestamp });
|
||||
expect(result.tail.slice(1)).toEqual([plan, speak]);
|
||||
expect(result.groupsByHostId.get("1")?.calls).toEqual(shellCalls);
|
||||
});
|
||||
|
||||
it("deduplicates file resources by full path while displaying basenames", () => {
|
||||
const calls = [
|
||||
toolCall("1", { type: "read", filePath: "/repo/src/index.ts" }),
|
||||
toolCall("2", { type: "read", filePath: "/repo/tests/index.ts" }),
|
||||
toolCall("3", { type: "read", filePath: "/repo/src/other.ts" }),
|
||||
toolCall("4", { type: "read", filePath: "/repo/src/index.ts" }),
|
||||
];
|
||||
|
||||
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
|
||||
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
readFileCount: 3,
|
||||
categories: [
|
||||
{
|
||||
key: "read",
|
||||
resources: ["index.ts", "index.ts", "other.ts"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,325 +0,0 @@
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
|
||||
import { getFileNameFromPath } from "@/attachments/utils";
|
||||
import type { StreamItem, ToolCallItem } from "@/types/stream";
|
||||
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIconName, type ToolCallIcon } from "@/utils/tool-call-icon-name";
|
||||
|
||||
export const MIN_COMPACT_TOOL_CALLS = 4;
|
||||
|
||||
const DIRECT_PASEO_TOOL_PREFIX = "paseo_";
|
||||
const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/;
|
||||
|
||||
export interface ToolCallCategorySummary {
|
||||
key: string;
|
||||
label: string;
|
||||
iconName: ToolCallIcon;
|
||||
callCount: number;
|
||||
failedCount: number;
|
||||
runningCount: number;
|
||||
resources: string[];
|
||||
}
|
||||
|
||||
export interface CompactToolCallGroup {
|
||||
id: string;
|
||||
calls: ToolCallItem[];
|
||||
callCount: number;
|
||||
failedCount: number;
|
||||
isRunning: boolean;
|
||||
editedFileCount: number;
|
||||
commandCount: number;
|
||||
readFileCount: number;
|
||||
searchCount: number;
|
||||
otherToolCount: number;
|
||||
paseoCallCount: number;
|
||||
categories: ToolCallCategorySummary[];
|
||||
}
|
||||
|
||||
export interface CompactToolCallRunsResult {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
groupsByHostId: Map<string, CompactToolCallGroup>;
|
||||
}
|
||||
|
||||
interface CompactToolCallRunsInput {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface TaggedStreamItem {
|
||||
segment: "tail" | "head";
|
||||
item: StreamItem;
|
||||
}
|
||||
|
||||
interface ToolCallDescriptor {
|
||||
detail: ToolCallDetail;
|
||||
name: string;
|
||||
status: "running" | "completed" | "failed" | "canceled";
|
||||
error: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ResourceSummary {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function describeToolCall(item: ToolCallItem): ToolCallDescriptor {
|
||||
if (item.payload.source === "agent") {
|
||||
const { data } = item.payload;
|
||||
return {
|
||||
detail: data.detail,
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
error: data.error,
|
||||
metadata: data.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
const { data } = item.payload;
|
||||
return {
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: data.arguments ?? null,
|
||||
output: data.result ?? null,
|
||||
},
|
||||
name: data.toolName,
|
||||
status: data.status === "executing" ? "running" : data.status,
|
||||
error: data.error,
|
||||
};
|
||||
}
|
||||
|
||||
function isCompactableToolCall(item: StreamItem): item is ToolCallItem {
|
||||
if (item.kind !== "tool_call") {
|
||||
return false;
|
||||
}
|
||||
const descriptor = describeToolCall(item);
|
||||
return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak";
|
||||
}
|
||||
|
||||
function isDirectPaseoToolName(name: string): boolean {
|
||||
return name.startsWith(DIRECT_PASEO_TOOL_PREFIX);
|
||||
}
|
||||
|
||||
function isDirectSearchToolName(name: string): boolean {
|
||||
return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name);
|
||||
}
|
||||
|
||||
function resourceForDetail(detail: ToolCallDetail): ResourceSummary | null {
|
||||
if (detail.type === "read" || detail.type === "edit" || detail.type === "write") {
|
||||
return {
|
||||
key: detail.filePath,
|
||||
label: getFileNameFromPath(detail.filePath) ?? detail.filePath,
|
||||
};
|
||||
}
|
||||
if (detail.type !== "fetch") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const hostname = new URL(detail.url).hostname || detail.url;
|
||||
return { key: hostname, label: hostname };
|
||||
} catch {
|
||||
return { key: detail.url, label: detail.url };
|
||||
}
|
||||
}
|
||||
|
||||
function categoryIdentity(input: {
|
||||
descriptor: ToolCallDescriptor;
|
||||
normalizedName: string;
|
||||
displayName: string;
|
||||
}): { key: string; label: string; iconName: ToolCallIcon } {
|
||||
if (isPaseoToolName(input.descriptor.name) || isDirectPaseoToolName(input.normalizedName)) {
|
||||
return { key: "paseo", label: "Paseo", iconName: "paseo" };
|
||||
}
|
||||
if (
|
||||
(input.descriptor.detail.type === "search" &&
|
||||
input.descriptor.detail.toolName === "web_search") ||
|
||||
isDirectSearchToolName(input.normalizedName)
|
||||
) {
|
||||
return { key: "web_search", label: "Web search", iconName: "search" };
|
||||
}
|
||||
if (input.descriptor.detail.type === "fetch") {
|
||||
return { key: "fetch", label: "Web fetch", iconName: "search" };
|
||||
}
|
||||
if (input.descriptor.detail.type !== "unknown" && input.descriptor.detail.type !== "plain_text") {
|
||||
return {
|
||||
key: input.descriptor.detail.type,
|
||||
label: input.displayName,
|
||||
iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail),
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: `tool:${input.displayName.toLowerCase()}`,
|
||||
label: input.displayName,
|
||||
iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCompactToolCallGroup(calls: ToolCallItem[]) {
|
||||
const editedFiles = new Set<string>();
|
||||
const readFiles = new Set<string>();
|
||||
const categories = new Map<string, ToolCallCategorySummary>();
|
||||
const categoryResourceKeys = new Map<string, Set<string>>();
|
||||
let failedCount = 0;
|
||||
let isRunning = false;
|
||||
let commandCount = 0;
|
||||
let searchCount = 0;
|
||||
let otherToolCount = 0;
|
||||
let paseoCallCount = 0;
|
||||
|
||||
for (const call of calls) {
|
||||
const descriptor = describeToolCall(call);
|
||||
const isFailed = descriptor.status === "failed";
|
||||
const isCallRunning = descriptor.status === "running";
|
||||
failedCount += isFailed ? 1 : 0;
|
||||
isRunning ||= isCallRunning;
|
||||
const normalizedName = descriptor.name.trim().toLowerCase();
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: descriptor.name,
|
||||
status: descriptor.status,
|
||||
error: descriptor.error,
|
||||
detail: descriptor.detail,
|
||||
metadata: descriptor.metadata,
|
||||
});
|
||||
const identity = categoryIdentity({
|
||||
descriptor,
|
||||
normalizedName,
|
||||
displayName: display.displayName,
|
||||
});
|
||||
let category = categories.get(identity.key);
|
||||
if (!category) {
|
||||
category = {
|
||||
...identity,
|
||||
callCount: 0,
|
||||
failedCount: 0,
|
||||
runningCount: 0,
|
||||
resources: [],
|
||||
};
|
||||
categories.set(identity.key, category);
|
||||
}
|
||||
category.callCount += 1;
|
||||
category.failedCount += isFailed ? 1 : 0;
|
||||
category.runningCount += isCallRunning ? 1 : 0;
|
||||
const resource = resourceForDetail(descriptor.detail);
|
||||
if (resource) {
|
||||
let resourceKeys = categoryResourceKeys.get(identity.key);
|
||||
if (!resourceKeys) {
|
||||
resourceKeys = new Set();
|
||||
categoryResourceKeys.set(identity.key, resourceKeys);
|
||||
}
|
||||
if (!resourceKeys.has(resource.key)) {
|
||||
resourceKeys.add(resource.key);
|
||||
category.resources.push(resource.label);
|
||||
}
|
||||
}
|
||||
|
||||
if (isPaseoToolName(descriptor.name) || isDirectPaseoToolName(normalizedName)) {
|
||||
paseoCallCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") {
|
||||
editedFiles.add(descriptor.detail.filePath);
|
||||
continue;
|
||||
}
|
||||
if (descriptor.detail.type === "shell") {
|
||||
commandCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (descriptor.detail.type === "read") {
|
||||
readFiles.add(descriptor.detail.filePath);
|
||||
continue;
|
||||
}
|
||||
if (descriptor.detail.type === "search" || isDirectSearchToolName(normalizedName)) {
|
||||
searchCount += 1;
|
||||
continue;
|
||||
}
|
||||
otherToolCount += 1;
|
||||
}
|
||||
|
||||
const firstCall = calls[0];
|
||||
if (!firstCall) {
|
||||
throw new Error("Cannot build an empty tool call group");
|
||||
}
|
||||
return {
|
||||
id: firstCall.id,
|
||||
calls,
|
||||
callCount: calls.length,
|
||||
failedCount,
|
||||
isRunning,
|
||||
editedFileCount: editedFiles.size,
|
||||
commandCount,
|
||||
readFileCount: readFiles.size,
|
||||
searchCount,
|
||||
otherToolCount,
|
||||
paseoCallCount,
|
||||
categories: [...categories.values()],
|
||||
} satisfies CompactToolCallGroup;
|
||||
}
|
||||
|
||||
export function compactToolCallRuns(input: CompactToolCallRunsInput): CompactToolCallRunsResult {
|
||||
if (!input.enabled) {
|
||||
return {
|
||||
tail: input.tail,
|
||||
head: input.head,
|
||||
groupsByHostId: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const taggedItems: TaggedStreamItem[] = [
|
||||
...input.tail.map((item) => ({ segment: "tail" as const, item })),
|
||||
...input.head.map((item) => ({ segment: "head" as const, item })),
|
||||
];
|
||||
const compactedTail: StreamItem[] = [];
|
||||
const compactedHead: StreamItem[] = [];
|
||||
const groupsByHostId = new Map<string, CompactToolCallGroup>();
|
||||
let pendingRun: TaggedStreamItem[] = [];
|
||||
|
||||
const append = ({ segment, item }: TaggedStreamItem) => {
|
||||
(segment === "tail" ? compactedTail : compactedHead).push(item);
|
||||
};
|
||||
const flushRun = () => {
|
||||
if (pendingRun.length >= MIN_COMPACT_TOOL_CALLS) {
|
||||
const first = pendingRun[0];
|
||||
const latest = pendingRun.at(-1);
|
||||
if (!first || !latest) {
|
||||
throw new Error("Cannot compact an empty tool call run");
|
||||
}
|
||||
const calls = pendingRun.map(({ item }) => item as ToolCallItem);
|
||||
const stableHost: TaggedStreamItem = {
|
||||
segment: latest.segment,
|
||||
item: { ...latest.item, id: first.item.id },
|
||||
};
|
||||
append(stableHost);
|
||||
groupsByHostId.set(stableHost.item.id, buildCompactToolCallGroup(calls));
|
||||
} else {
|
||||
for (const entry of pendingRun) {
|
||||
append(entry);
|
||||
}
|
||||
}
|
||||
pendingRun = [];
|
||||
};
|
||||
|
||||
for (const entry of taggedItems) {
|
||||
if (isCompactableToolCall(entry.item)) {
|
||||
pendingRun.push(entry);
|
||||
continue;
|
||||
}
|
||||
flushRun();
|
||||
append(entry);
|
||||
}
|
||||
flushRun();
|
||||
|
||||
if (groupsByHostId.size === 0) {
|
||||
return {
|
||||
tail: input.tail,
|
||||
head: input.head,
|
||||
groupsByHostId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail: compactedTail,
|
||||
head: compactedHead,
|
||||
groupsByHostId,
|
||||
};
|
||||
}
|
||||
10
packages/app/src/types/react-native-flat-list.d.ts
vendored
Normal file
10
packages/app/src/types/react-native-flat-list.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
// React Native 0.81 implements FlatList's renderer memoization flag and ships
|
||||
// it in generated types, but omits it from the legacy declarations exposed by
|
||||
// the package entry point.
|
||||
import "react-native";
|
||||
|
||||
declare module "react-native" {
|
||||
interface FlatListProps<ItemT> {
|
||||
strictMode?: boolean;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export function toXtermTheme(terminal: TerminalPalette): ITheme {
|
||||
cursorAccent: terminal.cursorAccent,
|
||||
selectionBackground: terminal.selectionBackground,
|
||||
selectionForeground: terminal.selectionForeground,
|
||||
|
||||
black: terminal.black,
|
||||
red: terminal.red,
|
||||
green: terminal.green,
|
||||
|
||||
Reference in New Issue
Block a user