feat: add sqlite storage and terminal ui

This commit is contained in:
Mohamed Boudra
2026-03-29 23:20:18 +07:00
parent e5fd61f131
commit 7d02e24d84
189 changed files with 17868 additions and 4663 deletions
@@ -66,7 +66,7 @@ type UseAgentFormStateOptions = {
onlineServerIds?: string[];
};
type UseAgentFormStateResult = {
export type UseAgentFormStateResult = {
selectedServerId: string | null;
setSelectedServerId: (value: string | null) => void;
setSelectedServerIdFromUser: (value: string | null) => void;
@@ -2,11 +2,10 @@ import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-initialization";
describe("useAgentInitialization timeline request policy", () => {
it("uses canonical tail bootstrap when history has not synced yet", () => {
it("uses committed tail bootstrap when history has not synced yet", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
seq: 42,
},
hasAuthoritativeHistory: false,
@@ -15,11 +14,10 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical tail bootstrap when cursor is missing", () => {
it("uses committed tail bootstrap when cursor is missing", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: null,
@@ -29,15 +27,13 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical catch-up after the current cursor once history is synced", () => {
it("uses committed catch-up after the current cursor once history is synced", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
seq: 42,
},
hasAuthoritativeHistory: true,
@@ -45,9 +41,8 @@ describe("useAgentInitialization timeline request policy", () => {
}),
).toEqual({
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
cursor: { seq: 42 },
limit: 0,
projection: "canonical",
});
});
@@ -61,7 +56,6 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 0,
projection: "canonical",
});
});
@@ -60,7 +60,7 @@ export function useAgentInitialization({
const hasAuthoritativeHistory =
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
const timelineRequest = deriveInitialTimelineRequest({
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
cursor: cursor ? { seq: cursor.endSeq } : null,
hasAuthoritativeHistory,
initialTimelineLimit,
});
@@ -107,7 +107,6 @@ export function useAgentInitialization({
await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: initialTimelineLimit,
projection: "canonical",
});
} catch (error) {
setAgentInitializing(agentId, false);
@@ -0,0 +1,270 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStore } from "@/stores/draft-store";
import type { AttachmentMetadata } from "@/attachments/types";
const { asyncStorage } = vi.hoisted(() => ({
asyncStorage: new Map<string, string>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: async (key: string) => asyncStorage.get(key) ?? null,
setItem: async (key: string, value: string) => {
asyncStorage.set(key, value);
},
removeItem: async (key: string) => {
asyncStorage.delete(key);
},
},
}));
vi.mock("@/attachments/service", () => ({
garbageCollectAttachments: async () => undefined,
}));
vi.mock("./use-agent-form-state", () => ({
useAgentFormState: () => ({
selectedServerId: "host-1",
setSelectedServerId: () => undefined,
setSelectedServerIdFromUser: () => undefined,
selectedProvider: "codex",
setProviderFromUser: () => undefined,
selectedMode: "auto",
setModeFromUser: () => undefined,
selectedModel: "",
setModelFromUser: () => undefined,
selectedThinkingOptionId: "",
setThinkingOptionFromUser: () => undefined,
workingDir: "/repo",
setWorkingDir: () => undefined,
setWorkingDirFromUser: () => undefined,
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
providerDefinitionMap: new Map(),
agentDefinition: undefined,
modeOptions: [{ id: "auto", label: "Auto" }],
availableModels: [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
},
],
allProviderModels: new Map([
[
"codex",
[
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
},
],
],
]),
isAllModelsLoading: false,
availableThinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
isModelLoading: false,
modelError: null,
refreshProviderModels: () => undefined,
setProviderAndModelFromUser: () => undefined,
workingDirIsEmpty: false,
persistFormPreferences: async () => undefined,
}),
}));
let useAgentInputDraft: typeof import("./use-agent-input-draft").useAgentInputDraft;
beforeAll(async () => {
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "window", {
value: {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
},
},
configurable: true,
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
({ useAgentInputDraft } = await import("./use-agent-input-draft"));
});
describe("useAgentInputDraft live contract", () => {
beforeEach(() => {
asyncStorage.clear();
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
url: "http://localhost",
});
Object.defineProperty(globalThis, "document", {
value: dom.window.document,
configurable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
configurable: true,
});
useDraftStore.setState({ drafts: {}, createModalDraft: null });
});
it("hydrates persisted text and images and returns draft-mode composer state for a caller-provided key", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {
id: "attachment-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/1",
createdAt: 1,
fileName: "image.png",
byteSize: 128,
};
function getLatest(): ReturnType<typeof useAgentInputDraft> {
if (!latest) {
throw new Error("Expected hook result");
}
return latest;
}
function Probe({ draftKey }: { draftKey: string }) {
latest = useAgentInputDraft({
draftKey,
composer: {
initialServerId: "host-1",
initialValues: { workingDir: "/repo" },
isVisible: true,
onlineServerIds: ["host-1"],
lockedWorkingDir: "/repo",
},
});
return null;
}
const container = document.getElementById("root");
if (!container) {
throw new Error("Missing root container");
}
let root: Root | null = createRoot(container);
await act(async () => {
root!.render(<Probe draftKey="draft:setup" />);
});
expect(getLatest().composerState?.statusControls.selectedProvider).toBe("codex");
expect(getLatest().composerState?.commandDraftConfig).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
});
await act(async () => {
getLatest().setText("hello world");
getLatest().setImages([image]);
});
await act(async () => {
root!.unmount();
});
root = createRoot(container);
await act(async () => {
root.render(<Probe draftKey="draft:setup" />);
});
expect(getLatest().text).toBe("hello world");
expect(getLatest().images).toEqual([image]);
});
it("clears drafts with sent and abandoned lifecycle tombstones", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const sentImage: AttachmentMetadata = {
id: "attachment-sent",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/sent",
createdAt: 2,
};
function getLatest(): ReturnType<typeof useAgentInputDraft> {
if (!latest) {
throw new Error("Expected hook result");
}
return latest;
}
function Probe() {
latest = useAgentInputDraft({ draftKey: "draft:lifecycle" });
return null;
}
const container = document.getElementById("root");
if (!container) {
throw new Error("Missing root container");
}
const root = createRoot(container);
await act(async () => {
root.render(<Probe />);
});
await act(async () => {
getLatest().setText("queued message");
getLatest().setImages([sentImage]);
});
await act(async () => {
getLatest().clear("sent");
});
expect(getLatest().text).toBe("");
expect(getLatest().images).toEqual([]);
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
lifecycle: "sent",
input: { text: "", images: [] },
});
await act(async () => {
getLatest().setText("draft again");
});
await act(async () => {
getLatest().clear("abandoned");
});
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
lifecycle: "abandoned",
input: { text: "", images: [] },
});
});
});
@@ -0,0 +1,149 @@
import { beforeAll, describe, expect, it } from "vitest";
let __private__: typeof import("./use-agent-input-draft").__private__;
beforeAll(async () => {
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "window", {
value: {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
},
},
configurable: true,
});
({ __private__ } = await import("./use-agent-input-draft"));
});
describe("useAgentInputDraft", () => {
describe("__private__.resolveDraftKey", () => {
it("returns an object draft key string unchanged", () => {
expect(
__private__.resolveDraftKey({
draftKey: "draft:key",
selectedServerId: "host-1",
}),
).toBe("draft:key");
});
it("resolves a computed draft key from the selected server", () => {
expect(
__private__.resolveDraftKey({
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
selectedServerId: "host-1",
}),
).toBe("draft:host-1");
});
});
describe("__private__.resolveEffectiveComposerModelId", () => {
const models = [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
},
{
provider: "codex",
id: "gpt-5.4-mini",
label: "gpt-5.4-mini",
},
];
it("prefers the selected model when present", () => {
expect(
__private__.resolveEffectiveComposerModelId({
selectedModel: "gpt-5.4-mini",
availableModels: models,
}),
).toBe("gpt-5.4-mini");
});
it("falls back to the provider default model", () => {
expect(
__private__.resolveEffectiveComposerModelId({
selectedModel: "",
availableModels: models,
}),
).toBe("gpt-5.4");
});
});
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
const models = [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
},
];
it("prefers the selected thinking option when present", () => {
expect(
__private__.resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "medium",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
).toBe("medium");
});
it("falls back to the model default thinking option", () => {
expect(
__private__.resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
).toBe("high");
});
});
describe("__private__.buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
expect(
__private__.buildDraftComposerCommandConfig({
provider: "codex",
cwd: " ",
modeOptions: [],
selectedMode: "",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toBeUndefined();
});
it("builds the draft command config from derived composer state", () => {
expect(
__private__.buildDraftComposerCommandConfig({
provider: "codex",
cwd: "/repo",
modeOptions: [{ id: "auto", label: "Auto" }],
selectedMode: "auto",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
});
});
});
});
+225 -2
View File
@@ -1,9 +1,44 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import {
useAgentFormState,
type CreateAgentInitialValues,
type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state";
import { useDraftStore } from "@/stores/draft-store";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]);
type AgentInputDraftComposerOptions = {
initialServerId: string | null;
initialValues?: CreateAgentInitialValues;
isVisible?: boolean;
onlineServerIds?: string[];
lockedWorkingDir?: string;
};
type DraftKeyContext = {
selectedServerId: string | null;
};
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
type UseAgentInputDraftInput = {
draftKey: DraftKeyInput;
composer?: AgentInputDraftComposerOptions;
};
type DraftComposerState = UseAgentFormStateResult & {
workingDir: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
statusControls: DraftAgentStatusBarProps;
commandDraftConfig: DraftCommandConfig | undefined;
};
interface AgentInputDraft {
text: string;
setText: (text: string) => void;
@@ -11,6 +46,7 @@ interface AgentInputDraft {
setImages: (updater: ImageUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
isHydrated: boolean;
composerState: DraftComposerState | null;
}
function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean {
@@ -36,7 +72,108 @@ function areImagesEqual(input: {
});
}
export function useAgentInputDraft(draftKey: string): AgentInputDraft {
function resolveDraftKey(input: {
draftKey: DraftKeyInput;
selectedServerId: string | null;
}): string {
if (typeof input.draftKey === "function") {
return input.draftKey({ selectedServerId: input.selectedServerId });
}
return input.draftKey;
}
function resolveEffectiveComposerModelId(input: {
selectedModel: string;
availableModels: AgentModelDefinition[];
}): string {
const selectedModel = input.selectedModel.trim();
if (selectedModel) {
return selectedModel;
}
return input.availableModels.find((model) => model.isDefault)?.id ?? input.availableModels[0]?.id ?? "";
}
function resolveEffectiveComposerThinkingOptionId(input: {
selectedThinkingOptionId: string;
availableModels: AgentModelDefinition[];
effectiveModelId: string;
}): string {
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
if (selectedThinkingOptionId) {
return selectedThinkingOptionId;
}
const selectedModelDefinition =
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
function buildDraftComposerCommandConfig(input: {
provider: DraftAgentStatusBarProps["selectedProvider"];
cwd: string;
modeOptions: DraftAgentStatusBarProps["modeOptions"];
selectedMode: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
}): DraftCommandConfig | undefined {
const cwd = input.cwd.trim();
if (!cwd) {
return undefined;
}
return {
provider: input.provider,
cwd,
...(input.modeOptions.length > 0 && input.selectedMode !== "" ? { modeId: input.selectedMode } : {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
};
}
function buildDraftStatusControls(input: {
formState: UseAgentFormStateResult;
}): DraftAgentStatusBarProps {
const { formState } = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
onSelectProvider: formState.setProviderFromUser,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
onSelectMode: formState.setModeFromUser,
models: formState.availableModels,
selectedModel: formState.selectedModel,
onSelectModel: formState.setModelFromUser,
isModelLoading: formState.isModelLoading,
allProviderModels: formState.allProviderModels,
isAllModelsLoading: formState.isAllModelsLoading,
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
thinkingOptions: formState.availableThinkingOptions,
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
};
}
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
const composerOptions = input.composer ?? null;
const formState = useAgentFormState({
initialServerId: composerOptions?.initialServerId ?? null,
initialValues: composerOptions?.initialValues,
isVisible: composerOptions?.isVisible ?? false,
isCreateFlow: true,
onlineServerIds: composerOptions?.onlineServerIds ?? [],
});
const draftKey = useMemo(
() =>
resolveDraftKey({
draftKey: input.draftKey,
selectedServerId: formState.selectedServerId,
}),
[formState.selectedServerId, input.draftKey],
);
const [text, setText] = useState("");
const [images, setImagesState] = useState<AttachmentMetadata[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
@@ -148,6 +285,83 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
});
}, [draftKey, images, text]);
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
useEffect(() => {
if (!composerOptions || !lockedWorkingDir) {
return;
}
if (formState.workingDir.trim() === lockedWorkingDir) {
return;
}
formState.setWorkingDir(lockedWorkingDir);
}, [composerOptions, formState, lockedWorkingDir]);
const effectiveModelId = useMemo(
() =>
resolveEffectiveComposerModelId({
selectedModel: formState.selectedModel,
availableModels: formState.availableModels,
}),
[formState.availableModels, formState.selectedModel],
);
const effectiveThinkingOptionId = useMemo(
() =>
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: formState.selectedThinkingOptionId,
availableModels: formState.availableModels,
effectiveModelId,
}),
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
);
const workingDir = lockedWorkingDir || formState.workingDir;
const commandDraftConfig = useMemo(
() =>
composerOptions
? buildDraftComposerCommandConfig({
provider: formState.selectedProvider,
cwd: workingDir,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
effectiveModelId,
effectiveThinkingOptionId,
})
: undefined,
[
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
workingDir,
formState.modeOptions,
formState.selectedMode,
formState.selectedProvider,
],
);
const composerState = useMemo<DraftComposerState | null>(() => {
if (!composerOptions) {
return null;
}
return {
...formState,
workingDir,
effectiveModelId,
effectiveThinkingOptionId,
statusControls: buildDraftStatusControls({ formState }),
commandDraftConfig,
};
}, [
commandDraftConfig,
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
formState,
workingDir,
]);
return {
text,
setText,
@@ -155,5 +369,14 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
setImages,
clear,
isHydrated,
composerState,
};
}
export const __private__ = {
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
buildDraftComposerCommandConfig,
buildDraftStatusControls,
};
@@ -17,6 +17,7 @@ function createAgent(id: string): Agent {
serverId: "server-1",
id,
provider: "claude",
terminal: false,
status: "running",
createdAt: now,
updatedAt: now,
@@ -29,6 +30,7 @@ function createAgent(id: string): Agent {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],
@@ -5,6 +5,14 @@ export interface AgentScreenAgent {
id: string;
status: "initializing" | "idle" | "running" | "error" | "closed";
cwd: string;
lastError?: string | null;
terminalExit?: {
command: string;
message: string;
exitCode: number | null;
signal: number | null;
outputLines: string[];
} | null;
projectPlacement?: {
checkout?: {
cwd?: string;
@@ -65,6 +65,7 @@ export function useAggregatedAgents(options?: {
serverId,
serverLabel,
title: agent.title ?? null,
terminal: agent.terminal,
status: agent.status,
lastActivityAt: agent.lastActivityAt,
cwd: agent.cwd,
@@ -8,6 +8,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
serverId: "server-1",
id: input?.id ?? "agent-1",
provider: input?.provider ?? "codex",
terminal: input?.terminal ?? false,
status: input?.status ?? "idle",
createdAt: input?.createdAt ?? timestamp,
updatedAt: input?.updatedAt ?? timestamp,
@@ -20,6 +21,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: input?.currentModeId ?? null,
availableModes: input?.availableModes ?? [],
@@ -19,6 +19,7 @@ function toAggregatedAgent(params: {
serverId: params.serverId,
serverLabel: params.serverLabel,
title: source.title ?? null,
terminal: source.terminal,
status: source.status,
lastActivityAt: source.lastActivityAt,
cwd: source.cwd,
@@ -72,6 +72,7 @@ type CreateRequestContext = {
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string;
getPendingServerId: () => string | null;
allowEmptyText?: boolean;
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
onCreateStart?: () => void;
@@ -84,6 +85,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
draftId,
getPendingServerId,
allowEmptyText = false,
validateBeforeSubmit,
onBeforeSubmit,
onCreateStart,
@@ -110,6 +112,10 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
return EMPTY_STREAM_ITEMS;
}
if (!machine.attempt.text && (!machine.attempt.images || machine.attempt.images.length === 0)) {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: "user_message",
@@ -139,7 +145,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const trimmedPrompt = text.trim();
if (!trimmedPrompt) {
if (!trimmedPrompt && !allowEmptyText) {
const error = new Error("Initial prompt is required");
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
@@ -215,6 +221,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
setPendingCreateAttempt,
updatePendingAgentId,
validateBeforeSubmit,
allowEmptyText,
],
);
@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key);
}),
},
};
});
const { replaceRoute } = vi.hoisted(() => ({
replaceRoute: vi.fn(),
}));
vi.mock("expo-router", () => ({
router: {
replace: replaceRoute,
},
}));
import { openProjectDirectly } from "@/hooks/use-open-project";
import { useSessionStore } from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/project";
describe("openProjectDirectly", () => {
beforeEach(() => {
replaceRoute.mockReset();
useSessionStore.setState({
sessions: {},
});
useSessionStore.getState().initializeSession(SERVER_ID, {} as never);
useWorkspaceLayoutStore.setState({
layoutByWorkspace: {},
splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
});
vi.restoreAllMocks();
});
it("opens the workspace directly, marks workspaces hydrated, and seeds a launcher tab", async () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"11111111-1111-1111-1111-111111111111",
);
const result = await openProjectDirectly({
serverId: SERVER_ID,
projectPath: WORKSPACE_ID,
isConnected: true,
client: {
openProject: vi.fn(async () => ({
requestId: "request-1",
error: null,
workspace: {
id: 1,
projectId: 1,
projectDisplayName: "project",
projectRootPath: WORKSPACE_ID,
projectKind: "git" as const,
workspaceKind: "checkout" as const,
name: "project",
status: "done" as const,
activityAt: null,
diffStat: null,
},
})),
},
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
replaceRoute,
});
expect(result).toBe(true);
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
expect(Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? [])).toEqual([
expect.objectContaining({ id: "1", projectId: "1", projectRootPath: WORKSPACE_ID }),
]);
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: "1",
});
expect(workspaceKey).toBeTruthy();
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey as string];
expect(layout.root.kind).toBe("pane");
const tabs = collectAllTabs(layout.root);
expect(tabs).toHaveLength(1);
expect(tabs[0]?.target).toEqual({
kind: "launcher",
launcherId: "11111111-1111-1111-1111-111111111111",
});
expect(replaceRoute).toHaveBeenCalledWith("/h/server-1/workspace/MQ");
});
it("does not navigate or seed tabs when openProject fails", async () => {
const result = await openProjectDirectly({
serverId: SERVER_ID,
projectPath: WORKSPACE_ID,
isConnected: true,
client: {
openProject: vi.fn(async () => ({
requestId: "request-2",
error: "Failed to open project",
workspace: null,
})),
},
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
replaceRoute,
});
expect(result).toBe(false);
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(false);
expect(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.size).toBe(0);
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace).toEqual({});
expect(replaceRoute).not.toHaveBeenCalled();
});
});
+63 -32
View File
@@ -1,45 +1,76 @@
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 { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { router } from "expo-router";
import type { DaemonClient } from "@server/client/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
interface OpenProjectDirectlyInput {
serverId: string;
projectPath: string;
isConnected: boolean;
client: Pick<DaemonClient, "openProject"> | null;
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
openLauncherTab: (workspaceKey: string) => string | null;
replaceRoute: (route: string) => void;
}
export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise<boolean> {
const normalizedServerId = input.serverId.trim();
const trimmedPath = input.projectPath.trim();
if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) {
return false;
}
const payload = await input.client.openProject(trimmedPath);
if (payload.error || !payload.workspace) {
return false;
}
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
input.mergeWorkspaces(normalizedServerId, [workspace]);
input.setHasHydratedWorkspaces(normalizedServerId, true);
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: workspace.id,
});
if (!workspaceKey) {
return false;
}
input.openLauncherTab(workspaceKey);
input.replaceRoute(buildHostWorkspaceRoute(normalizedServerId, workspace.id));
return true;
}
export function useOpenProject(serverId: string | null): (path: string) => Promise<boolean> {
const normalizedServerId = serverId?.trim() ?? "";
const toast = useToast();
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
async (path: string) => {
const trimmedPath = path.trim();
if (!trimmedPath || !client || !normalizedServerId) {
return false;
}
try {
const payload = await client.openProject(trimmedPath);
if (payload.error || !payload.workspace) {
throw new Error(payload.error || "Failed to open project");
}
mergeWorkspaces(normalizedServerId, [normalizeWorkspaceDescriptor(payload.workspace)]);
setHasHydratedWorkspaces(normalizedServerId, true);
router.replace(
prepareWorkspaceTab({
serverId: normalizedServerId,
workspaceId: payload.workspace.id,
target: { kind: "draft", draftId: "new" },
}) as any,
);
return true;
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to open project");
return false;
}
return openProjectDirectly({
serverId: normalizedServerId,
projectPath: path,
isConnected,
client,
mergeWorkspaces,
setHasHydratedWorkspaces,
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
replaceRoute: (route) => {
router.replace(route as any);
},
});
},
[client, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces, toast],
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces],
);
}
@@ -29,7 +29,7 @@ function workspace(
projectDisplayName: input.projectDisplayName ?? input.projectId,
projectRootPath: input.projectRootPath ?? input.id,
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
workspaceKind: input.workspaceKind ?? "checkout",
name: input.name,
status: input.status,
activityAt: input.activityAt,
@@ -15,6 +15,7 @@ export interface SidebarWorkspaceEntry {
workspaceKey: string;
serverId: string;
workspaceId: string;
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
activityAt: Date | null;
@@ -130,6 +131,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
workspaceKey: `${input.serverId}:${workspace.id}`,
serverId: input.serverId,
workspaceId: workspace.id,
projectKind: workspace.projectKind,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
activityAt: workspace.activityAt,
@@ -248,8 +250,8 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
}
function toWorkspaceDescriptor(payload: {
id: string;
projectId: string;
id: number;
projectId: number;
projectDisplayName: string;
projectRootPath: string;
projectKind: WorkspaceDescriptor["projectKind"];