Merge branch 'main' into dev

Resolve 38 conflicts preserving both sides' work:
- Server: DB-based registries (dev) + WorkspaceGitService/DaemonConfigStore (main)
- App: script proxy/voice MCP (dev) + pin/hide tabs/bootstrap boundary (main)
- Shared: WorkspaceScriptPayload (dev) + git/GitHub runtime schemas (main)
- Deleted workspace-registry-bootstrap.ts (dev's DB approach supersedes)
This commit is contained in:
Mohamed Boudra
2026-04-14 03:05:45 +07:00
333 changed files with 13490 additions and 6317 deletions
+21 -38
View File
@@ -256,7 +256,7 @@ function resolveFormState(
if (!userModified.thinkingOptionId) {
const effectiveModelId = result.model.trim();
const preferredThinking = effectiveModelId
? providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? ""
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
: "";
if (initialThinkingOptionId.length > 0) {
@@ -329,11 +329,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
onlineServerIds = [],
} = options;
const {
preferences,
isLoading: isPreferencesLoading,
updatePreferences,
} = useFormPreferences();
const { preferences, isLoading: isPreferencesLoading, updatePreferences } = useFormPreferences();
const daemons = useHosts();
@@ -503,13 +499,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
]);
// User setters - mark fields as modified and persist to preferences
const setSelectedServerIdFromUser = useCallback(
(value: string | null) => {
setFormState((prev) => ({ ...prev, serverId: value }));
setUserModified((prev) => ({ ...prev, serverId: true }));
},
[],
);
const setSelectedServerIdFromUser = useCallback((value: string | null) => {
setFormState((prev) => ({ ...prev, serverId: value }));
setUserModified((prev) => ({ ...prev, serverId: true }));
}, []);
const setProviderFromUser = useCallback(
(provider: AgentProvider) => {
@@ -520,8 +513,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider];
const isValidModel = (m: string) =>
providerModels?.some((am) => am.id === m) ?? false;
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(providerModels);
const nextModelId =
@@ -533,10 +525,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const nextModeId =
providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)
? providerPrefs.mode
: providerDef?.defaultModeId ?? "";
: (providerDef?.defaultModeId ?? "");
const preferredThinking = nextModelId
? providerPrefs?.thinkingByModel?.[nextModelId]?.trim() ?? ""
? (providerPrefs?.thinkingByModel?.[nextModelId]?.trim() ?? "")
: "";
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels: providerModels,
@@ -591,13 +583,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
);
const setModeFromUser = useCallback(
(modeId: string) => {
setFormState((prev) => ({ ...prev, modeId }));
setUserModified((prev) => ({ ...prev, modeId: true }));
},
[],
);
const setModeFromUser = useCallback((modeId: string) => {
setFormState((prev) => ({ ...prev, modeId }));
setUserModified((prev) => ({ ...prev, modeId: true }));
}, []);
const setModelFromUser = useCallback(
(modelId: string) => {
@@ -620,25 +609,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[availableModels, userModified.thinkingOptionId],
);
const setThinkingOptionFromUser = useCallback(
(thinkingOptionId: string) => {
setFormState((prev) => ({ ...prev, thinkingOptionId }));
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
},
[],
);
const setThinkingOptionFromUser = useCallback((thinkingOptionId: string) => {
setFormState((prev) => ({ ...prev, thinkingOptionId }));
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
}, []);
const setWorkingDir = useCallback((value: string) => {
setFormState((prev) => ({ ...prev, workingDir: value }));
}, []);
const setWorkingDirFromUser = useCallback(
(value: string) => {
setFormState((prev) => ({ ...prev, workingDir: value }));
setUserModified((prev) => ({ ...prev, workingDir: true }));
},
[],
);
const setWorkingDirFromUser = useCallback((value: string) => {
setFormState((prev) => ({ ...prev, workingDir: value }));
setUserModified((prev) => ({ ...prev, workingDir: true }));
}, []);
const setSelectedServerId = useCallback((value: string | null) => {
setFormState((prev) => ({ ...prev, serverId: value }));
@@ -104,18 +104,8 @@ describe("useArchiveAgent", () => {
it("applies archived agent close results to session state and cached lists", async () => {
const queryClient = new QueryClient();
useSessionStore
.getState()
.initializeSession("server-a", {} as DaemonClient);
useSessionStore.getState().setAgents(
"server-a",
new Map([
[
"agent-1",
makeAgent(),
],
]),
);
useSessionStore.getState().initializeSession("server-a", {} as DaemonClient);
useSessionStore.getState().setAgents("server-a", new Map([["agent-1", makeAgent()]]));
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
});
@@ -130,7 +120,10 @@ describe("useArchiveAgent", () => {
});
expect(
useSessionStore.getState().sessions["server-a"]?.agents.get("agent-1")?.archivedAt?.toISOString(),
useSessionStore
.getState()
.sessions["server-a"]?.agents.get("agent-1")
?.archivedAt?.toISOString(),
).toBe("2026-04-01T04:00:00.000Z");
expect(queryClient.getQueryData(["sidebarAgentsList", "server-a"])).toEqual({
entries: [{ agent: { id: "agent-2" } }],
+1 -3
View File
@@ -216,9 +216,7 @@ interface ApplyArchivedAgentCloseResultsInput {
invalidateQueries?: boolean;
}
export function applyArchivedAgentCloseResults(
input: ApplyArchivedAgentCloseResultsInput,
): void {
export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseResultsInput): void {
if (input.results.length === 0) {
return;
}
+11 -4
View File
@@ -81,8 +81,7 @@ export function useBranchSwitcher({
if (!client) return;
const shouldStash = await confirmDialog({
title: "Uncommitted changes",
message:
"You have uncommitted changes. Stash them before switching branches?",
message: "You have uncommitted changes. Stash them before switching branches?",
confirmLabel: "Stash & Switch",
cancelLabel: "Cancel",
});
@@ -135,7 +134,8 @@ export function useBranchSwitcher({
if (targetStash) {
const shouldRestore = await confirmDialog({
title: "Restore stashed changes?",
message: "This branch has stashed changes from a previous session. Would you like to restore them?",
message:
"This branch has stashed changes from a previous session. Would you like to restore them?",
confirmLabel: "Restore",
cancelLabel: "Later",
});
@@ -157,7 +157,14 @@ export function useBranchSwitcher({
}
})();
},
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, stashAndSwitch, toast],
[
client,
currentBranchName,
invalidateStashAndCheckout,
normalizedWorkspaceId,
stashAndSwitch,
toast,
],
);
return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout };
@@ -54,11 +54,12 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null
return {
url: status.url,
number,
state: status.isMerged || status.state === "merged"
? "merged"
: status.state === "open"
? "open"
: "closed",
state:
status.isMerged || status.state === "merged"
? "merged"
: status.state === "open"
? "open"
: "closed",
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
@@ -0,0 +1,68 @@
import { useCallback, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@server/shared/messages";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
export function daemonConfigQueryKey(serverId: string | null) {
return ["daemon-config", serverId] as const;
}
interface UseDaemonConfigResult {
config: MutableDaemonConfig | null;
isLoading: boolean;
patchConfig: (patch: MutableDaemonConfigPatch) => Promise<MutableDaemonConfig | undefined>;
}
export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const queryKey = useMemo(() => daemonConfigQueryKey(serverId), [serverId]);
const configQuery = useQuery({
queryKey,
enabled: Boolean(serverId && client && isConnected),
staleTime: Infinity,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const result = await client.getDaemonConfig();
return result.config;
},
});
useEffect(() => {
if (!client || !isConnected || !serverId) {
return;
}
return client.on("status", (message) => {
if (message.type !== "status") {
return;
}
if (message.payload.status !== "daemon_config_changed") {
return;
}
queryClient.setQueryData(queryKey, message.payload.config as MutableDaemonConfig);
});
}, [client, isConnected, queryClient, queryKey, serverId]);
const patchConfig = useCallback(
async (patch: MutableDaemonConfigPatch) => {
if (!client) {
return undefined;
}
const result = await client.patchDaemonConfig(patch);
queryClient.setQueryData(queryKey, result.config);
return result.config;
},
[client, queryClient, queryKey],
);
return {
config: configQuery.data ?? null,
isLoading: configQuery.isLoading,
patchConfig,
};
}
@@ -1,9 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type {
AgentProvider,
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProvider, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
import {
@@ -100,16 +97,16 @@ export function useDraftAgentFeatures(input: {
}, [availableFeatures, localFeatureValues]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback((featureId: string, value: unknown) => {
setLocalFeatureValues((current) => {
if (Object.is(current[featureId], value)) {
return current;
}
const setFeatureValue = useCallback(
(featureId: string, value: unknown) => {
setLocalFeatureValues((current) => {
if (Object.is(current[featureId], value)) {
return current;
}
return { ...current, [featureId]: value };
});
void updatePreferences(
(current) =>
return { ...current, [featureId]: value };
});
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider,
@@ -119,10 +116,12 @@ export function useDraftAgentFeatures(input: {
},
},
}),
).catch((error) => {
console.warn("[useDraftAgentFeatures] persist feature preference failed", error);
});
}, [provider, updatePreferences]);
).catch((error) => {
console.warn("[useDraftAgentFeatures] persist feature preference failed", error);
});
},
[provider, updatePreferences],
);
return {
features,
@@ -31,12 +31,14 @@ const providerPreferencesSchema = z.object({
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
favoriteModels: z.array(
z.object({
provider: z.string(),
modelId: z.string(),
}),
).optional(),
favoriteModels: z
.array(
z.object({
provider: z.string(),
modelId: z.string(),
}),
)
.optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
@@ -148,8 +150,7 @@ export function useFormPreferences(): UseFormPreferencesReturn {
const prev =
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
DEFAULT_FORM_PREFERENCES;
const next =
typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
const next = typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
@@ -27,8 +27,7 @@ export function useKeyboardShortcutOverrides(): UseKeyboardShortcutOverridesRetu
const setOverride = useCallback(
async (bindingId: string, comboString: string) => {
const prev =
queryClient.getQueryData<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
const prev = queryClient.getQueryData<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
const next = { ...prev, [bindingId]: comboString };
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, next);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
@@ -38,8 +37,7 @@ export function useKeyboardShortcutOverrides(): UseKeyboardShortcutOverridesRetu
const removeOverride = useCallback(
async (bindingId: string) => {
const prev =
queryClient.getQueryData<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
const prev = queryClient.getQueryData<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
const { [bindingId]: _, ...next } = prev;
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, next);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
@@ -57,6 +57,7 @@ describe("use-settings", () => {
expect(result).toEqual({
theme: "light",
manageBuiltInDaemon: false,
sendBehavior: "interrupt",
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
@@ -0,0 +1,5 @@
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
export function useShowShortcutBadges(): boolean {
return useKeyboardShortcutsStore((state) => state.showShortcutBadges);
}
@@ -16,7 +16,7 @@ function item(key: string): OrderedItem {
}
function workspace(
input: Pick<WorkspaceDescriptor, "id" | "projectId" | "name" | "status" | "activityAt"> &
input: Pick<WorkspaceDescriptor, "id" | "projectId" | "name" | "status"> &
Partial<
Pick<
WorkspaceDescriptor,
@@ -39,7 +39,6 @@ function workspace(
workspaceKind: input.workspaceKind ?? "checkout",
name: input.name,
status: input.status,
activityAt: input.activityAt,
diffStat: null,
scripts: input.scripts ?? [],
};
@@ -130,7 +129,6 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
projectId: "project-1",
name: "feat/hard-cut",
status: "failed",
activityAt: new Date("2026-01-01T00:00:00.000Z"),
}),
];
@@ -156,7 +154,6 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
projectId: "project-1",
name: "main",
status: "running",
activityAt: new Date("2026-01-01T00:00:00.000Z"),
scripts: [runningScript, stoppedScript],
}),
],
@@ -168,21 +165,19 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
expect(projects[0]?.workspaces[0]?.hasRunningScripts).toBe(true);
});
it("preserves stored project order even when activity changes", () => {
it("preserves stored project order even when input order differs", () => {
const initialWorkspaces: WorkspaceDescriptor[] = [
workspace({
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"),
}),
];
@@ -199,19 +194,17 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
const updatedProjects = buildSidebarProjectsFromWorkspaces({
serverId: "srv",
workspaces: [
workspace({
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"),
}),
workspace({
id: "/repo/b",
projectId: "project-b",
name: "feat/b",
status: "running",
}),
],
projectOrder: seededOrder,
@@ -219,8 +212,8 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
});
expect(updatedProjects.map((project) => project.projectKey)).toEqual([
"project-b",
"project-a",
"project-b",
]);
});
@@ -233,21 +226,18 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
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"),
}),
workspace({
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"],
@@ -270,14 +260,12 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
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"),
}),
],
projectOrder: ["project-1"],
@@ -297,14 +285,12 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
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"),
}),
],
projectOrder: ["project-1"],
@@ -314,8 +300,8 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
});
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
"/repo/main",
"/repo/feature",
"/repo/main",
]);
});
});
@@ -4,10 +4,10 @@ import {
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} 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[] = [];
@@ -25,7 +25,6 @@ export interface SidebarWorkspaceEntry {
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
activityAt: Date | null;
statusBucket: SidebarStateBucket;
diffStat: { additions: number; deletions: number } | null;
scripts: WorkspaceDescriptor["scripts"];
@@ -40,7 +39,6 @@ export interface SidebarProjectEntry {
statusBucket: SidebarStateBucket;
activeCount: number;
totalWorkspaces: number;
latestActivityAt: Date | null;
workspaces: SidebarWorkspaceEntry[];
}
@@ -73,15 +71,6 @@ function compareWorkspaceBaseline(
left: SidebarWorkspaceEntry,
right: SidebarWorkspaceEntry,
): number {
if (left.activityAt && right.activityAt) {
const dateDelta = right.activityAt.getTime() - left.activityAt.getTime();
if (dateDelta !== 0) {
return dateDelta;
}
} else if (left.activityAt || right.activityAt) {
return left.activityAt ? -1 : 1;
}
const nameDelta = left.name.localeCompare(right.name, undefined, {
numeric: true,
sensitivity: "base",
@@ -97,15 +86,6 @@ function compareWorkspaceBaseline(
}
function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number {
if (left.latestActivityAt && right.latestActivityAt) {
const dateDelta = right.latestActivityAt.getTime() - left.latestActivityAt.getTime();
if (dateDelta !== 0) {
return dateDelta;
}
} else if (left.latestActivityAt || right.latestActivityAt) {
return left.latestActivityAt ? -1 : 1;
}
return left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
@@ -132,7 +112,6 @@ export function buildSidebarProjectsFromWorkspaces(input: {
statusBucket: "done",
activeCount: 0,
totalWorkspaces: 0,
latestActivityAt: null,
workspaces: [],
} satisfies SidebarProjectEntry);
@@ -145,7 +124,6 @@ export function buildSidebarProjectsFromWorkspaces(input: {
projectKind: workspace.projectKind,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
activityAt: workspace.activityAt,
statusBucket: workspace.status,
diffStat: workspace.diffStat,
scripts: workspace.scripts,
@@ -158,12 +136,6 @@ export function buildSidebarProjectsFromWorkspaces(input: {
project.activeCount += 1;
}
project.statusBucket = aggregateBucket(project.statusBucket, workspace.status);
if (
!project.latestActivityAt ||
(workspace.activityAt && workspace.activityAt.getTime() > project.latestActivityAt.getTime())
) {
project.latestActivityAt = workspace.activityAt;
}
byProject.set(workspace.projectId, project);
}
@@ -262,23 +234,9 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
return `${serverId.trim()}::${projectKey.trim()}`;
}
function toWorkspaceDescriptor(payload: {
id: string;
projectId: string;
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
status: WorkspaceDescriptor["status"];
activityAt: string | null;
scripts?: WorkspaceDescriptorPayload["scripts"];
}): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor({
...payload,
scripts: payload.scripts ?? [],
});
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor(payload);
}
export function useSidebarWorkspacesList(options?: {