Switch to double quotes and reformat codebase with Biome
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 @@
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}, []),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user