feat(app): add tool call detail levels (#2031)

* feat(app): compact tool call sequences

* feat(app): summarize compact tool activity

* feat(app): add tool call detail levels

* fix(app): stabilize compact tool groups

* fix(app): address compact tool review feedback
This commit is contained in:
Matt Cowger
2026-07-14 18:55:06 +08:00
committed by GitHub
parent 3b078240a8
commit 81113d1582
16 changed files with 1488 additions and 13 deletions
+7 -2
View File
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { queryClient as appQueryClient } from "@/data/query-client";
@@ -26,6 +26,7 @@ import {
MIN_UI_FONT_SIZE,
loadAppSettingsFromStorage as loadAppSettingsFromStoragePure,
loadSettingsFromStorage as loadSettingsFromStoragePure,
normalizeAppSettings,
parseClampedFontSize,
parseTerminalScrollbackLines,
sanitizeFontFamily,
@@ -129,9 +130,10 @@ export function useAppSettings(): UseAppSettingsReturn {
throw err;
}
}, [queryClient]);
const settings = useMemo(() => normalizeAppSettings(data), [data]);
return {
settings: data ?? DEFAULT_CLIENT_SETTINGS,
settings,
isLoading: isPending,
error: error ?? null,
updateSettings,
@@ -186,6 +188,9 @@ export function useSettings<TSelected>(
if (updates.autoExpandReasoning !== undefined) {
appUpdates.autoExpandReasoning = updates.autoExpandReasoning;
}
if (updates.toolCallDetailLevel !== undefined) {
appUpdates.toolCallDetailLevel = updates.toolCallDetailLevel;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import {
APP_SETTINGS_KEY,
APP_SETTINGS_QUERY_KEY,
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
@@ -282,6 +283,27 @@ describe("saveAppSettings", () => {
}),
);
});
it("normalizes a legacy cached settings shape before saving", async () => {
const deps = makeDeps();
const queryClient = new QueryClient();
queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, {
theme: "dark",
compactToolCalls: true,
});
await saveAppSettings({
queryClient,
updates: { theme: "light" },
deps,
});
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({
...DEFAULT_CLIENT_SETTINGS,
theme: "light",
toolCallDetailLevel: "overview",
});
});
});
describe("parseTerminalScrollbackLines", () => {
@@ -306,6 +328,27 @@ describe("appearance settings", () => {
expect(result.uiFontSize).toBe(DEFAULT_UI_FONT_SIZE);
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
expect(result.syntaxTheme).toBe("one");
expect(result.toolCallDetailLevel).toBe("detailed");
});
it("migrates the enabled compact tool call preference to overview", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
});
it("loads an explicit tool call detail level", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "concise" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("concise");
});
it("clamps the UI font size into range and rejects non-numeric values", async () => {
+40 -4
View File
@@ -12,10 +12,16 @@ 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";
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",
]);
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
@@ -40,6 +46,7 @@ export interface AppSettings {
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
autoExpandReasoning: boolean;
toolCallDetailLevel: ToolCallDetailLevel;
}
export interface Settings extends AppSettings {
@@ -47,6 +54,8 @@ export interface Settings extends AppSettings {
releaseChannel: ReleaseChannel;
}
type StoredAppSettings = Partial<AppSettings> & { compactToolCalls?: unknown };
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: "auto",
language: "system",
@@ -60,6 +69,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
syntaxTheme: "one",
workspaceTitleSource: "title",
autoExpandReasoning: false,
toolCallDetailLevel: "detailed",
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -92,9 +102,10 @@ export async function saveAppSettings(input: {
updates: Partial<AppSettings>;
deps: SettingsDeps;
}): Promise<void> {
const current =
const storedCurrent =
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
(await loadAppSettingsFromStorage(input.deps));
const current = normalizeAppSettings(storedCurrent);
const next = { ...current, ...input.updates };
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await input.deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
@@ -104,8 +115,7 @@ export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<Ap
try {
const stored = await deps.storage.getItem(APP_SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<AppSettings>;
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) };
return normalizeAppSettings(JSON.parse(stored));
}
const legacyStored = await deps.storage.getItem(LEGACY_SETTINGS_KEY);
@@ -153,7 +163,29 @@ export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Setti
};
}
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
export function normalizeAppSettings(value: unknown): AppSettings {
const stored =
typeof value === "object" && value !== null && !Array.isArray(value)
? (value as StoredAppSettings)
: {};
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(stored) };
}
function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null {
if (
typeof stored.toolCallDetailLevel === "string" &&
VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel)
) {
return stored.toolCallDetailLevel;
}
if (typeof stored.compactToolCalls === "boolean") {
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
return stored.compactToolCalls ? "overview" : "detailed";
}
return null;
}
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
result.theme = stored.theme;
@@ -209,6 +241,10 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
if (typeof stored.autoExpandReasoning === "boolean") {
result.autoExpandReasoning = stored.autoExpandReasoning;
}
const toolCallDetailLevel = parseToolCallDetailLevel(stored);
if (toolCallDetailLevel !== null) {
result.toolCallDetailLevel = toolCallDetailLevel;
}
return result;
}