Switch to double quotes and reformat codebase with Biome

This commit is contained in:
Mohamed Boudra
2026-03-21 20:22:15 +07:00
parent ab40786882
commit f6579dbeb4
805 changed files with 38132 additions and 40175 deletions
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
import {
compareCheckoutDiffPaths,
orderCheckoutDiffFiles,
} from "./checkout-diff-order";
import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./checkout-diff-order";
function createFile(path: string, additions = 0) {
return {
@@ -29,11 +26,7 @@ describe("checkout diff ordering", () => {
createFile("beta.ts"),
]);
expect(ordered.map((file) => file.path)).toEqual([
"alpha.ts",
"beta.ts",
"zeta.ts",
]);
expect(ordered.map((file) => file.path)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"]);
});
it("preserves relative order for equal paths", () => {
@@ -9,9 +9,7 @@ export function compareCheckoutDiffPaths(left: string, right: string): number {
return left < right ? -1 : 1;
}
export function orderCheckoutDiffFiles(
files: ParsedDiffFile[]
): ParsedDiffFile[] {
export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[] {
if (files.length < 2) {
return files;
}
@@ -5,7 +5,9 @@ export type CheckoutStatusRevalidationParams = {
explorerTab: string;
};
export function checkoutStatusRevalidationKey(params: CheckoutStatusRevalidationParams): string | null {
export function checkoutStatusRevalidationKey(
params: CheckoutStatusRevalidationParams,
): string | null {
if (!params.cwd) return null;
if (!params.isOpen) return null;
if (params.explorerTab !== "changes") return null;
@@ -14,10 +16,9 @@ export function checkoutStatusRevalidationKey(params: CheckoutStatusRevalidation
export function nextCheckoutStatusRefetchDecision(
prevKey: string | null,
nextKey: string | null
nextKey: string | null,
): { nextSeenKey: string | null; shouldRefetch: boolean } {
if (!nextKey) return { nextSeenKey: null, shouldRefetch: false };
if (prevKey === nextKey) return { nextSeenKey: prevKey, shouldRefetch: false };
return { nextSeenKey: nextKey, shouldRefetch: true };
}
@@ -82,7 +82,7 @@ describe("image-attachment-picker", () => {
multiple: true,
directory: false,
title: "Attach images",
})
}),
);
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
});
@@ -91,7 +91,7 @@ describe("image-attachment-picker", () => {
desktopHostState.api = {};
await expect(openImagePathsWithDesktopDialog()).rejects.toThrow(
"Desktop dialog API is not available."
"Desktop dialog API is not available.",
);
});
});
@@ -1,8 +1,6 @@
import { getDesktopHost } from "@/desktop/host";
export type PickedImageSource =
| { kind: "file_uri"; uri: string }
| { kind: "blob"; blob: Blob };
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
export interface PickedImageAttachmentInput {
source: PickedImageSource;
@@ -48,7 +46,7 @@ async function blobFromUri(uri: string): Promise<Blob> {
}
export async function normalizePickedImageAssets(
assets: readonly ExpoImagePickerAssetLike[]
assets: readonly ExpoImagePickerAssetLike[],
): Promise<PickedImageAttachmentInput[]> {
return await Promise.all(
assets.map(async (asset) => {
@@ -73,7 +71,7 @@ export async function normalizePickedImageAssets(
mimeType: asset.mimeType ?? null,
fileName: asset.fileName ?? null,
};
})
}),
);
}
@@ -32,9 +32,7 @@ export function useAgentAttentionClear({
attentionReason,
isScreenFocused,
}: UseAgentAttentionClearParams): AgentAttentionClearController {
const [isAppVisible, setIsAppVisible] = useState<boolean>(() =>
getIsAppActivelyVisible()
);
const [isAppVisible, setIsAppVisible] = useState<boolean>(() => getIsAppActivelyVisible());
const deferredFocusEntryClearRef = useRef(false);
const prevRequiresAttentionRef = useRef(Boolean(requiresAttention));
const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppActivelyVisible());
@@ -62,7 +60,7 @@ export function useAgentAttentionClear({
deferredFocusEntryClearRef.current = false;
client.clearAgentAttention(resolvedAgentId);
},
[agentId, attentionReason, client, isConnected, requiresAttention]
[agentId, attentionReason, client, isConnected, requiresAttention],
);
useEffect(() => {
@@ -70,10 +68,7 @@ export function useAgentAttentionClear({
setIsAppVisible(getIsAppActivelyVisible());
};
const appStateSubscription = AppState.addEventListener(
"change",
updateVisibility
);
const appStateSubscription = AppState.addEventListener("change", updateVisibility);
if (Platform.OS === "web" && typeof document !== "undefined") {
document.addEventListener("visibilitychange", updateVisibility);
@@ -114,10 +109,8 @@ export function useAgentAttentionClear({
}, [isAppVisible, isScreenFocused, requiresAttention]);
useEffect(() => {
const enteredScreenFocus =
!prevScreenFocusedRef.current && isScreenFocused && isAppVisible;
const resumedIntoFocusedAgent =
!prevAppVisibleRef.current && isAppVisible && isScreenFocused;
const enteredScreenFocus = !prevScreenFocusedRef.current && isScreenFocused && isAppVisible;
const resumedIntoFocusedAgent = !prevAppVisibleRef.current && isAppVisible && isScreenFocused;
if (enteredScreenFocus || resumedIntoFocusedAgent) {
clearAttention("focus-entry");
+121 -121
View File
@@ -1,97 +1,97 @@
import { useCallback, useMemo } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import type { AutocompleteOption } from '@/components/ui/autocomplete'
import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-commands-query'
import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils'
import { useAutocomplete } from './use-autocomplete'
import { useSessionStore } from '@/stores/session-store'
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
import { useCallback, useMemo } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { AutocompleteOption } from "@/components/ui/autocomplete";
import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query";
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
import { useAutocomplete } from "./use-autocomplete";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
applyFileMentionReplacement,
findActiveFileMention,
type FileMentionRange,
} from '@/utils/file-mention-autocomplete'
} from "@/utils/file-mention-autocomplete";
interface UseAgentAutocompleteInput {
userInput: string
cursorIndex: number
setUserInput: (nextValue: string) => void
serverId: string
agentId: string
draftConfig?: DraftCommandConfig
onAutocompleteApplied?: () => void
userInput: string;
cursorIndex: number;
setUserInput: (nextValue: string) => void;
serverId: string;
agentId: string;
draftConfig?: DraftCommandConfig;
onAutocompleteApplied?: () => void;
}
type AgentAutocompleteOption =
| (AutocompleteOption & { type: 'command' })
| (AutocompleteOption & { type: "command" })
| (AutocompleteOption & {
type: 'workspace_entry'
entryPath: string
mention: FileMentionRange
})
type: "workspace_entry";
entryPath: string;
mention: FileMentionRange;
});
interface AgentAutocompleteResult {
isVisible: boolean
options: AutocompleteOption[]
selectedIndex: number
isLoading: boolean
errorMessage?: string
loadingText: string
emptyText: string
onSelectOption: (option: AutocompleteOption) => void
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean
isVisible: boolean;
options: AutocompleteOption[];
selectedIndex: number;
isLoading: boolean;
errorMessage?: string;
loadingText: string;
emptyText: string;
onSelectOption: (option: AutocompleteOption) => void;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
}
interface DirectorySuggestionEntry {
path: string
kind: 'file' | 'directory'
path: string;
kind: "file" | "directory";
}
function normalizeDraftCommandConfig(
draftConfig?: DraftCommandConfig
draftConfig?: DraftCommandConfig,
): DraftCommandConfig | undefined {
if (!draftConfig) {
return undefined
return undefined;
}
const cwd = draftConfig.cwd.trim()
const cwd = draftConfig.cwd.trim();
if (!cwd) {
return undefined
return undefined;
}
const modeId = draftConfig.modeId?.trim() ?? ''
const model = draftConfig.model?.trim() ?? ''
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ''
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
return {
provider: draftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
}
};
}
function mapDirectorySuggestionsToEntries(payload: {
entries?: Array<{ path: string; kind: string }>
directories?: string[]
entries?: Array<{ path: string; kind: string }>;
directories?: string[];
}): DirectorySuggestionEntry[] {
if (Array.isArray(payload.entries) && payload.entries.length > 0) {
return payload.entries.flatMap((entry) => {
if (
!entry ||
typeof entry.path !== 'string' ||
(entry.kind !== 'file' && entry.kind !== 'directory')
typeof entry.path !== "string" ||
(entry.kind !== "file" && entry.kind !== "directory")
) {
return []
return [];
}
return [{ path: entry.path, kind: entry.kind }]
})
return [{ path: entry.path, kind: entry.kind }];
});
}
return (payload.directories ?? []).map((path) => ({
path,
kind: 'directory' as const,
}))
kind: "directory" as const,
}));
}
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
@@ -103,10 +103,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
agentId,
draftConfig,
onAutocompleteApplied,
} = input
} = input;
const showCommandAutocomplete = userInput.startsWith('/') && !userInput.includes(' ')
const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : ''
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : "";
const activeFileMention = useMemo(
() =>
@@ -114,44 +114,44 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
text: userInput,
cursorIndex,
}),
[cursorIndex, userInput]
)
const showFileAutocomplete = activeFileMention !== null
const fileFilterQuery = activeFileMention?.query ?? ''
[cursorIndex, userInput],
);
const showFileAutocomplete = activeFileMention !== null;
const fileFilterQuery = activeFileMention?.query ?? "";
const normalizedDraftConfig = useMemo(
() => normalizeDraftCommandConfig(draftConfig),
[draftConfig]
)
[draftConfig],
);
const isDraftContext = normalizedDraftConfig !== undefined
const queryDraftConfig = isDraftContext ? normalizedDraftConfig : undefined
const canLoadCommands = Boolean(serverId) && (Boolean(agentId) || isDraftContext)
const isDraftContext = normalizedDraftConfig !== undefined;
const queryDraftConfig = isDraftContext ? normalizedDraftConfig : undefined;
const canLoadCommands = Boolean(serverId) && (Boolean(agentId) || isDraftContext);
const agentCwd = useSessionStore(
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? ''
)
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "",
);
const autocompleteCwd = useMemo(() => {
if (isDraftContext) {
return queryDraftConfig?.cwd ?? ''
return queryDraftConfig?.cwd ?? "";
}
return agentCwd.trim()
}, [agentCwd, isDraftContext, queryDraftConfig])
return agentCwd.trim();
}, [agentCwd, isDraftContext, queryDraftConfig]);
const client = useHostRuntimeClient(serverId)
const isConnected = useHostRuntimeIsConnected(serverId)
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const mode: 'command' | 'file' | null = showFileAutocomplete
? 'file'
const mode: "command" | "file" | null = showFileAutocomplete
? "file"
: showCommandAutocomplete
? 'command'
: null
? "command"
: null;
const isVisible =
mode === 'command'
mode === "command"
? canLoadCommands
: mode === 'file'
: mode === "file"
? Boolean(serverId) && autocompleteCwd.length > 0
: false
: false;
const {
commands,
@@ -161,15 +161,15 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
} = useAgentCommandsQuery({
serverId,
agentId,
enabled: mode === 'command' && canLoadCommands,
enabled: mode === "command" && canLoadCommands,
draftConfig: queryDraftConfig,
})
});
const fileSuggestionsQuery = useQuery({
queryKey: ['directorySuggestions', serverId, autocompleteCwd, fileFilterQuery, true, true],
queryKey: ["directorySuggestions", serverId, autocompleteCwd, fileFilterQuery, true, true],
queryFn: async (): Promise<DirectorySuggestionEntry[]> => {
if (!client) {
throw new Error('Daemon client unavailable')
throw new Error("Daemon client unavailable");
}
const response = await client.getDirectorySuggestions({
cwd: autocompleteCwd,
@@ -177,14 +177,14 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
limit: 50,
includeFiles: true,
includeDirectories: true,
})
});
if (response.error) {
throw new Error(response.error)
throw new Error(response.error);
}
return mapDirectorySuggestionsToEntries(response)
return mapDirectorySuggestionsToEntries(response);
},
enabled:
mode === 'file' &&
mode === "file" &&
Boolean(serverId) &&
autocompleteCwd.length > 0 &&
Boolean(client) &&
@@ -192,89 +192,89 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
retry: false,
staleTime: 15_000,
placeholderData: keepPreviousData,
})
});
const options = useMemo<AgentAutocompleteOption[]>(() => {
if (!isVisible) {
return []
return [];
}
if (mode === 'command') {
const filterLower = commandFilterQuery.toLowerCase()
const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower))
const orderedMatches = orderAutocompleteOptions(matches)
if (mode === "command") {
const filterLower = commandFilterQuery.toLowerCase();
const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower));
const orderedMatches = orderAutocompleteOptions(matches);
return orderedMatches.map((cmd) => ({
type: 'command' as const,
type: "command" as const,
id: cmd.name,
label: `/${cmd.name}`,
detail: cmd.argumentHint || undefined,
description: cmd.description,
kind: 'command',
}))
kind: "command",
}));
}
if (mode === 'file' && activeFileMention) {
const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? [])
if (mode === "file" && activeFileMention) {
const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? []);
return orderedEntries.map((entry) => ({
type: 'workspace_entry' as const,
type: "workspace_entry" as const,
id: `${entry.kind}:${entry.path}`,
label: entry.path,
kind: entry.kind,
entryPath: entry.path,
mention: activeFileMention,
}))
}));
}
return []
}, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode])
return [];
}, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode]);
const onSelectOption = useCallback(
(option: AutocompleteOption) => {
const selected = option as AgentAutocompleteOption
if (selected.type === 'command') {
setUserInput(`/${selected.id} `)
onAutocompleteApplied?.()
return
const selected = option as AgentAutocompleteOption;
if (selected.type === "command") {
setUserInput(`/${selected.id} `);
onAutocompleteApplied?.();
return;
}
const nextInput = applyFileMentionReplacement({
text: userInput,
mention: selected.mention,
relativePath: selected.entryPath,
})
setUserInput(nextInput)
onAutocompleteApplied?.()
});
setUserInput(nextInput);
onAutocompleteApplied?.();
},
[onAutocompleteApplied, setUserInput, userInput]
)
[onAutocompleteApplied, setUserInput, userInput],
);
const { selectedIndex, onKeyPress } = useAutocomplete({
isVisible,
options,
query: mode === 'command' ? commandFilterQuery : fileFilterQuery,
query: mode === "command" ? commandFilterQuery : fileFilterQuery,
onSelectOption,
onEscape: mode === 'command' ? () => setUserInput('') : undefined,
})
onEscape: mode === "command" ? () => setUserInput("") : undefined,
});
const isLoading =
mode === 'command'
mode === "command"
? isCommandsLoading
: mode === 'file'
: mode === "file"
? fileSuggestionsQuery.isPending || (fileSuggestionsQuery.isLoading && options.length === 0)
: false
: false;
const errorMessage =
mode === 'command'
mode === "command"
? isError
? (error?.message ?? 'Failed to load')
? (error?.message ?? "Failed to load")
: undefined
: mode === 'file'
: mode === "file"
? fileSuggestionsQuery.error instanceof Error
? fileSuggestionsQuery.error.message
: undefined
: undefined
: undefined;
const loadingText = mode === 'file' ? 'Searching workspace...' : 'Loading commands...'
const emptyText = mode === 'file' ? 'No files or directories found' : 'No commands found'
const loadingText = mode === "file" ? "Searching workspace..." : "Loading commands...";
const emptyText = mode === "file" ? "No files or directories found" : "No commands found";
return {
isVisible,
@@ -286,5 +286,5 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
emptyText,
onSelectOption,
onKeyPress,
}
};
}
@@ -18,11 +18,7 @@ export interface DraftCommandConfig {
thinkingOptionId?: string;
}
function commandsQueryKey(
serverId: string,
agentId: string,
draftConfig?: DraftCommandConfig
) {
function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
return [
"agentCommands",
serverId,
@@ -4,10 +4,7 @@ import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import type {
AgentModelDefinition,
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
describe("useAgentFormState", () => {
describe("__private__.combineInitialValues", () => {
@@ -80,7 +77,7 @@ describe("useAgentFormState", () => {
thinkingOptionId: "",
workingDir: "",
},
new Set<string>()
new Set<string>(),
);
expect(resolved.thinkingOptionId).toBe("xhigh");
@@ -114,7 +111,7 @@ describe("useAgentFormState", () => {
thinkingOptionId: "",
workingDir: "",
},
new Set<string>()
new Set<string>(),
);
expect(resolved.thinkingOptionId).toBe("low");
@@ -148,7 +145,7 @@ describe("useAgentFormState", () => {
thinkingOptionId: "",
workingDir: "",
},
new Set<string>()
new Set<string>(),
);
expect(resolved.thinkingOptionId).toBe("xhigh");
@@ -175,7 +172,7 @@ describe("useAgentFormState", () => {
thinkingOptionId: "",
workingDir: "",
},
new Set<string>()
new Set<string>(),
);
expect(resolved.model).toBe("");
@@ -209,7 +206,7 @@ describe("useAgentFormState", () => {
thinkingOptionId: "",
workingDir: "",
},
new Set<string>()
new Set<string>(),
);
expect(resolved.model).toBe("");
@@ -217,9 +214,9 @@ describe("useAgentFormState", () => {
it("resolves provider only from allowed provider map", () => {
const allowedProviderMap = new Map<AgentProvider, AgentProviderDefinition>(
AGENT_PROVIDER_DEFINITIONS
.filter((definition) => definition.id === "claude")
.map((definition) => [definition.id as AgentProvider, definition])
AGENT_PROVIDER_DEFINITIONS.filter((definition) => definition.id === "claude").map(
(definition) => [definition.id as AgentProvider, definition],
),
);
const resolved = __private__.resolveFormState(
undefined,
@@ -242,7 +239,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
allowedProviderMap
allowedProviderMap,
);
expect(resolved.provider).toBe("claude");
@@ -270,7 +267,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
new Map<AgentProvider, AgentProviderDefinition>()
new Map<AgentProvider, AgentProviderDefinition>(),
);
expect(resolved.provider).toBe("codex");
+35 -53
View File
@@ -94,16 +94,13 @@ type UseAgentFormStateResult = {
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS;
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
allProviderDefinitions.map((definition) => [definition.id, definition])
allProviderDefinitions.map((definition) => [definition.id, definition]),
);
const fallbackDefinition = allProviderDefinitions[0];
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
fallbackDefinition?.defaultModeId ?? "";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "";
function normalizeSelectedModelId(
modelId: string | null | undefined
): string {
function normalizeSelectedModelId(modelId: string | null | undefined): string {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
return "";
@@ -112,7 +109,7 @@ function normalizeSelectedModelId(
}
function resolveDefaultModel(
availableModels: AgentModelDefinition[] | null
availableModels: AgentModelDefinition[] | null,
): AgentModelDefinition | null {
if (!availableModels || availableModels.length === 0) {
return null;
@@ -122,7 +119,7 @@ function resolveDefaultModel(
function resolveEffectiveModel(
availableModels: AgentModelDefinition[] | null,
modelId: string
modelId: string,
): AgentModelDefinition | null {
if (!availableModels || availableModels.length === 0) {
return null;
@@ -150,13 +147,11 @@ function resolveFormState(
userModified: UserModifiedFields,
currentState: FormState,
validServerIds: Set<string>,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap,
): FormState {
// Start with current state - we only update non-user-modified fields
const result = { ...currentState };
const fallbackProvider = allowedProviderMap.keys().next().value as
| AgentProvider
| undefined;
const fallbackProvider = allowedProviderMap.keys().next().value as AgentProvider | undefined;
// 1. Resolve provider first (other fields depend on it)
if (!userModified.provider) {
@@ -188,10 +183,7 @@ function resolveFormState(
validModeIds.includes(initialValues.modeId)
) {
result.modeId = initialValues.modeId;
} else if (
providerPrefs?.mode &&
validModeIds.includes(providerPrefs.mode)
) {
} else if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
result.modeId = providerPrefs.mode;
} else {
result.modeId = providerDef?.defaultModeId ?? validModeIds[0] ?? "";
@@ -200,8 +192,7 @@ function resolveFormState(
// 3. Resolve model (depends on provider + availableModels)
if (!userModified.model) {
const isValidModel = (m: string) =>
availableModels?.some((am) => am.id === m) ?? false;
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
const initialModel = normalizeSelectedModelId(initialValues?.model);
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
@@ -233,8 +224,7 @@ function resolveFormState(
typeof initialValues?.thinkingOptionId === "string"
? initialValues.thinkingOptionId.trim()
: "";
const preferredThinkingOptionId =
providerPrefs?.thinkingOptionId?.trim() ?? "";
const preferredThinkingOptionId = providerPrefs?.thinkingOptionId?.trim() ?? "";
if (!userModified.thinkingOptionId) {
if (initialThinkingOptionId.length > 0) {
@@ -255,9 +245,7 @@ function resolveFormState(
} else {
const thinkingIds = new Set(thinkingOptions.map((option) => option.id));
const defaultThinkingOptionId =
effectiveModel?.defaultThinkingOptionId ??
thinkingOptions[0]?.id ??
"";
effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) {
result.thinkingOptionId = defaultThinkingOptionId;
}
@@ -290,7 +278,7 @@ function resolveFormState(
function combineInitialValues(
initialValues: FormInitialValues | undefined,
initialServerId: string | null
initialServerId: string | null,
): FormInitialValues | undefined {
const hasExplicitServerId = initialValues?.serverId !== undefined;
const serverIdFromOptions = initialServerId === null ? undefined : initialServerId;
@@ -311,9 +299,7 @@ function combineInitialValues(
return initialValues;
}
export function useAgentFormState(
options: UseAgentFormStateOptions = {}
): UseAgentFormStateResult {
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
const {
initialServerId = null,
initialValues,
@@ -333,10 +319,7 @@ export function useAgentFormState(
const daemons = useHosts();
// Build a set of valid server IDs for preference validation
const validServerIds = useMemo(
() => new Set(daemons.map((d) => d.serverId)),
[daemons]
);
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
// Track which fields the user has explicitly modified
const [userModified, setUserModified] = useState<UserModifiedFields>(INITIAL_USER_MODIFIED);
@@ -373,7 +356,7 @@ export function useAgentFormState(
const availableProvidersQuery = useQuery({
queryKey: ["availableProviders", formState.serverId],
enabled: Boolean(
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
),
staleTime: 60 * 1000,
queryFn: async () => {
@@ -384,9 +367,7 @@ export function useAgentFormState(
if (payload.error) {
throw new Error(payload.error);
}
return payload.providers
.filter((entry) => entry.available)
.map((entry) => entry.provider);
return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider);
},
});
@@ -397,16 +378,16 @@ export function useAgentFormState(
}
const available = new Set(availableProviders);
return allProviderDefinitions.filter((definition) =>
available.has(definition.id as AgentProvider)
available.has(definition.id as AgentProvider),
);
}, [availableProvidersQuery.data]);
const providerDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id as AgentProvider, definition])
providerDefinitions.map((definition) => [definition.id as AgentProvider, definition]),
),
[providerDefinitions]
[providerDefinitions],
);
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
@@ -425,7 +406,7 @@ export function useAgentFormState(
formState.serverId &&
client &&
isConnected &&
providerDefinitionMap.has(formState.provider)
providerDefinitionMap.has(formState.provider),
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
@@ -448,11 +429,7 @@ export function useAgentFormState(
queries: providerDefinitions.map((def) => ({
queryKey: ["providerModels", formState.serverId, def.id, debouncedCwd],
enabled: Boolean(
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
@@ -506,7 +483,7 @@ export function useAgentFormState(
userModified,
formStateRef.current,
validServerIds,
providerDefinitionMap
providerDefinitionMap,
);
// Only update if something changed
@@ -586,7 +563,7 @@ export function useAgentFormState(
setUserModified((prev) => ({ ...prev, serverId: true }));
void updatePreferences({ serverId: value ?? undefined });
},
[updatePreferences]
[updatePreferences],
);
const setProviderFromUser = useCallback(
@@ -608,7 +585,7 @@ export function useAgentFormState(
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
}));
},
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences]
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
);
const setProviderAndModelFromUser = useCallback(
@@ -627,7 +604,12 @@ export function useAgentFormState(
void updatePreferences({ provider });
void updateProviderPreferences(provider, { model: modelId });
},
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences, updateProviderPreferences]
[
preferences?.providerPreferences,
providerDefinitionMap,
updatePreferences,
updateProviderPreferences,
],
);
const setModeFromUser = useCallback(
@@ -636,7 +618,7 @@ export function useAgentFormState(
setUserModified((prev) => ({ ...prev, modeId: true }));
void updateProviderPreferences(formState.provider, { mode: modeId });
},
[formState.provider, updateProviderPreferences]
[formState.provider, updateProviderPreferences],
);
const setModelFromUser = useCallback(
@@ -646,7 +628,7 @@ export function useAgentFormState(
setUserModified((prev) => ({ ...prev, model: true }));
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
},
[formState.provider, updateProviderPreferences]
[formState.provider, updateProviderPreferences],
);
const setThinkingOptionFromUser = useCallback(
@@ -655,7 +637,7 @@ export function useAgentFormState(
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
void updateProviderPreferences(formState.provider, { thinkingOptionId });
},
[formState.provider, updateProviderPreferences]
[formState.provider, updateProviderPreferences],
);
const setWorkingDir = useCallback((value: string) => {
@@ -668,7 +650,7 @@ export function useAgentFormState(
setUserModified((prev) => ({ ...prev, workingDir: true }));
void updatePreferences({ workingDir: value });
},
[updatePreferences]
[updatePreferences],
);
const setSelectedServerId = useCallback((value: string | null) => {
@@ -780,7 +762,7 @@ export function useAgentFormState(
setProviderAndModelFromUser,
workingDirIsEmpty,
persistFormPreferences,
]
],
);
}
@@ -11,7 +11,7 @@ describe("useAgentInitialization timeline request policy", () => {
},
hasAuthoritativeHistory: false,
initialTimelineLimit: 200,
})
}),
).toEqual({
direction: "tail",
limit: 200,
@@ -25,7 +25,7 @@ describe("useAgentInitialization timeline request policy", () => {
cursor: null,
hasAuthoritativeHistory: true,
initialTimelineLimit: 200,
})
}),
).toEqual({
direction: "tail",
limit: 200,
@@ -42,7 +42,7 @@ describe("useAgentInitialization timeline request policy", () => {
},
hasAuthoritativeHistory: true,
initialTimelineLimit: 200,
})
}),
).toEqual({
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
@@ -57,7 +57,7 @@ describe("useAgentInitialization timeline request policy", () => {
cursor: null,
hasAuthoritativeHistory: false,
initialTimelineLimit: 0,
})
}),
).toEqual({
direction: "tail",
limit: 0,
@@ -66,8 +66,6 @@ describe("useAgentInitialization timeline request policy", () => {
});
it("does not expose an RPC-success init fallback", () => {
expect(
"shouldResolveInitFromRpcSuccess" in __private__
).toBe(false);
expect("shouldResolveInitFromRpcSuccess" in __private__).toBe(false);
});
});
@@ -1,9 +1,7 @@
import { useCallback } from "react";
import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store";
import type {
DaemonClient,
} from "@server/client/daemon-client";
import type { DaemonClient } from "@server/client/daemon-client";
import {
attachInitTimeout,
createInitDeferred,
@@ -18,9 +16,7 @@ const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
const UNBOUNDED_TIMELINE_LIMIT = 0;
function resolveInitialTimelineLimit(): number {
return Platform.OS === "web"
? UNBOUNDED_TIMELINE_LIMIT
: NATIVE_INITIAL_TIMELINE_LIMIT;
return Platform.OS === "web" ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT;
}
export const __private__ = {
@@ -47,7 +43,7 @@ export function useAgentInitialization({
return next;
});
},
[serverId, setInitializingAgents]
[serverId, setInitializingAgents],
);
const ensureAgentIsInitialized = useCallback(
@@ -64,23 +60,18 @@ export function useAgentInitialization({
const hasAuthoritativeHistory =
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
const timelineRequest = deriveInitialTimelineRequest({
cursor: cursor
? { epoch: cursor.epoch, seq: cursor.endSeq }
: null,
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
hasAuthoritativeHistory,
initialTimelineLimit,
});
const initRequestDirection =
timelineRequest.direction === "after" ? "after" : "tail";
const initRequestDirection = timelineRequest.direction === "after" ? "after" : "tail";
const deferred = createInitDeferred(key, initRequestDirection);
const timeoutId = setTimeout(() => {
setAgentInitializing(agentId, false);
rejectInitDeferred(
key,
new Error(
`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`
)
new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`),
);
}, INIT_TIMEOUT_MS);
attachInitTimeout(key, timeoutId);
@@ -93,19 +84,14 @@ export function useAgentInitialization({
return deferred.promise;
}
client
.fetchAgentTimeline(agentId, timelineRequest)
.catch((error) => {
setAgentInitializing(agentId, false);
rejectInitDeferred(
key,
error instanceof Error ? error : new Error(String(error))
);
});
client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => {
setAgentInitializing(agentId, false);
rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
});
return deferred.promise;
},
[client, serverId, setAgentInitializing]
[client, serverId, setAgentInitializing],
);
const refreshAgent = useCallback(
@@ -128,7 +114,7 @@ export function useAgentInitialization({
throw error;
}
},
[client, setAgentInitializing]
[client, setAgentInitializing],
);
return { ensureAgentIsInitialized, refreshAgent };
+71 -73
View File
@@ -1,144 +1,142 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AttachmentMetadata } from '@/attachments/types'
import { useDraftStore } from '@/stores/draft-store'
import { useCallback, useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import { useDraftStore } from "@/stores/draft-store";
type ImageUpdater =
| AttachmentMetadata[]
| ((prev: AttachmentMetadata[]) => AttachmentMetadata[])
type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]);
interface AgentInputDraft {
text: string
setText: (text: string) => void
images: AttachmentMetadata[]
setImages: (updater: ImageUpdater) => void
clear: (lifecycle: 'sent' | 'abandoned') => void
isHydrated: boolean
text: string;
setText: (text: string) => void;
images: AttachmentMetadata[];
setImages: (updater: ImageUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
isHydrated: boolean;
}
function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean {
return input.text.trim().length > 0 || input.images.length > 0
return input.text.trim().length > 0 || input.images.length > 0;
}
function areImagesEqual(input: {
left: AttachmentMetadata[]
right: AttachmentMetadata[]
left: AttachmentMetadata[];
right: AttachmentMetadata[];
}): boolean {
if (input.left.length !== input.right.length) {
return false
return false;
}
return input.left.every((image, index) => {
const other = input.right[index]
const other = input.right[index];
return (
image.id === other?.id &&
image.mimeType === other?.mimeType &&
image.storageType === other?.storageType &&
image.storageKey === other?.storageKey
)
})
);
});
}
export function useAgentInputDraft(draftKey: string): AgentInputDraft {
const [text, setText] = useState('')
const [images, setImagesState] = useState<AttachmentMetadata[]>([])
const [isHydrated, setIsHydrated] = useState(false)
const draftGenerationRef = useRef(0)
const hydratedGenerationRef = useRef(0)
const [text, setText] = useState("");
const [images, setImagesState] = useState<AttachmentMetadata[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
const draftGenerationRef = useRef(0);
const hydratedGenerationRef = useRef(0);
const setImages = useCallback((updater: ImageUpdater) => {
setImagesState((previousImages) => {
if (typeof updater === 'function') {
return updater(previousImages)
if (typeof updater === "function") {
return updater(previousImages);
}
return updater
})
}, [])
return updater;
});
}, []);
const clear = useCallback(
(lifecycle: 'sent' | 'abandoned') => {
const store = useDraftStore.getState()
store.clearDraftInput({ draftKey, lifecycle })
(lifecycle: "sent" | "abandoned") => {
const store = useDraftStore.getState();
store.clearDraftInput({ draftKey, lifecycle });
const generation = store.beginDraftGeneration(draftKey)
draftGenerationRef.current = generation
hydratedGenerationRef.current = generation
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = generation;
setText('')
setImagesState([])
setIsHydrated(true)
setText("");
setImagesState([]);
setIsHydrated(true);
},
[draftKey]
)
[draftKey],
);
useEffect(() => {
const store = useDraftStore.getState()
const generation = store.beginDraftGeneration(draftKey)
draftGenerationRef.current = generation
hydratedGenerationRef.current = 0
const store = useDraftStore.getState();
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = 0;
setText('')
setImagesState([])
setIsHydrated(false)
setText("");
setImagesState([]);
setIsHydrated(false);
let cancelled = false
let cancelled = false;
void (async () => {
const draft = await store.hydrateDraftInput(draftKey)
const draft = await store.hydrateDraftInput(draftKey);
if (cancelled) {
return
return;
}
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
return
return;
}
if (draft) {
setText(draft.text)
setImagesState(draft.images)
setText(draft.text);
setImagesState(draft.images);
}
hydratedGenerationRef.current = generation
setIsHydrated(true)
})()
hydratedGenerationRef.current = generation;
setIsHydrated(true);
})();
return () => {
cancelled = true
}
}, [draftKey])
cancelled = true;
};
}, [draftKey]);
useEffect(() => {
const currentGeneration = draftGenerationRef.current
const currentGeneration = draftGenerationRef.current;
if (currentGeneration <= 0) {
return
return;
}
const store = useDraftStore.getState()
const store = useDraftStore.getState();
const isCurrentGeneration = store.isDraftGenerationCurrent({
draftKey,
generation: currentGeneration,
})
});
if (!isCurrentGeneration) {
return
return;
}
if (hydratedGenerationRef.current !== currentGeneration) {
return
return;
}
const existing = store.getDraftInput(draftKey)
const existing = store.getDraftInput(draftKey);
const isSameDraft =
existing?.text === text &&
areImagesEqual({
left: existing?.images ?? [],
right: images,
})
});
if (isSameDraft) {
return
return;
}
if (!hasDraftContent({ text, images })) {
if (existing) {
store.clearDraftInput({ draftKey, lifecycle: 'abandoned' })
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
}
return
return;
}
store.saveDraftInput({
@@ -147,8 +145,8 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
text,
images,
},
})
}, [draftKey, images, text])
});
}, [draftKey, images, text]);
return {
text,
@@ -157,5 +155,5 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
setImages,
clear,
isHydrated,
}
};
}
@@ -47,13 +47,7 @@ function createAgent(id: string): Agent {
};
}
function createAgentWithStatus({
id,
status,
}: {
id: string;
status: Agent["status"];
}): Agent {
function createAgentWithStatus({ id, status }: { id: string; status: Agent["status"] }): Agent {
return {
...createAgent(id),
status,
@@ -75,7 +69,7 @@ function createBaseInput(): AgentScreenMachineInput {
}
function createBaseMemory(
overrides: Partial<AgentScreenMachineMemory> = {}
overrides: Partial<AgentScreenMachineMemory> = {},
): AgentScreenMachineMemory {
return {
hasRenderedReady: false,
@@ -19,9 +19,7 @@ export interface AgentScreenMachineInput {
hasHydratedHistoryBefore: boolean;
}
function shouldBlockInitialAuthoritativeReadyState(
input: AgentScreenMachineInput
): boolean {
function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInput): boolean {
return (
!input.shouldUseOptimisticStream &&
!input.hasHydratedHistoryBefore &&
@@ -96,24 +94,19 @@ export function deriveAgentScreenViewState({
nextMemory.hadInitialSyncFailure = false;
}
if (
input.missingAgentState.kind === "error" &&
!input.hasHydratedHistoryBefore
) {
if (input.missingAgentState.kind === "error" && !input.hasHydratedHistoryBefore) {
nextMemory.hadInitialSyncFailure = true;
}
const useOptimisticCreateFlowAgent =
input.shouldUseOptimisticStream &&
Boolean(input.placeholderAgent) &&
(!input.agent ||
input.agent.status === "initializing" ||
input.agent.status === "idle");
(!input.agent || input.agent.status === "initializing" || input.agent.status === "idle");
const candidateAgent =
input.agent && useOptimisticCreateFlowAgent && input.placeholderAgent
? { ...input.agent, status: input.placeholderAgent.status }
: input.agent ?? input.placeholderAgent;
: (input.agent ?? input.placeholderAgent);
const shouldBlockReadyState = shouldBlockInitialAuthoritativeReadyState(input);
if (input.missingAgentState.kind === "not_found") {
@@ -197,8 +190,7 @@ export function deriveAgentScreenViewState({
}
if (ui === "toast") {
const shouldEmitHistoryRefreshToast =
memory.activeToastLatch !== "history_refresh";
const shouldEmitHistoryRefreshToast = memory.activeToastLatch !== "history_refresh";
nextMemory.activeToastLatch = "history_refresh";
sync = {
status: "catching_up",
@@ -27,7 +27,7 @@ export function useAggregatedAgents(options?: {
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion()
() => runtime.getVersion(),
);
const sessionAgents = useSessionStore(
@@ -37,7 +37,7 @@ export function useAggregatedAgents(options?: {
result[serverId] = session.agents;
}
return result;
})
}),
);
const refreshAll = useCallback(() => {
@@ -47,7 +47,7 @@ export function useAggregatedAgents(options?: {
const result = useMemo(() => {
const allAgents: AggregatedAgent[] = [];
const serverLabelById = new Map(
daemons.map((daemon) => [daemon.serverId, daemon.label] as const)
daemons.map((daemon) => [daemon.serverId, daemon.label] as const),
);
// Derive agent directory from all sessions
@@ -102,8 +102,7 @@ export function useAggregatedAgents(options?: {
// Align list loading with the runtime directory-sync machine.
const isLoading = daemons.some((daemon) => {
const status =
runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ??
"initial_loading";
runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading";
return status === "initial_loading" || status === "revalidating";
});
const isInitialLoad = isLoading && !hasAnyData;
+4 -10
View File
@@ -6,10 +6,7 @@ import {
useHostRuntimeConnectionStatus,
useHostRuntimeIsDirectoryLoading,
} from "@/runtime/host-runtime";
import type {
AggregatedAgent,
AggregatedAgentsResult,
} from "@/hooks/use-aggregated-agents";
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
function toAggregatedAgent(params: {
source: Agent;
@@ -80,14 +77,12 @@ export function useAllAgentsList(options?: {
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options?.serverId]);
const includeArchived = options?.includeArchived ?? false;
const liveAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents ?? null : null
serverId ? (state.sessions[serverId]?.agents ?? null) : null,
);
const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? "");
@@ -102,8 +97,7 @@ export function useAllAgentsList(options?: {
if (!serverId || !liveAgents) {
return [];
}
const serverLabel =
daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
return buildAllAgentsList({
agents: liveAgents.values(),
serverId,
@@ -11,7 +11,7 @@ describe("useArchiveAgent", () => {
queryClient,
serverId: "server-a",
agentId: "agent-1",
})
}),
).toBe(false);
__private__.setAgentArchiving({
@@ -26,14 +26,14 @@ describe("useArchiveAgent", () => {
queryClient,
serverId: "server-a",
agentId: "agent-1",
})
}),
).toBe(true);
expect(
__private__.isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-2",
})
}),
).toBe(false);
__private__.setAgentArchiving({
@@ -48,16 +48,13 @@ describe("useArchiveAgent", () => {
queryClient,
serverId: "server-a",
agentId: "agent-1",
})
}),
).toBe(false);
});
it("removes an archived agent from cached list payloads", () => {
const payload = {
entries: [
{ agent: { id: "agent-1" } },
{ agent: { id: "agent-2" } },
],
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
pageInfo: { hasMore: false },
};
+8 -15
View File
@@ -34,11 +34,7 @@ function toArchiveKey(input: ArchiveAgentInput): string {
}
function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState {
return (
queryClient.getQueryData<ArchiveAgentPendingState>(
ARCHIVE_AGENT_PENDING_QUERY_KEY
) ?? {}
);
return queryClient.getQueryData<ArchiveAgentPendingState>(ARCHIVE_AGENT_PENDING_QUERY_KEY) ?? {};
}
function setAgentArchiving(input: SetAgentArchivingInput): void {
@@ -65,7 +61,7 @@ function setAgentArchiving(input: SetAgentArchivingInput): void {
const next = { ...state };
delete next[key];
return next;
}
},
);
}
@@ -79,7 +75,7 @@ function isAgentArchiving(input: IsAgentArchivingInput): boolean {
function removeAgentFromListPayload<T extends AgentsListQueryData | undefined>(
payload: T,
agentId: string
agentId: string,
): T {
if (!payload || !Array.isArray(payload.entries) || !agentId) {
return payload;
@@ -94,10 +90,7 @@ function removeAgentFromListPayload<T extends AgentsListQueryData | undefined>(
} as T;
}
function removeAgentFromCachedLists(
queryClient: QueryClient,
input: ArchiveAgentInput
): void {
function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgentInput): void {
const agentId = input.agentId.trim();
if (!agentId) {
return;
@@ -105,11 +98,11 @@ function removeAgentFromCachedLists(
queryClient.setQueryData<AgentsListQueryData | undefined>(
["sidebarAgentsList", input.serverId],
(current) => removeAgentFromListPayload(current, agentId)
(current) => removeAgentFromListPayload(current, agentId),
);
queryClient.setQueryData<AgentsListQueryData | undefined>(
["allAgents", input.serverId],
(current) => removeAgentFromListPayload(current, agentId)
(current) => removeAgentFromListPayload(current, agentId),
);
}
@@ -200,7 +193,7 @@ export function useArchiveAgent() {
async (input: ArchiveAgentInput): Promise<void> => {
await archiveMutateAsync(input);
},
[archiveMutateAsync]
[archiveMutateAsync],
);
const isArchivingAgent = useCallback(
@@ -211,7 +204,7 @@ export function useArchiveAgent() {
}
return Boolean((pendingQuery.data ?? {})[key]);
},
[pendingQuery.data]
[pendingQuery.data],
);
return {
@@ -1,7 +1,14 @@
import { useAudioRecorder as useExpoAudioRecorder, useAudioRecorderState, RecordingOptions, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio';
import { Paths, File, Directory, FileInfo } from 'expo-file-system';
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { AttemptCancelledError, AttemptGuard } from '@/utils/attempt-guard';
import {
useAudioRecorder as useExpoAudioRecorder,
useAudioRecorderState,
RecordingOptions,
RecordingPresets,
requestRecordingPermissionsAsync,
setAudioModeAsync,
} from "expo-audio";
import { Paths, File, Directory, FileInfo } from "expo-file-system";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
export interface AudioCaptureConfig {
sampleRate?: number;
@@ -18,8 +25,7 @@ export interface AudioCaptureConfig {
*/
async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
try {
const audioDir = new Directory(Paths.cache, 'Audio');
const audioDir = new Directory(Paths.cache, "Audio");
if (!audioDir.exists) {
return null;
@@ -32,11 +38,11 @@ async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
}
const validFiles = files
.map(file => {
.map((file) => {
const info = file.info();
return info;
})
.filter(f => f.size && f.size > 0);
.filter((f) => f.size && f.size > 0);
if (validFiles.length === 0) {
return null;
@@ -61,7 +67,7 @@ async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
return null;
} catch (e) {
console.error('[AudioRecorder] Error finding actual recording file:', e);
console.error("[AudioRecorder] Error finding actual recording file:", e);
return null;
}
}
@@ -79,7 +85,7 @@ async function uriToBlob(uri: string): Promise<Blob> {
// React Native doesn't support creating Blobs from binary data
// Create a Blob-like object that has the methods we need
const blobLike = {
type: 'audio/m4a',
type: "audio/m4a",
size: size,
arrayBuffer: async () => {
// Convert base64 to ArrayBuffer
@@ -114,33 +120,36 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
}, [config]);
// Create stable recording options - only recreate if actual config values change
const recordingOptions: RecordingOptions = useMemo(() => ({
...RecordingPresets.HIGH_QUALITY,
sampleRate: config?.sampleRate || 16000,
numberOfChannels: config?.numberOfChannels || 1,
bitRate: config?.bitRate || 128000,
extension: '.m4a',
isMeteringEnabled: !!config?.onAudioLevel, // Enable metering if callback provided
android: {
extension: '.m4a',
outputFormat: 'mpeg4',
audioEncoder: 'aac',
const recordingOptions: RecordingOptions = useMemo(
() => ({
...RecordingPresets.HIGH_QUALITY,
sampleRate: config?.sampleRate || 16000,
audioSource: 'voice_communication', // Enables echo cancellation, noise suppression, auto gain control
},
ios: {
extension: '.m4a',
audioQuality: 127, // High quality
sampleRate: config?.sampleRate || 16000,
linearPCMBitDepth: 16,
linearPCMIsBigEndian: false,
linearPCMIsFloat: false,
},
web: {
mimeType: 'audio/webm;codecs=opus',
bitsPerSecond: config?.bitRate || 128000,
},
}), [config?.sampleRate, config?.numberOfChannels, config?.bitRate, config?.onAudioLevel]);
numberOfChannels: config?.numberOfChannels || 1,
bitRate: config?.bitRate || 128000,
extension: ".m4a",
isMeteringEnabled: !!config?.onAudioLevel, // Enable metering if callback provided
android: {
extension: ".m4a",
outputFormat: "mpeg4",
audioEncoder: "aac",
sampleRate: config?.sampleRate || 16000,
audioSource: "voice_communication", // Enables echo cancellation, noise suppression, auto gain control
},
ios: {
extension: ".m4a",
audioQuality: 127, // High quality
sampleRate: config?.sampleRate || 16000,
linearPCMBitDepth: 16,
linearPCMIsBigEndian: false,
linearPCMIsFloat: false,
},
web: {
mimeType: "audio/webm;codecs=opus",
bitsPerSecond: config?.bitRate || 128000,
},
}),
[config?.sampleRate, config?.numberOfChannels, config?.bitRate, config?.onAudioLevel],
);
const audioRecorder = useExpoAudioRecorder(recordingOptions);
const recorderState = useAudioRecorderState(audioRecorder, 100);
@@ -176,7 +185,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
// Use expo's isRecording as single source of truth
if (recorder.isRecording) {
throw new Error('Already recording');
throw new Error("Already recording");
}
try {
@@ -188,7 +197,9 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
attemptGuardRef.current.assertCurrent(attemptId);
if (!permissionResponse.granted) {
throw new Error('Microphone permission denied. Please enable microphone access in your device settings.');
throw new Error(
"Microphone permission denied. Please enable microphone access in your device settings.",
);
}
// Configure audio mode for recording
@@ -208,14 +219,13 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
await recorder.record();
attemptGuardRef.current.assertCurrent(attemptId);
} catch (error: any) {
setRecordingStartTime(null);
if (error instanceof AttemptCancelledError) {
return;
}
if (error?.message !== 'Recording cancelled') {
console.error('[AudioRecorder] Failed to start recording:', error);
if (error?.message !== "Recording cancelled") {
console.error("[AudioRecorder] Failed to start recording:", error);
}
throw new Error(`Failed to start audio recording: ${error.message}`);
}
@@ -236,24 +246,26 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
if (recorder.isRecording) {
await recorder.stop();
} else {
console.warn('[AudioRecorder] Recorder already stopped before stop() call, continuing cleanup.');
console.warn(
"[AudioRecorder] Recorder already stopped before stop() call, continuing cleanup.",
);
}
// Get URI from recorder
let uri = recorder.uri;
// Workaround for Expo SDK 54 Android bug - find actual recording file
if (recordingStartTime && (!uri || uri === '')) {
if (recordingStartTime && (!uri || uri === "")) {
const actualUri = await getActualRecordingUri(recordingStartTime);
if (actualUri) {
uri = actualUri;
}
}
if (!uri || uri === '') {
if (!uri || uri === "") {
// Cancellation / early stop: return an empty blob, but guarantee cleanup.
setRecordingStartTime(null);
return new Blob([], { type: 'audio/m4a' });
return new Blob([], { type: "audio/m4a" });
}
// Get file info
@@ -262,7 +274,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
if (!exists) {
setRecordingStartTime(null);
return new Blob([], { type: 'audio/m4a' });
return new Blob([], { type: "audio/m4a" });
}
// Convert URI to Blob
@@ -280,7 +292,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
return await stopPromise;
} catch (error: any) {
setRecordingStartTime(null);
console.error('[AudioRecorder] Failed to stop recording:', error);
console.error("[AudioRecorder] Failed to stop recording:", error);
throw new Error(`Failed to stop audio recording: ${error.message}`);
} finally {
startStopMutexRef.current = null;
@@ -290,7 +302,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
const getSupportedMimeType = useCallback((): string | null => {
// On native platforms, expo-audio uses m4a/AAC
// On web, it can use webm/opus
return 'audio/m4a';
return "audio/m4a";
}, []);
const isRecording = useCallback(() => {
@@ -298,10 +310,13 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
}, [recorderState.isRecording]);
// Return stable object using useMemo
return useMemo(() => ({
start,
stop,
isRecording,
getSupportedMimeType,
}), [start, stop, isRecording, getSupportedMimeType]);
return useMemo(
() => ({
start,
stop,
isRecording,
getSupportedMimeType,
}),
[start, stop, isRecording, getSupportedMimeType],
);
}
@@ -46,7 +46,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
}
const detected = MIME_TYPE_CANDIDATES.find((candidate) =>
window.MediaRecorder.isTypeSupported(candidate)
window.MediaRecorder.isTypeSupported(candidate),
);
supportedMimeTypeRef.current = detected ?? null;
@@ -165,13 +165,13 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
if (!secureContext && !isDesktopApp) {
throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`,
);
}
if (!secureContext && isDesktopApp) {
console.warn(
"[AudioRecorder][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
{ currentOrigin }
{ currentOrigin },
);
}
@@ -335,9 +335,10 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
};
}, [hardReset]);
const getSupportedMimeType = useCallback(() => detectSupportedMimeType(), [
detectSupportedMimeType,
]);
const getSupportedMimeType = useCallback(
() => detectSupportedMimeType(),
[detectSupportedMimeType],
);
return {
start,
+6 -6
View File
@@ -20,7 +20,7 @@ interface UseAutocompleteResult {
}
export function useAutocomplete<TOption>(
input: UseAutocompleteInput<TOption>
input: UseAutocompleteInput<TOption>,
): UseAutocompleteResult {
const [selectedIndex, setSelectedIndex] = useState(-1);
const previousQueryRef = useRef("");
@@ -42,7 +42,7 @@ export function useAutocomplete<TOption>(
const fallbackIndex = getAutocompleteFallbackIndex(
input.options.length,
input.optionsPosition
input.optionsPosition,
);
if (queryChanged) {
@@ -68,7 +68,7 @@ export function useAutocomplete<TOption>(
currentIndex: current,
itemCount: input.options.length,
key: "ArrowUp",
})
}),
);
return true;
}
@@ -80,7 +80,7 @@ export function useAutocomplete<TOption>(
currentIndex: current,
itemCount: input.options.length,
key: "ArrowDown",
})
}),
);
return true;
}
@@ -89,7 +89,7 @@ export function useAutocomplete<TOption>(
event.preventDefault();
const fallbackIndex = getAutocompleteFallbackIndex(
input.options.length,
input.optionsPosition
input.optionsPosition,
);
const resolvedIndex =
selectedIndex >= 0 && selectedIndex < input.options.length
@@ -110,7 +110,7 @@ export function useAutocomplete<TOption>(
return false;
},
[input, selectedIndex]
[input, selectedIndex],
);
return {
@@ -12,7 +12,7 @@ function checkoutDiffQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef?: string
baseRef?: string,
) {
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const;
}
@@ -25,10 +25,7 @@ interface UseCheckoutDiffQueryOptions {
enabled?: boolean;
}
type CheckoutDiffQueryPayload = Omit<
SubscribeCheckoutDiffResponse["payload"],
"subscriptionId"
>;
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
export type ParsedDiffFile = CheckoutDiffQueryPayload["files"][number];
export type DiffHunk = ParsedDiffFile["hunks"][number];
@@ -43,9 +40,7 @@ function normalizeCheckoutDiffCompare(compare: {
return { mode: "uncommitted" };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef }
: { mode: "base" };
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
}
export function useCheckoutDiffQuery({
@@ -58,8 +53,7 @@ export function useCheckoutDiffQuery({
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab);
@@ -67,13 +61,13 @@ export function useCheckoutDiffQuery({
const hookInstanceId = useId();
const normalizedCompare = useMemo(
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
[mode, baseRef]
[mode, baseRef],
);
const compareMode = normalizedCompare.mode;
const compareBaseRef = normalizedCompare.baseRef;
const queryKey = useMemo(
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
[serverId, cwd, mode, baseRef]
[serverId, cwd, mode, baseRef],
);
const query = useQuery({
@@ -142,7 +136,7 @@ export function useCheckoutDiffQuery({
error: message.payload.error,
requestId: message.payload.requestId,
});
}
},
);
void client
@@ -152,7 +146,7 @@ export function useCheckoutDiffQuery({
mode: compareMode,
baseRef: compareBaseRef,
},
{ subscriptionId }
{ subscriptionId },
)
.then((payload) => {
if (cancelled) {
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { checkoutStatusRevalidationKey, nextCheckoutStatusRefetchDecision } from "./checkout-status-revalidation";
import {
checkoutStatusRevalidationKey,
nextCheckoutStatusRefetchDecision,
} from "./checkout-status-revalidation";
describe("useCheckoutStatusQuery", () => {
describe("checkoutStatusRevalidationKey", () => {
@@ -10,7 +13,7 @@ describe("useCheckoutStatusQuery", () => {
cwd: "/path/to/project",
isOpen: false,
explorerTab: "changes",
})
}),
).toBeNull();
});
@@ -21,7 +24,7 @@ describe("useCheckoutStatusQuery", () => {
cwd: "/path/to/project",
isOpen: true,
explorerTab: "files",
})
}),
).toBeNull();
});
@@ -32,7 +35,7 @@ describe("useCheckoutStatusQuery", () => {
cwd: "/path/to/project",
isOpen: true,
explorerTab: "changes",
})
}),
).toBe("daemon-1:/path/to/project");
});
});
@@ -63,9 +66,7 @@ describe("useCheckoutStatusQuery", () => {
});
it("refetches again when cwd changes while active", () => {
expect(
nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b")
).toEqual({
expect(nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b")).toEqual({
nextSeenKey: "daemon-1:/path/b",
shouldRefetch: true,
});
@@ -24,7 +24,7 @@ export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
function fetchCheckoutStatus(
client: { getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload> },
cwd: string
cwd: string,
): Promise<CheckoutStatusPayload> {
return client.getCheckoutStatus(cwd);
}
@@ -32,8 +32,7 @@ function fetchCheckoutStatus(
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab);
@@ -62,11 +61,14 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
// Revalidate when sidebar is open with "changes" tab active.
const revalidationKey = useMemo(
() => checkoutStatusRevalidationKey({ serverId, cwd, isOpen, explorerTab }),
[serverId, cwd, isOpen, explorerTab]
[serverId, cwd, isOpen, explorerTab],
);
const lastRevalidationKey = useRef<string | null>(null);
useEffect(() => {
const decision = nextCheckoutStatusRefetchDecision(lastRevalidationKey.current, revalidationKey);
const decision = nextCheckoutStatusRefetchDecision(
lastRevalidationKey.current,
revalidationKey,
);
lastRevalidationKey.current = decision.nextSeenKey;
if (!decision.shouldRefetch) return;
void query.refetch();
@@ -26,7 +26,7 @@ export function useClientActivity({
const appVisibleRef = useRef(AppState.currentState === "active");
const appVisibilityChangedAtRef = useRef<Date>(new Date());
const backgroundedAtMsRef = useRef<number | null>(
AppState.currentState === "active" ? null : Date.now()
AppState.currentState === "active" ? null : Date.now(),
);
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
@@ -70,7 +70,7 @@ export function useClientActivity({
}
recordUserActivity();
},
[onAppResumed, recordUserActivity]
[onAppResumed, recordUserActivity],
);
const maybeSendImmediateHeartbeat = useCallback(() => {
+1 -1
View File
@@ -1 +1 @@
export { useColorScheme } from 'react-native';
export { useColorScheme } from "react-native";
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
import { useEffect, useState } from "react";
import { useColorScheme as useRNColorScheme } from "react-native";
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
@@ -17,5 +17,5 @@ export function useColorScheme() {
return colorScheme;
}
return 'light';
return "light";
}
+24 -37
View File
@@ -11,10 +11,7 @@ import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import {
buildHostSettingsRoute,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus";
@@ -74,10 +71,7 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
},
];
function matchesActionQuery(
query: string,
action: CommandCenterActionDefinition
): boolean {
function matchesActionQuery(query: string, action: CommandCenterActionDefinition): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
if (action.title.toLowerCase().includes(normalized)) {
@@ -157,7 +151,7 @@ export function useCommandCenter() {
return EMPTY_ACTION_ITEMS;
}
return COMMAND_CENTER_ACTIONS.filter((action) =>
matchesActionQuery(query, action)
matchesActionQuery(query, action),
).map<CommandCenterActionItem>((action) => ({
kind: "action",
id: action.id,
@@ -206,24 +200,27 @@ export function useCommandCenter() {
});
router.navigate(route as any);
},
[setOpen]
[setOpen],
);
const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
clearCommandCenterFocusRestoreElement();
setOpen(false);
if (action.id === "new-agent") {
void openProjectPicker();
return;
}
if (!action.route) {
return;
}
didNavigateRef.current = true;
router.push(action.route);
}, [openProjectPicker, setOpen]);
const handleSelectAction = useCallback(
(action: CommandCenterActionItem) => {
clearCommandCenterFocusRestoreElement();
setOpen(false);
if (action.id === "new-agent") {
void openProjectPicker();
return;
}
if (!action.route) {
return;
}
didNavigateRef.current = true;
router.push(action.route);
},
[openProjectPicker, setOpen],
);
const handleSelectItem = useCallback(
(item: CommandCenterItem) => {
@@ -233,7 +230,7 @@ export function useCommandCenter() {
}
handleSelectAgent(item.agent);
},
[handleSelectAction, handleSelectAgent]
[handleSelectAction, handleSelectAgent],
);
useEffect(() => {
@@ -263,9 +260,7 @@ export function useCommandCenter() {
if (prevOpen && !didNavigateRef.current) {
const el = takeCommandCenterFocusRestoreElement();
const isFocused = () =>
Boolean(el) &&
typeof document !== "undefined" &&
document.activeElement === el;
Boolean(el) && typeof document !== "undefined" && document.activeElement === el;
const cancel = focusWithRetries({
focus: () => el?.focus(),
@@ -304,12 +299,7 @@ export function useCommandCenter() {
const handler = (event: KeyboardEvent) => {
const currentItems = itemsRef.current;
const key = event.key;
if (
key !== "ArrowDown" &&
key !== "ArrowUp" &&
key !== "Enter" &&
key !== "Escape"
) {
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
return;
}
@@ -322,10 +312,7 @@ export function useCommandCenter() {
if (key === "Enter") {
if (currentItems.length === 0) return;
event.preventDefault();
const index = Math.max(
0,
Math.min(activeIndexRef.current, currentItems.length - 1)
);
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
handleSelectItemRef.current(currentItems[index]!);
return;
}
@@ -46,12 +46,7 @@ export function useDelayedHistoryRefreshToast({
return;
}
showToastRef.current("Refreshing", {
icon: (
<ActivityIndicator
size="small"
color={indicatorColorRef.current}
/>
),
icon: <ActivityIndicator size="small" color={indicatorColorRef.current} />,
durationMs: HISTORY_REFRESH_TOAST_DURATION_MS,
testID: "agent-history-refresh-toast",
});
@@ -1,2 +1 @@
export * from "./use-dictation-audio-source.native";
@@ -4,7 +4,10 @@ import { useState } from "react";
import { createAudioEngine } from "@/voice/audio-engine";
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
import type {
DictationAudioSource,
DictationAudioSourceConfig,
} from "./use-dictation-audio-source.types";
export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource {
const onPcmSegmentRef = useRef(config.onPcmSegment);
@@ -8,4 +8,3 @@ export type DictationAudioSource = {
stop: () => Promise<void>;
volume: number;
};
@@ -2,9 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parsePcm16Wav } from "@/utils/pcm16-wav";
import { isDesktop } from "@/desktop/host";
import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types";
import type {
DictationAudioSource,
DictationAudioSourceConfig,
} from "./use-dictation-audio-source.types";
const getAudioContextCtor = (): (typeof AudioContext) | null => {
const getAudioContextCtor = (): typeof AudioContext | null => {
if (typeof window === "undefined") {
return null;
}
@@ -19,7 +22,11 @@ const floatToInt16 = (sample: number): number => {
return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
};
const resampleToPcm16 = (input: Float32Array, inputRate: number, outputRate: number): Int16Array => {
const resampleToPcm16 = (
input: Float32Array,
inputRate: number,
outputRate: number,
): Int16Array => {
if (input.length === 0) {
return new Int16Array(0);
}
@@ -122,15 +129,18 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
},
});
const decodeAudioData = useCallback(async (context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> => {
const maybePromise = context.decodeAudioData(buffer);
if (maybePromise && typeof (maybePromise as Promise<AudioBuffer>).then === "function") {
return maybePromise as Promise<AudioBuffer>;
}
return await new Promise<AudioBuffer>((resolve, reject) => {
context.decodeAudioData(buffer, resolve, reject);
});
}, []);
const decodeAudioData = useCallback(
async (context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> => {
const maybePromise = context.decodeAudioData(buffer);
if (maybePromise && typeof (maybePromise as Promise<AudioBuffer>).then === "function") {
return maybePromise as Promise<AudioBuffer>;
}
return await new Promise<AudioBuffer>((resolve, reject) => {
context.decodeAudioData(buffer, resolve, reject);
});
},
[],
);
const emitPcmSegments = useCallback((pcm: Int16Array) => {
const outputRate = 16000;
@@ -164,12 +174,14 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext && !isDesktopApp) {
throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`);
throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`,
);
}
if (!secureContext && isDesktopApp) {
console.warn(
"[DictationAudio][Web] Insecure context reported under Desktop; attempting getUserMedia anyway",
{ currentOrigin }
{ currentOrigin },
);
}
@@ -253,7 +265,9 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
throw new Error("MediaRecorder unavailable");
}
const recorder = new RecorderCtor(stream, { mimeType: "audio/webm;codecs=opus" } as MediaRecorderOptions) as MediaRecorder;
const recorder = new RecorderCtor(stream, {
mimeType: "audio/webm;codecs=opus",
} as MediaRecorderOptions) as MediaRecorder;
const recorderRefs: RecorderRefs = {
recorder,
@@ -274,7 +288,10 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
};
recorder.addEventListener("stop", () => {
try {
const blob = recorderRefs.audioChunks.length > 0 ? new Blob(recorderRefs.audioChunks, { type: recorder.mimeType }) : new Blob([], { type: recorder.mimeType });
const blob =
recorderRefs.audioChunks.length > 0
? new Blob(recorderRefs.audioChunks, { type: recorder.mimeType })
: new Blob([], { type: recorder.mimeType });
recorderRefs.stoppedResolve?.(blob);
} catch (err) {
recorderRefs.stoppedReject?.(err);
@@ -310,8 +327,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
refs.current.started = false;
setVolume(0);
const { processor, source, gain, context, stream, pending, mode, recorder } =
refs.current;
const { processor, source, gain, context, stream, pending, mode, recorder } = refs.current;
if (processor) {
try {
@@ -434,6 +450,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
stop,
volume,
}),
[start, stop, volume]
[start, stop, volume],
);
}
+20 -22
View File
@@ -126,7 +126,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
setError(normalized.message);
onErrorRef.current?.(normalized);
},
[setError]
[setError],
);
const clearStreamingState = useCallback(() => {
@@ -135,20 +135,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
setPartialTranscript("");
}, []);
const startNewStream = useCallback(
async (reason: string) => {
await senderRef.current?.restartStream(reason);
},
[]
);
const startNewStream = useCallback(async (reason: string) => {
await senderRef.current?.restartStream(reason);
}, []);
const ensureFinalTranscript = useCallback(
async (finalSeq: number): Promise<string> => {
const result = await senderRef.current!.finish(finalSeq);
return result.text;
},
[]
);
const ensureFinalTranscript = useCallback(async (finalSeq: number): Promise<string> => {
const result = await senderRef.current!.finish(finalSeq);
return result.text;
}, []);
useEffect(() => {
if (!client) {
@@ -209,9 +203,8 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
setDuration(0);
setStatus("idle");
const transcriptText = (
text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim()
);
const transcriptText =
text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim();
clearStreamingState();
if (!transcriptText) {
@@ -219,7 +212,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
}
onTranscriptRef.current?.(transcriptText, { requestId });
},
[clearStreamingState]
[clearStreamingState],
);
const handleDictationFailure = useCallback(
@@ -240,11 +233,15 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
reportError(normalized, "Failed to complete dictation");
},
[reportError]
[reportError],
);
const startDictation = useCallback(async () => {
if (actionGateRef.current.starting || actionGateRef.current.confirming || actionGateRef.current.cancelling) {
if (
actionGateRef.current.starting ||
actionGateRef.current.confirming ||
actionGateRef.current.cancelling
) {
return;
}
if (isRecordingRef.current || isProcessingRef.current) {
@@ -438,10 +435,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
}, [cancelDictation]);
const visibilityRef = useRef<boolean | null>(
typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null
typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null,
);
useEffect(() => {
const nextVisible = typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null;
const nextVisible =
typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null;
const prevVisible = visibilityRef.current;
visibilityRef.current = nextVisible;
@@ -1,77 +1,84 @@
import { useCallback, useMemo, useReducer } from 'react'
import { useCreateFlowStore } from '@/stores/create-flow-store'
import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from '@/types/stream'
import { useCallback, useMemo, useReducer } from "react";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import {
generateMessageId,
type StreamItem,
type UserMessageImageAttachment,
} from "@/types/stream";
const EMPTY_STREAM_ITEMS: StreamItem[] = []
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
type CreateAttempt = {
clientMessageId: string
text: string
timestamp: Date
images?: UserMessageImageAttachment[]
}
clientMessageId: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
};
type DraftAgentMachineState =
| { tag: 'draft'; errorMessage: string }
| { tag: 'creating'; attempt: CreateAttempt }
| { tag: "draft"; errorMessage: string }
| { tag: "creating"; attempt: CreateAttempt };
type DraftAgentMachineEvent =
| { type: 'DRAFT_SET_ERROR'; message: string }
| { type: 'SUBMIT'; attempt: CreateAttempt }
| { type: 'CREATE_FAILED'; message: string }
| { type: "DRAFT_SET_ERROR"; message: string }
| { type: "SUBMIT"; attempt: CreateAttempt }
| { type: "CREATE_FAILED"; message: string };
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`)
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
function reducer(state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState {
function reducer(
state: DraftAgentMachineState,
event: DraftAgentMachineEvent,
): DraftAgentMachineState {
switch (event.type) {
case 'DRAFT_SET_ERROR': {
if (state.tag !== 'draft') {
return state
case "DRAFT_SET_ERROR": {
if (state.tag !== "draft") {
return state;
}
return { ...state, errorMessage: event.message }
return { ...state, errorMessage: event.message };
}
case 'SUBMIT': {
return { tag: 'creating', attempt: event.attempt }
case "SUBMIT": {
return { tag: "creating", attempt: event.attempt };
}
case 'CREATE_FAILED': {
if (state.tag !== 'creating') {
return state
case "CREATE_FAILED": {
if (state.tag !== "creating") {
return state;
}
return { tag: 'draft', errorMessage: event.message }
return { tag: "draft", errorMessage: event.message };
}
default:
return assertNever(event)
return assertNever(event);
}
}
type CreateRequestResult<TCreateResult> = {
agentId: string | null
result: TCreateResult
}
agentId: string | null;
result: TCreateResult;
};
type SubmitContext = {
text: string
images?: UserMessageImageAttachment[]
}
text: string;
images?: UserMessageImageAttachment[];
};
type CreateRequestContext = {
attempt: CreateAttempt
text: string
images?: UserMessageImageAttachment[]
}
attempt: CreateAttempt;
text: string;
images?: UserMessageImageAttachment[];
};
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string
getPendingServerId: () => string | null
validateBeforeSubmit?: (ctx: SubmitContext) => string | null
onBeforeSubmit?: (ctx: CreateRequestContext) => void
onCreateStart?: () => void
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent
onCreateSuccess: (ctx: { result: TCreateResult; attempt: CreateAttempt }) => Promise<void> | void
onCreateError?: (error: Error) => void
draftId: string;
getPendingServerId: () => string | null;
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
onCreateStart?: () => void;
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>;
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent;
onCreateSuccess: (ctx: { result: TCreateResult; attempt: CreateAttempt }) => Promise<void> | void;
onCreateError?: (error: Error) => void;
}
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
@@ -86,26 +93,26 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const [machine, dispatch] = useReducer(reducer, {
tag: 'draft',
errorMessage: '',
} as DraftAgentMachineState)
tag: "draft",
errorMessage: "",
} as DraftAgentMachineState);
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending)
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId)
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle)
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear)
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
const formErrorMessage = machine.tag === 'draft' ? machine.errorMessage : ''
const isSubmitting = machine.tag === 'creating'
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (machine.tag !== 'creating') {
return EMPTY_STREAM_ITEMS
if (machine.tag !== "creating") {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: 'user_message',
kind: "user_message",
id: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
@@ -113,43 +120,43 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
? { images: machine.attempt.images }
: {}),
},
]
}, [machine])
];
}, [machine]);
const draftAgent = useMemo<TDraftAgent | null>(() => {
if (machine.tag !== 'creating') {
return null
if (machine.tag !== "creating") {
return null;
}
return buildDraftAgent(machine.attempt)
}, [buildDraftAgent, machine])
return buildDraftAgent(machine.attempt);
}, [buildDraftAgent, machine]);
const handleCreateFromInput = useCallback(
async ({ text, images }: SubmitContext) => {
if (isSubmitting) {
throw new Error('Already loading')
throw new Error("Already loading");
}
dispatch({ type: 'DRAFT_SET_ERROR', message: '' })
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const trimmedPrompt = text.trim()
const trimmedPrompt = text.trim();
if (!trimmedPrompt) {
const error = new Error('Initial prompt is required')
dispatch({ type: 'DRAFT_SET_ERROR', message: error.message })
throw error
const error = new Error("Initial prompt is required");
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const validationError = validateBeforeSubmit?.({ text: trimmedPrompt, images })
const validationError = validateBeforeSubmit?.({ text: trimmedPrompt, images });
if (validationError) {
const error = new Error(validationError)
dispatch({ type: 'DRAFT_SET_ERROR', message: validationError })
throw error
const error = new Error(validationError);
dispatch({ type: "DRAFT_SET_ERROR", message: validationError });
throw error;
}
const pendingServerId = getPendingServerId()
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error('No host selected')
dispatch({ type: 'DRAFT_SET_ERROR', message: error.message })
throw error
const error = new Error("No host selected");
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const attempt: CreateAttempt = {
@@ -157,7 +164,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
text: trimmedPrompt,
timestamp: new Date(),
...(images && images.length > 0 ? { images } : {}),
}
};
setPendingCreateAttempt({
draftId,
@@ -167,31 +174,31 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
text: attempt.text,
timestamp: attempt.timestamp.getTime(),
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
})
});
onBeforeSubmit?.({ attempt, text: trimmedPrompt, images })
dispatch({ type: 'SUBMIT', attempt })
onCreateStart?.()
onBeforeSubmit?.({ attempt, text: trimmedPrompt, images });
dispatch({ type: "SUBMIT", attempt });
onCreateStart?.();
try {
const createResult = await createRequest({
attempt,
text: trimmedPrompt,
images,
})
});
if (createResult.agentId) {
updatePendingAgentId({ draftId, agentId: createResult.agentId })
updatePendingAgentId({ draftId, agentId: createResult.agentId });
}
await onCreateSuccess({ result: createResult.result, attempt })
await onCreateSuccess({ result: createResult.result, attempt });
} catch (error) {
const resolved = error instanceof Error ? error : new Error('Failed to create agent')
dispatch({ type: 'CREATE_FAILED', message: resolved.message })
markPendingCreateLifecycle({ draftId, lifecycle: 'abandoned' })
clearPendingCreateAttempt({ draftId })
onCreateError?.(resolved)
throw error
const resolved = error instanceof Error ? error : new Error("Failed to create agent");
dispatch({ type: "CREATE_FAILED", message: resolved.message });
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
clearPendingCreateAttempt({ draftId });
onCreateError?.(resolved);
throw error;
}
},
[
@@ -208,8 +215,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
setPendingCreateAttempt,
updatePendingAgentId,
validateBeforeSubmit,
]
)
],
);
return {
machine,
@@ -218,7 +225,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
optimisticStreamItems,
draftAgent,
handleCreateFromInput,
}
};
}
export type { CreateAttempt as DraftCreateAttempt }
export type { CreateAttempt as DraftCreateAttempt };
@@ -1,11 +1,6 @@
import { useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import {
Extrapolation,
interpolate,
runOnJS,
useSharedValue,
} from "react-native-reanimated";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
interface UseExplorerOpenGestureParams {
@@ -13,18 +8,9 @@ interface UseExplorerOpenGestureParams {
onOpen: () => void;
}
export function useExplorerOpenGesture({
enabled,
onOpen,
}: UseExplorerOpenGestureParams) {
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
} = useExplorerSidebarAnimation();
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
useExplorerSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
@@ -75,14 +61,14 @@ export function useExplorerOpenGesture({
// Right sidebar: start from closed position (+windowWidth) and move towards 0.
const newTranslateX = Math.max(
0,
Math.min(windowWidth, windowWidth + event.translationX)
Math.min(windowWidth, windowWidth + event.translationX),
);
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
@@ -111,6 +97,6 @@ export function useExplorerOpenGesture({
onOpen,
touchStartX,
touchStartY,
]
],
);
}
+8 -4
View File
@@ -23,7 +23,7 @@ const FAVICON_IMAGES: Record<ColorScheme, Record<FaviconStatus, { uri: string }
/* eslint-enable @typescript-eslint/no-require-imports */
function deriveFaviconStatus(
agents: ReturnType<typeof useAggregatedAgents>["agents"]
agents: ReturnType<typeof useAggregatedAgents>["agents"],
): FaviconStatus {
const hasRunning = agents.some((agent) => agent.status === "running");
if (hasRunning) {
@@ -38,12 +38,12 @@ function deriveFaviconStatus(
}
function deriveMacDockBadgeCount(
agents: ReturnType<typeof useAggregatedAgents>["agents"]
agents: ReturnType<typeof useAggregatedAgents>["agents"],
): number | undefined {
const attentionCount = agents.filter(
(agent) =>
(agent.pendingPermissionCount ?? 0) > 0 ||
(agent.requiresAttention && agent.attentionReason === "finished")
(agent.requiresAttention && agent.attentionReason === "finished"),
).length;
if (attentionCount > 0) {
return attentionCount;
@@ -85,7 +85,11 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) {
}
function getSystemColorScheme(): ColorScheme {
if (Platform.OS !== "web" || typeof window === "undefined" || typeof window.matchMedia !== "function") {
if (
Platform.OS !== "web" ||
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return "dark";
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+35 -42
View File
@@ -2,10 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input";
import { getDesktopHost } from "@/desktop/host";
import {
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void;
@@ -140,43 +137,41 @@ export function useFileDropZone({
}
try {
const unlisten = await desktopWindow.onDragDropEvent(
(event: DesktopDragDropEvent) => {
const payload = event.payload;
if (payload.type === "leave") {
setIsDragging(false);
return;
}
if (payload.type === "enter" || payload.type === "over") {
if (!disabled) {
setIsDragging(true);
}
return;
}
// Drop always ends the current drag operation.
const unlisten = await desktopWindow.onDragDropEvent((event: DesktopDragDropEvent) => {
const payload = event.payload;
if (payload.type === "leave") {
setIsDragging(false);
if (disabled) return;
const imagePaths = payload.paths.filter(isImagePath);
if (imagePaths.length === 0) {
return;
}
void Promise.all(imagePaths.map(filePathToImageAttachment))
.then((attachments) => {
if (attachments.length === 0) {
return;
}
onFilesDroppedRef.current(attachments);
})
.catch((error) => {
console.error("[useFileDropZone] Failed to persist dropped files:", error);
});
return;
}
);
if (payload.type === "enter" || payload.type === "over") {
if (!disabled) {
setIsDragging(true);
}
return;
}
// Drop always ends the current drag operation.
setIsDragging(false);
if (disabled) return;
const imagePaths = payload.paths.filter(isImagePath);
if (imagePaths.length === 0) {
return;
}
void Promise.all(imagePaths.map(filePathToImageAttachment))
.then((attachments) => {
if (attachments.length === 0) {
return;
}
onFilesDroppedRef.current(attachments);
})
.catch((error) => {
console.error("[useFileDropZone] Failed to persist dropped files:", error);
});
});
if (disposed) {
runCleanup(unlisten);
@@ -247,9 +242,7 @@ export function useFileDropZone({
if (imageFiles.length === 0) return;
try {
const attachments = await Promise.all(
imageFiles.map(fileToImageAttachment)
);
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
onFilesDroppedRef.current(attachments);
} catch (error) {
console.error("[useFileDropZone] Failed to process dropped files:", error);
@@ -37,9 +37,7 @@ function normalizeWorkspaceValue(value: string | null | undefined): string | nul
return trimmed.length > 0 ? trimmed : null;
}
export function buildWorkspaceExplorerStateKey(
scope: FileExplorerWorkspaceScope
): string | null {
export function buildWorkspaceExplorerStateKey(scope: FileExplorerWorkspaceScope): string | null {
const normalizedWorkspaceId = normalizeWorkspaceValue(scope.workspaceId);
if (normalizedWorkspaceId) {
return `workspace:${normalizedWorkspaceId}`;
@@ -51,15 +49,13 @@ export function buildWorkspaceExplorerStateKey(
return `root:${normalizedWorkspaceRoot}`;
}
export function useFileExplorerActions(
params: { serverId: string } & FileExplorerWorkspaceScope
) {
export function useFileExplorerActions(params: { serverId: string } & FileExplorerWorkspaceScope) {
const { serverId, workspaceId, workspaceRoot } = params;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const normalizedWorkspaceRoot = useMemo(
() => normalizeWorkspaceValue(workspaceRoot),
[workspaceRoot]
[workspaceRoot],
);
const workspaceStateKey = useMemo(
() =>
@@ -67,7 +63,7 @@ export function useFileExplorerActions(
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[workspaceId, normalizedWorkspaceRoot]
[workspaceId, normalizedWorkspaceRoot],
);
const updateExplorerState = useCallback(
@@ -82,21 +78,17 @@ export function useFileExplorerActions(
return next;
});
},
[serverId, setFileExplorer, workspaceStateKey]
[serverId, setFileExplorer, workspaceStateKey],
);
const requestDirectoryListing = useCallback(
async (
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean }
) => {
async (path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }) => {
if (!workspaceStateKey) {
return;
}
const normalizedPath = path && path.length > 0 ? path : ".";
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
const shouldRecordHistory =
options?.recordHistory ?? (shouldSetCurrentPath ? true : false);
const shouldRecordHistory = options?.recordHistory ?? (shouldSetCurrentPath ? true : false);
updateExplorerState((state) => ({
...state,
@@ -138,7 +130,7 @@ export function useFileExplorerActions(
const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot,
normalizedPath,
"list"
"list",
);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = {
@@ -167,7 +159,7 @@ export function useFileExplorerActions(
}));
}
},
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey]
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey],
);
const requestFilePreview = useCallback(
@@ -207,7 +199,7 @@ export function useFileExplorerActions(
const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot,
normalizedPath,
"file"
"file",
);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = {
@@ -236,7 +228,7 @@ export function useFileExplorerActions(
}));
}
},
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey]
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey],
);
const requestFileDownloadToken = useCallback(
@@ -253,7 +245,7 @@ export function useFileExplorerActions(
}
return payload;
},
[client, normalizedWorkspaceRoot]
[client, normalizedWorkspaceRoot],
);
const selectExplorerEntry = useCallback(
@@ -263,7 +255,7 @@ export function useFileExplorerActions(
selectedEntryPath: path,
}));
},
[updateExplorerState]
[updateExplorerState],
);
return {
@@ -39,7 +39,7 @@ export interface UseFormPreferencesReturn {
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
updateProviderPreferences: (
provider: AgentProvider,
updates: Partial<ProviderPreferences>
updates: Partial<ProviderPreferences>,
) => Promise<void>;
}
@@ -58,7 +58,7 @@ export function useFormPreferences(): UseFormPreferencesReturn {
(provider: AgentProvider): ProviderPreferences | undefined => {
return preferences.providerPreferences?.[provider];
},
[preferences.providerPreferences]
[preferences.providerPreferences],
);
const updatePreferences = useCallback(
@@ -70,7 +70,7 @@ export function useFormPreferences(): UseFormPreferencesReturn {
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
[queryClient]
[queryClient],
);
const updateProviderPreferences = useCallback(
@@ -91,7 +91,7 @@ export function useFormPreferences(): UseFormPreferencesReturn {
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
[queryClient]
[queryClient],
);
return {
+48 -31
View File
@@ -4,10 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import {
buildGitActions,
type GitActions,
} from "@/components/git-actions-policy";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -44,8 +41,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { status, isLoading: isStatusLoading } =
useCheckoutStatusQuery({ serverId, cwd });
const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error;
@@ -53,10 +49,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const {
status: prStatus,
githubFeaturesEnabled,
} = useCheckoutPrStatusQuery({
const { status: prStatus, githubFeaturesEnabled } = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
@@ -98,7 +91,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
// Ignore persistence failures; default will reset to "merge".
}
},
[shipDefaultStorageKey]
[shipDefaultStorageKey],
);
useEffect(() => {
@@ -107,22 +100,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
// Store selectors
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" })
state.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" })
state.getStatus({ serverId, cwd, actionId: "push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" })
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
state.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
state.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" })
state.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
@@ -228,14 +221,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled =
actionsDisabled || mergeFromBaseStatus === "pending";
const pushDisabled =
actionsDisabled || pushStatus === "pending";
const archiveDisabled =
actionsDisabled || archiveStatus === "pending";
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
const pushDisabled = actionsDisabled || pushStatus === "pending";
const archiveDisabled = actionsDisabled || archiveStatus === "pending";
const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
@@ -308,12 +297,40 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
},
});
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
icons, baseRef,
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
aheadCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
commitDisabled,
pushDisabled,
prDisabled,
mergeDisabled,
mergeFromBaseDisabled,
archiveDisabled,
commitStatus,
pushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
handleCommit,
handlePush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
icons,
baseRef,
]);
return { gitActions, branchLabel, actionError, isGit };
@@ -27,7 +27,10 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
}
if (!currentPermission?.granted) {
Alert.alert("Permission required", "Please allow access to your photo library to attach images.");
Alert.alert(
"Permission required",
"Please allow access to your photo library to attach images.",
);
return false;
}
+12 -12
View File
@@ -1,23 +1,23 @@
import { useQuery } from '@tanstack/react-query'
import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
import { useQuery } from "@tanstack/react-query";
import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
const DESKTOP_DAEMON_SERVER_ID_QUERY_KEY = ['desktop-daemon-server-id'] as const
const DESKTOP_DAEMON_SERVER_ID_QUERY_KEY = ["desktop-daemon-server-id"] as const;
interface DesktopDaemonServerIdResult {
serverId: string | null
serverId: string | null;
}
async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult> {
const status = await getDesktopDaemonStatus()
const serverId = status.serverId.trim()
const status = await getDesktopDaemonStatus();
const serverId = status.serverId.trim();
return {
serverId: serverId.length > 0 ? serverId : null,
}
};
}
export function useIsLocalDaemon(serverId: string): boolean {
const normalizedServerId = serverId.trim()
const isDesktop = shouldUseDesktopDaemon()
const normalizedServerId = serverId.trim();
const isDesktop = shouldUseDesktopDaemon();
const query = useQuery({
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
@@ -30,11 +30,11 @@ export function useIsLocalDaemon(serverId: string): boolean {
refetchOnReconnect: false,
refetchOnWindowFocus: false,
retry: false,
})
});
if (!isDesktop || normalizedServerId.length === 0) {
return false
return false;
}
return query.data?.serverId === normalizedServerId
return query.data?.serverId === normalizedServerId;
}
@@ -26,12 +26,5 @@ export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) {
isActive: input.isActive,
handle: input.handle,
});
}, [
input.actions,
input.enabled,
input.handle,
input.handlerId,
input.isActive,
input.priority,
]);
}, [input.actions, input.enabled, input.handle, input.handlerId, input.isActive, input.priority]);
}
@@ -89,7 +89,7 @@ export function useKeyboardShortcuts({
const currentIndex = targets.findIndex(
(target) =>
target.serverId === workspaceRoute.serverId &&
target.workspaceId === workspaceRoute.workspaceId
target.workspaceId === workspaceRoute.workspaceId,
);
const fromIndex = currentIndex >= 0 ? currentIndex : delta > 0 ? -1 : 0;
const nextIndex = (fromIndex + delta + targets.length) % targets.length;
@@ -106,9 +106,7 @@ export function useKeyboardShortcuts({
return true;
};
const dispatchMessageInputAction = (
kind: MessageInputKeyboardActionKind
): boolean => {
const dispatchMessageInputAction = (kind: MessageInputKeyboardActionKind): boolean => {
switch (kind) {
case "focus":
return keyboardActionDispatcher.dispatch({
@@ -229,7 +227,7 @@ export function useKeyboardShortcuts({
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement(
(targetEl as HTMLElement | null) ?? activeEl ?? null
(targetEl as HTMLElement | null) ?? activeEl ?? null,
);
}
store.setCommandCenterOpen(!store.commandCenterOpen);
@@ -7,9 +7,7 @@ import { useOpenProject } from "./use-open-project";
export function useOpenProjectPicker(serverId: string | null): () => Promise<void> {
const normalizedServerId = serverId?.trim() ?? "";
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
const setProjectPickerOpen = useKeyboardShortcutsStore(
(state) => state.setProjectPickerOpen
);
const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen);
const openProject = useOpenProject(serverId);
return useCallback(async () => {
+7 -24
View File
@@ -2,22 +2,15 @@ import { router } from "expo-router";
import { useCallback } from "react";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export function useOpenProject(
serverId: string | null
): (path: string) => Promise<boolean> {
export function useOpenProject(serverId: string | null): (path: string) => Promise<boolean> {
const normalizedServerId = serverId?.trim() ?? "";
const toast = useToast();
const client = useHostRuntimeClient(normalizedServerId);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setHasHydratedWorkspaces = useSessionStore(
(state) => state.setHasHydratedWorkspaces
);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
async (path: string) => {
@@ -32,31 +25,21 @@ export function useOpenProject(
throw new Error(payload.error || "Failed to open project");
}
mergeWorkspaces(normalizedServerId, [
normalizeWorkspaceDescriptor(payload.workspace),
]);
mergeWorkspaces(normalizedServerId, [normalizeWorkspaceDescriptor(payload.workspace)]);
setHasHydratedWorkspaces(normalizedServerId, true);
router.replace(
prepareWorkspaceTab({
serverId: normalizedServerId,
workspaceId: payload.workspace.id,
target: { kind: "draft", draftId: "new" },
}) as any
}) as any,
);
return true;
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to open project"
);
toast.error(error instanceof Error ? error.message : "Failed to open project");
return false;
}
},
[
client,
mergeWorkspaces,
normalizedServerId,
setHasHydratedWorkspaces,
toast,
]
[client, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces, toast],
);
}
@@ -25,10 +25,7 @@ async function ensurePushPermission(): Promise<boolean> {
return requested.status === "granted";
}
export function usePushTokenRegistration(params: {
client: DaemonClient;
serverId: string;
}): void {
export function usePushTokenRegistration(params: { client: DaemonClient; serverId: string }): void {
const { client, serverId } = params;
const tokenRef = useRef<string | null>(null);
const lastSentTokenRef = useRef<string | null>(null);
+20 -17
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useState, useEffect, useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
const STORAGE_KEY = '@paseo:recent-paths';
const STORAGE_KEY = "@paseo:recent-paths";
const MAX_RECENT_PATHS = 3;
export interface UseRecentPathsReturn {
@@ -28,33 +28,36 @@ export function useRecentPaths(): UseRecentPathsReturn {
setRecentPaths(parsed);
}
} catch (error) {
console.error('[RecentPaths] Failed to load recent paths:', error);
console.error("[RecentPaths] Failed to load recent paths:", error);
// Continue with empty array
} finally {
setIsLoading(false);
}
}
const addRecentPath = useCallback(async (path: string) => {
try {
// Remove duplicates and add to front
const filtered = recentPaths.filter((p) => p !== path);
const updated = [path, ...filtered].slice(0, MAX_RECENT_PATHS);
const addRecentPath = useCallback(
async (path: string) => {
try {
// Remove duplicates and add to front
const filtered = recentPaths.filter((p) => p !== path);
const updated = [path, ...filtered].slice(0, MAX_RECENT_PATHS);
setRecentPaths(updated);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
} catch (error) {
console.error('[RecentPaths] Failed to save recent path:', error);
throw error;
}
}, [recentPaths]);
setRecentPaths(updated);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
} catch (error) {
console.error("[RecentPaths] Failed to save recent path:", error);
throw error;
}
},
[recentPaths],
);
const clearRecentPaths = useCallback(async () => {
try {
setRecentPaths([]);
await AsyncStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error('[RecentPaths] Failed to clear recent paths:', error);
console.error("[RecentPaths] Failed to clear recent paths:", error);
throw error;
}
}, []);
@@ -17,24 +17,24 @@ export function useSessionForServer(serverId: string | null): SessionState | nul
export function useSessionForServer<T>(
serverId: string | null,
selector: SessionSelector<T>,
equalityFn?: EqualityFn<T>
equalityFn?: EqualityFn<T>,
): T;
export function useSessionForServer<T>(
serverId: string | null,
selector?: SessionSelector<T>,
equalityFn?: EqualityFn<T>
equalityFn?: EqualityFn<T>,
): SessionState | null | T {
const storeSelector = useCallback(
(state: ReturnType<typeof useSessionStore.getState>): SessionState | null | T => {
const session = serverId ? state.sessions[serverId] ?? null : null;
const session = serverId ? (state.sessions[serverId] ?? null) : null;
return selector ? selector(session) : session;
},
[selector, serverId]
[selector, serverId],
);
return useStoreWithEqualityFn(
useSessionStore,
storeSelector,
equalityFn as EqualityFn<SessionState | null | T>
equalityFn as EqualityFn<SessionState | null | T>,
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ describe("use-settings", () => {
expect(result).toEqual(mod.DEFAULT_APP_SETTINGS);
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
mod.APP_SETTINGS_KEY,
JSON.stringify(mod.DEFAULT_APP_SETTINGS)
JSON.stringify(mod.DEFAULT_APP_SETTINGS),
);
});
+3 -2
View File
@@ -36,7 +36,8 @@ export function useAppSettings(): UseAppSettingsReturn {
const updateSettings = useCallback(
async (updates: Partial<AppSettings>) => {
try {
const prev = queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS;
const prev =
queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS;
const next = { ...prev, ...updates };
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
@@ -45,7 +46,7 @@ export function useAppSettings(): UseAppSettingsReturn {
throw err;
}
},
[queryClient]
[queryClient],
);
const resetSettings = useCallback(async () => {
@@ -55,15 +55,14 @@ export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSecti
const { activeGroups } = useMemo(
() =>
groupAgents(agents, {
getRemoteUrl: (agent) =>
remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null,
getRemoteUrl: (agent) => remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null,
}),
[agents, remoteUrlByAgentKey]
[agents, remoteUrlByAgentKey],
);
const sortedGroups = useMemo(
() => sortProjectsByStoredOrder(activeGroups, projectOrder),
[activeGroups, projectOrder]
[activeGroups, projectOrder],
);
const sections: SidebarSectionData[] = useMemo(() => {
@@ -1,17 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
import { isSidebarProjectFlattened } from '@/utils/sidebar-project-row-model'
import { useCallback, useEffect, useMemo, useState } from "react";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set());
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setSidebarShortcutWorkspaceTargets
)
(state) => state.setSidebarShortcutWorkspaceTargets,
);
const setVisibleWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setVisibleWorkspaceTargets
)
(state) => state.setVisibleWorkspaceTargets,
);
const shortcutModel = useMemo(
() =>
@@ -19,71 +19,71 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
projects,
collapsedProjectKeys,
}),
[collapsedProjectKeys, projects]
)
[collapsedProjectKeys, projects],
);
useEffect(() => {
setCollapsedProjectKeys((prev) => {
const collapsibleProjectKeys = new Set(
projects
.filter((project) => !isSidebarProjectFlattened(project))
.map((project) => project.projectKey)
)
const next = new Set<string>()
.map((project) => project.projectKey),
);
const next = new Set<string>();
for (const key of prev) {
if (collapsibleProjectKeys.has(key)) {
next.add(key)
next.add(key);
}
}
return next
})
}, [projects])
return next;
});
}, [projects]);
useEffect(() => {
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets)
setVisibleWorkspaceTargets(shortcutModel.visibleTargets)
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
setVisibleWorkspaceTargets(shortcutModel.visibleTargets);
}, [
setSidebarShortcutWorkspaceTargets,
setVisibleWorkspaceTargets,
shortcutModel.shortcutTargets,
shortcutModel.visibleTargets,
])
]);
useEffect(() => {
return () => {
setSidebarShortcutWorkspaceTargets([])
setVisibleWorkspaceTargets([])
}
}, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets])
setSidebarShortcutWorkspaceTargets([]);
setVisibleWorkspaceTargets([]);
};
}, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets]);
const toggleProjectCollapsed = useCallback((projectKey: string) => {
setCollapsedProjectKeys((prev) => {
const next = new Set(prev)
const next = new Set(prev);
if (next.has(projectKey)) {
next.delete(projectKey)
next.delete(projectKey);
} else {
next.add(projectKey)
next.add(projectKey);
}
return next
})
}, [])
return next;
});
}, []);
const setProjectCollapsed = useCallback((projectKey: string, collapsed: boolean) => {
setCollapsedProjectKeys((prev) => {
const next = new Set(prev)
const next = new Set(prev);
if (collapsed) {
next.add(projectKey)
next.add(projectKey);
} else {
next.delete(projectKey)
next.delete(projectKey);
}
return next
})
}, [])
return next;
});
}, []);
return {
collapsedProjectKeys,
shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey,
setProjectCollapsed,
toggleProjectCollapsed,
}
};
}
@@ -1,17 +1,17 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from "vitest";
import {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarProjectsFromWorkspaces,
} from './use-sidebar-workspaces-list'
import type { WorkspaceDescriptor } from '@/stores/session-store'
} from "./use-sidebar-workspaces-list";
import type { WorkspaceDescriptor } from "@/stores/session-store";
interface OrderedItem {
key: string
key: string;
}
function item(key: string): OrderedItem {
return { key }
return { key };
}
function workspace(
@@ -21,7 +21,7 @@ function workspace(
WorkspaceDescriptor,
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind"
>
>
>,
): WorkspaceDescriptor {
return {
id: input.id,
@@ -34,230 +34,237 @@ function workspace(
status: input.status,
activityAt: input.activityAt,
diffStat: null,
}
};
}
describe('applyStoredOrdering', () => {
it('keeps unknown items on the baseline while applying stored order', () => {
describe("applyStoredOrdering", () => {
it("keeps unknown items on the baseline while applying stored order", () => {
const result = applyStoredOrdering({
items: [item('new'), item('a'), item('b')],
storedOrder: ['b', 'a'],
items: [item("new"), item("a"), item("b")],
storedOrder: ["b", "a"],
getKey: (entry) => entry.key,
})
});
expect(result.map((entry) => entry.key)).toEqual(['new', 'b', 'a'])
})
expect(result.map((entry) => entry.key)).toEqual(["new", "b", "a"]);
});
it('ignores stale and duplicate stored keys', () => {
it("ignores stale and duplicate stored keys", () => {
const result = applyStoredOrdering({
items: [item('x'), item('y')],
storedOrder: ['missing', 'y', 'y', 'x'],
items: [item("x"), item("y")],
storedOrder: ["missing", "y", "y", "x"],
getKey: (entry) => entry.key,
})
});
expect(result.map((entry) => entry.key)).toEqual(['y', 'x'])
})
expect(result.map((entry) => entry.key)).toEqual(["y", "x"]);
});
it('returns baseline when there is no persisted order', () => {
const baseline = [item('first'), item('second')]
it("returns baseline when there is no persisted order", () => {
const baseline = [item("first"), item("second")];
const result = applyStoredOrdering({
items: baseline,
storedOrder: [],
getKey: (entry) => entry.key,
})
});
expect(result).toBe(baseline)
})
})
expect(result).toBe(baseline);
});
});
describe('appendMissingOrderKeys', () => {
it('appends unseen keys while preserving existing order', () => {
describe("appendMissingOrderKeys", () => {
it("appends unseen keys while preserving existing order", () => {
const result = appendMissingOrderKeys({
currentOrder: ['project-b', 'project-a'],
visibleKeys: ['project-a', 'project-b', 'project-c'],
})
currentOrder: ["project-b", "project-a"],
visibleKeys: ["project-a", "project-b", "project-c"],
});
expect(result).toEqual(['project-b', 'project-a', 'project-c'])
})
expect(result).toEqual(["project-b", "project-a", "project-c"]);
});
it('returns the same array when there are no unseen keys', () => {
const currentOrder = ['project-a', 'project-b']
it("returns the same array when there are no unseen keys", () => {
const currentOrder = ["project-a", "project-b"];
const result = appendMissingOrderKeys({
currentOrder,
visibleKeys: ['project-b', 'project-a'],
})
visibleKeys: ["project-b", "project-a"],
});
expect(result).toBe(currentOrder)
})
})
expect(result).toBe(currentOrder);
});
});
describe('buildSidebarProjectsFromWorkspaces', () => {
it('uses workspace descriptor name and status directly', () => {
describe("buildSidebarProjectsFromWorkspaces", () => {
it("uses workspace descriptor name and status directly", () => {
const workspaces: WorkspaceDescriptor[] = [
workspace({
id: '/repo/main',
projectId: 'project-1',
name: 'feat/hard-cut',
status: 'failed',
activityAt: new Date('2026-01-01T00:00:00.000Z'),
id: "/repo/main",
projectId: "project-1",
name: "feat/hard-cut",
status: "failed",
activityAt: new Date("2026-01-01T00:00:00.000Z"),
}),
]
];
const projects = buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
serverId: "srv",
workspaces,
projectOrder: [],
workspaceOrderByScope: {},
})
});
expect(projects).toHaveLength(1)
expect(projects[0]?.statusBucket).toBe('failed')
expect(projects[0]?.workspaces[0]?.name).toBe('feat/hard-cut')
expect(projects[0]?.workspaces[0]?.statusBucket).toBe('failed')
})
expect(projects).toHaveLength(1);
expect(projects[0]?.statusBucket).toBe("failed");
expect(projects[0]?.workspaces[0]?.name).toBe("feat/hard-cut");
expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed");
});
it('preserves stored project order even when activity changes', () => {
it("preserves stored project order even when activity changes", () => {
const initialWorkspaces: WorkspaceDescriptor[] = [
workspace({
id: '/repo/b',
projectId: 'project-b',
name: 'feat/b',
status: 'running',
activityAt: new Date('2026-01-02T00:00:00.000Z'),
id: "/repo/b",
projectId: "project-b",
name: "feat/b",
status: "running",
activityAt: new Date("2026-01-02T00:00:00.000Z"),
}),
workspace({
id: '/repo/a',
projectId: 'project-a',
name: 'feat/a',
status: 'running',
activityAt: new Date('2026-01-01T00:00:00.000Z'),
id: "/repo/a",
projectId: "project-a",
name: "feat/a",
status: "running",
activityAt: new Date("2026-01-01T00:00:00.000Z"),
}),
]
];
const seededOrder = appendMissingOrderKeys({
currentOrder: [],
visibleKeys: buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
serverId: "srv",
workspaces: initialWorkspaces,
projectOrder: [],
workspaceOrderByScope: {},
}).map((project) => project.projectKey),
})
});
const updatedProjects = buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
serverId: "srv",
workspaces: [
workspace({
id: '/repo/b',
projectId: 'project-b',
name: 'feat/b',
status: 'running',
activityAt: new Date('2026-01-02T00:00:00.000Z'),
id: "/repo/b",
projectId: "project-b",
name: "feat/b",
status: "running",
activityAt: new Date("2026-01-02T00:00:00.000Z"),
}),
workspace({
id: '/repo/a',
projectId: 'project-a',
name: 'feat/a',
status: 'running',
activityAt: new Date('2026-01-03T00:00:00.000Z'),
id: "/repo/a",
projectId: "project-a",
name: "feat/a",
status: "running",
activityAt: new Date("2026-01-03T00:00:00.000Z"),
}),
],
projectOrder: seededOrder,
workspaceOrderByScope: {},
})
});
expect(updatedProjects.map((project) => project.projectKey)).toEqual(['project-b', 'project-a'])
})
expect(updatedProjects.map((project) => project.projectKey)).toEqual([
"project-b",
"project-a",
]);
});
it('appends new projects after the stored project order', () => {
it("appends new projects after the stored project order", () => {
const projects = buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
serverId: "srv",
workspaces: [
workspace({
id: '/repo/c',
projectId: 'project-c',
name: 'feat/c',
status: 'running',
activityAt: new Date('2026-01-04T00:00:00.000Z'),
id: "/repo/c",
projectId: "project-c",
name: "feat/c",
status: "running",
activityAt: new Date("2026-01-04T00:00:00.000Z"),
}),
workspace({
id: '/repo/b',
projectId: 'project-b',
name: 'feat/b',
status: 'running',
activityAt: new Date('2026-01-02T00:00:00.000Z'),
id: "/repo/b",
projectId: "project-b",
name: "feat/b",
status: "running",
activityAt: new Date("2026-01-02T00:00:00.000Z"),
}),
workspace({
id: '/repo/a',
projectId: 'project-a',
name: 'feat/a',
status: 'running',
activityAt: new Date('2026-01-01T00:00:00.000Z'),
id: "/repo/a",
projectId: "project-a",
name: "feat/a",
status: "running",
activityAt: new Date("2026-01-01T00:00:00.000Z"),
}),
],
projectOrder: ['project-b', 'project-a', 'project-c'],
projectOrder: ["project-b", "project-a", "project-c"],
workspaceOrderByScope: {},
})
});
expect(projects.map((project) => project.projectKey)).toEqual(['project-b', 'project-a', 'project-c'])
})
expect(projects.map((project) => project.projectKey)).toEqual([
"project-b",
"project-a",
"project-c",
]);
});
it('preserves stored workspace order when workspace activity changes', () => {
it("preserves stored workspace order when workspace activity changes", () => {
const initialProjects = buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
serverId: "srv",
workspaces: [
workspace({
id: '/repo/main',
projectId: 'project-1',
name: 'main',
status: 'running',
activityAt: new Date('2026-01-02T00:00:00.000Z'),
id: "/repo/main",
projectId: "project-1",
name: "main",
status: "running",
activityAt: new Date("2026-01-02T00:00:00.000Z"),
}),
workspace({
id: '/repo/feature',
projectId: 'project-1',
name: 'feature',
status: 'running',
activityAt: new Date('2026-01-01T00:00:00.000Z'),
id: "/repo/feature",
projectId: "project-1",
name: "feature",
status: "running",
activityAt: new Date("2026-01-01T00:00:00.000Z"),
}),
],
projectOrder: ['project-1'],
projectOrder: ["project-1"],
workspaceOrderByScope: {},
})
});
const seededWorkspaceOrder = appendMissingOrderKeys({
currentOrder: [],
visibleKeys: initialProjects[0]?.workspaces.map((workspace) => workspace.workspaceKey) ?? [],
})
});
const projects = buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
serverId: "srv",
workspaces: [
workspace({
id: '/repo/main',
projectId: 'project-1',
name: 'main',
status: 'running',
activityAt: new Date('2026-01-02T00:00:00.000Z'),
id: "/repo/main",
projectId: "project-1",
name: "main",
status: "running",
activityAt: new Date("2026-01-02T00:00:00.000Z"),
}),
workspace({
id: '/repo/feature',
projectId: 'project-1',
name: 'feature',
status: 'running',
activityAt: new Date('2026-01-03T00:00:00.000Z'),
id: "/repo/feature",
projectId: "project-1",
name: "feature",
status: "running",
activityAt: new Date("2026-01-03T00:00:00.000Z"),
}),
],
projectOrder: ['project-1'],
projectOrder: ["project-1"],
workspaceOrderByScope: {
'srv::project-1': seededWorkspaceOrder,
"srv::project-1": seededWorkspaceOrder,
},
})
});
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
'/repo/main',
'/repo/feature',
])
})
})
"/repo/main",
"/repo/feature",
]);
});
});
@@ -1,45 +1,45 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store'
import { getHostRuntimeStore } from '@/runtime/host-runtime'
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
import type { WorkspaceDescriptor } from '@/stores/session-store'
import { projectDisplayNameFromProjectId } from '@/utils/project-display-name'
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
const EMPTY_ORDER: string[] = []
const EMPTY_PROJECTS: SidebarProjectEntry[] = []
const EMPTY_WORKSPACE_ORDER_SCOPE: Record<string, string[]> = {}
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
const EMPTY_WORKSPACE_ORDER_SCOPE: Record<string, string[]> = {};
export type SidebarStateBucket = WorkspaceDescriptor['status']
export type SidebarStateBucket = WorkspaceDescriptor["status"];
export interface SidebarWorkspaceEntry {
workspaceKey: string
serverId: string
workspaceId: string
workspaceKind: WorkspaceDescriptor['workspaceKind']
name: string
activityAt: Date | null
statusBucket: SidebarStateBucket
diffStat: { additions: number; deletions: number } | null
workspaceKey: string;
serverId: string;
workspaceId: string;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
activityAt: Date | null;
statusBucket: SidebarStateBucket;
diffStat: { additions: number; deletions: number } | null;
}
export interface SidebarProjectEntry {
projectKey: string
projectName: string
projectKind: WorkspaceDescriptor['projectKind']
iconWorkingDir: string
statusBucket: SidebarStateBucket
activeCount: number
totalWorkspaces: number
latestActivityAt: Date | null
workspaces: SidebarWorkspaceEntry[]
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
statusBucket: SidebarStateBucket;
activeCount: number;
totalWorkspaces: number;
latestActivityAt: Date | null;
workspaces: SidebarWorkspaceEntry[];
}
export interface SidebarWorkspacesListResult {
projects: SidebarProjectEntry[]
isLoading: boolean
isInitialLoad: boolean
isRevalidating: boolean
refreshAll: () => void
projects: SidebarProjectEntry[];
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
refreshAll: () => void;
}
const SIDEBAR_BUCKET_PRIORITY: Record<SidebarStateBucket, number> = {
@@ -48,80 +48,83 @@ const SIDEBAR_BUCKET_PRIORITY: Record<SidebarStateBucket, number> = {
running: 2,
failed: 3,
needs_input: 4,
}
};
function aggregateBucket(
current: SidebarStateBucket,
candidate: SidebarStateBucket
candidate: SidebarStateBucket,
): SidebarStateBucket {
return SIDEBAR_BUCKET_PRIORITY[candidate] > SIDEBAR_BUCKET_PRIORITY[current] ? candidate : current
return SIDEBAR_BUCKET_PRIORITY[candidate] > SIDEBAR_BUCKET_PRIORITY[current]
? candidate
: current;
}
function compareWorkspaceBaseline(
left: SidebarWorkspaceEntry,
right: SidebarWorkspaceEntry
right: SidebarWorkspaceEntry,
): number {
if (left.activityAt && right.activityAt) {
const dateDelta = right.activityAt.getTime() - left.activityAt.getTime()
const dateDelta = right.activityAt.getTime() - left.activityAt.getTime();
if (dateDelta !== 0) {
return dateDelta
return dateDelta;
}
} else if (left.activityAt || right.activityAt) {
return left.activityAt ? -1 : 1
return left.activityAt ? -1 : 1;
}
const nameDelta = left.name.localeCompare(right.name, undefined, {
numeric: true,
sensitivity: 'base',
})
sensitivity: "base",
});
if (nameDelta !== 0) {
return nameDelta
return nameDelta;
}
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
numeric: true,
sensitivity: 'base',
})
sensitivity: "base",
});
}
function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number {
if (left.latestActivityAt && right.latestActivityAt) {
const dateDelta = right.latestActivityAt.getTime() - left.latestActivityAt.getTime()
const dateDelta = right.latestActivityAt.getTime() - left.latestActivityAt.getTime();
if (dateDelta !== 0) {
return dateDelta
return dateDelta;
}
} else if (left.latestActivityAt || right.latestActivityAt) {
return left.latestActivityAt ? -1 : 1
return left.latestActivityAt ? -1 : 1;
}
return left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: 'base',
})
sensitivity: "base",
});
}
export function buildSidebarProjectsFromWorkspaces(input: {
serverId: string
workspaces: Iterable<WorkspaceDescriptor>
projectOrder: string[]
workspaceOrderByScope: Record<string, string[]>
serverId: string;
workspaces: Iterable<WorkspaceDescriptor>;
projectOrder: string[];
workspaceOrderByScope: Record<string, string[]>;
}): SidebarProjectEntry[] {
const byProject = new Map<string, SidebarProjectEntry>()
const byProject = new Map<string, SidebarProjectEntry>();
for (const workspace of input.workspaces) {
const project =
byProject.get(workspace.projectId) ??
({
projectKey: workspace.projectId,
projectName: workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectName:
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath || workspace.id,
statusBucket: 'done',
statusBucket: "done",
activeCount: 0,
totalWorkspaces: 0,
latestActivityAt: null,
workspaces: [],
} satisfies SidebarProjectEntry)
} satisfies SidebarProjectEntry);
const row: SidebarWorkspaceEntry = {
workspaceKey: `${input.serverId}:${workspace.id}`,
@@ -132,255 +135,256 @@ export function buildSidebarProjectsFromWorkspaces(input: {
activityAt: workspace.activityAt,
statusBucket: workspace.status,
diffStat: workspace.diffStat,
}
};
project.workspaces.push(row)
project.totalWorkspaces += 1
if (workspace.status !== 'done') {
project.activeCount += 1
project.workspaces.push(row);
project.totalWorkspaces += 1;
if (workspace.status !== "done") {
project.activeCount += 1;
}
project.statusBucket = aggregateBucket(project.statusBucket, workspace.status)
project.statusBucket = aggregateBucket(project.statusBucket, workspace.status);
if (
!project.latestActivityAt ||
(workspace.activityAt && workspace.activityAt.getTime() > project.latestActivityAt.getTime())
) {
project.latestActivityAt = workspace.activityAt
project.latestActivityAt = workspace.activityAt;
}
byProject.set(workspace.projectId, project)
byProject.set(workspace.projectId, project);
}
const baselineProjects = Array.from(byProject.values()).map((project) => {
const baselineWorkspaces = [...project.workspaces]
baselineWorkspaces.sort(compareWorkspaceBaseline)
const baselineWorkspaces = [...project.workspaces];
baselineWorkspaces.sort(compareWorkspaceBaseline);
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(input.serverId, project.projectKey)
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(input.serverId, project.projectKey);
const orderedWorkspaces = applyStoredOrdering({
items: baselineWorkspaces,
storedOrder: input.workspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER,
getKey: (workspace) => workspace.workspaceKey,
})
});
return {
...project,
workspaces: orderedWorkspaces,
}
})
};
});
baselineProjects.sort(compareProjectBaseline)
baselineProjects.sort(compareProjectBaseline);
return applyStoredOrdering({
items: baselineProjects,
storedOrder: input.projectOrder,
getKey: (project) => project.projectKey,
})
});
}
export function applyStoredOrdering<T>(input: {
items: T[]
storedOrder: string[]
getKey: (item: T) => string
items: T[];
storedOrder: string[];
getKey: (item: T) => string;
}): T[] {
if (input.items.length <= 1 || input.storedOrder.length === 0) {
return input.items
return input.items;
}
const itemByKey = new Map<string, T>()
const itemByKey = new Map<string, T>();
for (const item of input.items) {
itemByKey.set(input.getKey(item), item)
itemByKey.set(input.getKey(item), item);
}
const prunedOrder: string[] = []
const seen = new Set<string>()
const prunedOrder: string[] = [];
const seen = new Set<string>();
for (const key of input.storedOrder) {
if (!itemByKey.has(key) || seen.has(key)) {
continue
continue;
}
seen.add(key)
prunedOrder.push(key)
seen.add(key);
prunedOrder.push(key);
}
if (prunedOrder.length === 0) {
return input.items
return input.items;
}
const orderedSet = new Set(prunedOrder)
const ordered: T[] = []
let orderedIndex = 0
const orderedSet = new Set(prunedOrder);
const ordered: T[] = [];
let orderedIndex = 0;
for (const item of input.items) {
const key = input.getKey(item)
const key = input.getKey(item);
if (!orderedSet.has(key)) {
ordered.push(item)
continue
ordered.push(item);
continue;
}
const targetKey = prunedOrder[orderedIndex] ?? key
orderedIndex += 1
ordered.push(itemByKey.get(targetKey) ?? item)
const targetKey = prunedOrder[orderedIndex] ?? key;
orderedIndex += 1;
ordered.push(itemByKey.get(targetKey) ?? item);
}
return ordered
return ordered;
}
export function appendMissingOrderKeys(input: {
currentOrder: string[]
visibleKeys: string[]
currentOrder: string[];
visibleKeys: string[];
}): string[] {
if (input.visibleKeys.length === 0) {
return input.currentOrder
return input.currentOrder;
}
const existingKeys = new Set(input.currentOrder)
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key))
const existingKeys = new Set(input.currentOrder);
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key));
if (missingKeys.length === 0) {
return input.currentOrder
return input.currentOrder;
}
return [...input.currentOrder, ...missingKeys]
return [...input.currentOrder, ...missingKeys];
}
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
return `${serverId.trim()}::${projectKey.trim()}`
return `${serverId.trim()}::${projectKey.trim()}`;
}
function toWorkspaceDescriptor(payload: {
id: string
projectId: string
projectDisplayName: string
projectRootPath: string
projectKind: WorkspaceDescriptor['projectKind']
workspaceKind: WorkspaceDescriptor['workspaceKind']
name: string
status: WorkspaceDescriptor['status']
activityAt: string | null
id: string;
projectId: string;
projectDisplayName: string;
projectRootPath: string;
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
status: WorkspaceDescriptor["status"];
activityAt: string | null;
}): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor(payload)
return normalizeWorkspaceDescriptor(payload);
}
export function useSidebarWorkspacesList(options?: {
serverId?: string | null
enabled?: boolean
serverId?: string | null;
enabled?: boolean;
}): SidebarWorkspacesListResult {
const runtime = getHostRuntimeStore()
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
const value = options?.serverId
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
}, [options?.serverId])
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options?.serverId]);
const persistedProjectOrder = useSidebarOrderStore((state) =>
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER
)
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
);
const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
serverId ? state.workspaceOrderByServerAndProject : EMPTY_WORKSPACE_ORDER_SCOPE
)
serverId ? state.workspaceOrderByServerAndProject : EMPTY_WORKSPACE_ORDER_SCOPE,
);
const isActive = Boolean(serverId)
const isActive = Boolean(serverId);
const sessionWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.workspaces ?? null) : null
)
const hasHydratedWorkspaces = useSessionStore(
(state) => (isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false)
)
isActive && serverId ? (state.sessions[serverId]?.workspaces ?? null) : null,
);
const hasHydratedWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const connectionStatus = useSyncExternalStore(
(onStoreChange) =>
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
() => {
if (!isActive || !serverId) {
return 'idle'
return "idle";
}
const snapshot = runtime.getSnapshot(serverId)
return snapshot?.connectionStatus ?? 'idle'
const snapshot = runtime.getSnapshot(serverId);
return snapshot?.connectionStatus ?? "idle";
},
() => {
if (!isActive || !serverId) {
return 'idle'
return "idle";
}
const snapshot = runtime.getSnapshot(serverId)
return snapshot?.connectionStatus ?? 'idle'
}
)
const snapshot = runtime.getSnapshot(serverId);
return snapshot?.connectionStatus ?? "idle";
},
);
const projects = useMemo(() => {
if (!sessionWorkspaces || sessionWorkspaces.size === 0 || !serverId) {
return EMPTY_PROJECTS
return EMPTY_PROJECTS;
}
return buildSidebarProjectsFromWorkspaces({
serverId,
workspaces: sessionWorkspaces.values(),
projectOrder: persistedProjectOrder,
workspaceOrderByScope: persistedWorkspaceOrderByScope,
})
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces])
});
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]);
useEffect(() => {
if (!serverId || projects.length === 0) {
return
return;
}
const nextProjectOrder = appendMissingOrderKeys({
currentOrder: persistedProjectOrder,
visibleKeys: projects.map((project) => project.projectKey),
})
});
if (nextProjectOrder !== persistedProjectOrder) {
useSidebarOrderStore.getState().setProjectOrder(serverId, nextProjectOrder)
useSidebarOrderStore.getState().setProjectOrder(serverId, nextProjectOrder);
}
for (const project of projects) {
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId, project.projectKey)
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId, project.projectKey);
const persistedWorkspaceOrder =
persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER
persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER;
const nextWorkspaceOrder = appendMissingOrderKeys({
currentOrder: persistedWorkspaceOrder,
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
})
});
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
useSidebarOrderStore
.getState()
.setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder)
.setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder);
}
}
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId])
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId]);
const refreshAll = useCallback(() => {
if (!isActive || !serverId || connectionStatus !== 'online') {
return
if (!isActive || !serverId || connectionStatus !== "online") {
return;
}
const client = runtime.getClient(serverId)
const client = runtime.getClient(serverId);
if (!client) {
return
return;
}
void (async () => {
const next = new Map<string, WorkspaceDescriptor>()
let cursor: string | null = null
const next = new Map<string, WorkspaceDescriptor>();
let cursor: string | null = null;
try {
while (true) {
const payload = await client.fetchWorkspaces({
sort: [{ key: 'activity_at', direction: 'desc' }],
sort: [{ key: "activity_at", direction: "desc" }],
page: cursor ? { limit: 200, cursor } : { limit: 200 },
})
});
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry)
next.set(workspace.id, workspace)
const workspace = toWorkspaceDescriptor(entry);
next.set(workspace.id, workspace);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break
break;
}
cursor = payload.pageInfo.nextCursor
cursor = payload.pageInfo.nextCursor;
}
const store = useSessionStore.getState()
store.setWorkspaces(serverId, next)
store.setHasHydratedWorkspaces(serverId, true)
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
store.setHasHydratedWorkspaces(serverId, true);
} catch {
// ignore explicit refresh failures; hook keeps existing data
}
})()
}, [connectionStatus, isActive, runtime, serverId])
})();
}, [connectionStatus, isActive, runtime, serverId]);
const isLoading = isActive && Boolean(serverId) && connectionStatus === 'online' && !hasHydratedWorkspaces
const isInitialLoad = isLoading && projects.length === 0
const isRevalidating = false
const isLoading =
isActive && Boolean(serverId) && connectionStatus === "online" && !hasHydratedWorkspaces;
const isInitialLoad = isLoading && projects.length === 0;
const isRevalidating = false;
return {
projects,
@@ -388,5 +392,5 @@ export function useSidebarWorkspacesList(options?: {
isInitialLoad,
isRevalidating,
refreshAll,
}
};
}
+4 -4
View File
@@ -3,14 +3,14 @@
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { Colors } from "@/constants/theme";
import { useColorScheme } from "@/hooks/use-color-scheme";
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
) {
const theme = useColorScheme() ?? 'light';
const theme = useColorScheme() ?? "light";
const colorFromProps = props[theme];
if (colorFromProps) {
@@ -16,11 +16,8 @@ export function navigateToWorkspace(serverId: string, workspaceId: string) {
export function useWorkspaceNavigation() {
return {
navigateToWorkspace: useCallback(
(serverId: string, workspaceId: string) => {
navigateToWorkspace(serverId, workspaceId);
},
[]
),
navigateToWorkspace: useCallback((serverId: string, workspaceId: string) => {
navigateToWorkspace(serverId, workspaceId);
}, []),
};
}