Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions src/browser/components/ChatInput/useCreationWorkspace.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { APIClient } from "@/browser/contexts/API";
import type { DraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
import {
getDraftAutoGenerateKey,
getDraftNameKey,
getDraftNameGeneratingKey,
getDraftNameErrorKey,
getDraftRuntimeKey,
getInputKey,
getInputImagesKey,
getModelKey,
Expand Down Expand Up @@ -489,13 +494,35 @@ describe("useCreationWorkspace", () => {
expect(readPersistedStateCalls).toContainEqual([projectModeKey, null]);

const modeKey = getModeKey(TEST_WORKSPACE_ID);
const pendingScopeId = getPendingScopeId(TEST_PROJECT_PATH);
const pendingInputKey = getInputKey(pendingScopeId);
const pendingImagesKey = getInputImagesKey(pendingScopeId);
expect(updatePersistedStateCalls).toContainEqual([modeKey, "plan"]);
// Thinking is workspace-scoped, but this test doesn't set a project-scoped thinking preference.
expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]);
expect(updatePersistedStateCalls).toContainEqual([pendingImagesKey, undefined]);

// Draft state is cleared via updatePersistedState so subscribers update immediately
const pendingScopeId = getPendingScopeId(TEST_PROJECT_PATH);
expect(updatePersistedStateCalls).toContainEqual([getInputKey(pendingScopeId), undefined]);
expect(updatePersistedStateCalls).toContainEqual([
getInputImagesKey(pendingScopeId),
undefined,
]);
expect(updatePersistedStateCalls).toContainEqual([
getDraftRuntimeKey(TEST_PROJECT_PATH),
undefined,
]);
expect(updatePersistedStateCalls).toContainEqual([
getDraftNameKey(TEST_PROJECT_PATH),
undefined,
]);
expect(updatePersistedStateCalls).toContainEqual([
getDraftAutoGenerateKey(TEST_PROJECT_PATH),
undefined,
]);
expect(updatePersistedStateCalls).toContainEqual([
getDraftNameGeneratingKey(TEST_PROJECT_PATH),
undefined,
]);
expect(updatePersistedStateCalls).toContainEqual([
getDraftNameErrorKey(TEST_PROJECT_PATH),
undefined,
]);
});

test("handleSend surfaces backend errors and resets state", async () => {
Expand Down
13 changes: 6 additions & 7 deletions src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@ import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSett
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
import {
getInputKey,
getInputImagesKey,
getCreationDraftKeys,
getModelKey,
getModeKey,
getThinkingLevelKey,
getPendingScopeId,
getProjectScopeId,
} from "@/common/constants/storage";
import type { Toast } from "@/browser/components/ChatInputToast";
Expand Down Expand Up @@ -121,6 +119,7 @@ export function useCreationWorkspace({

// Workspace name generation with debounce
const workspaceNameState = useWorkspaceName({
projectPath,
message,
debounceMs: 500,
fallbackModel: fallbackModel ?? undefined,
Expand Down Expand Up @@ -218,10 +217,10 @@ export function useCreationWorkspace({
});
// Sync preferences immediately (before switching)
syncCreationPreferences(projectPath, metadata.id);
if (projectPath) {
const pendingScopeId = getPendingScopeId(projectPath);
updatePersistedState(getInputKey(pendingScopeId), "");
updatePersistedState(getInputImagesKey(pendingScopeId), undefined);
// Clear draft state now that workspace is created (use updatePersistedState
// so subscribers using usePersistedState({ listener: true }) update immediately)
for (const key of getCreationDraftKeys(projectPath)) {
updatePersistedState(key, undefined);
}

// Switch to the workspace IMMEDIATELY after creation to exit splash faster.
Expand Down
30 changes: 17 additions & 13 deletions src/browser/hooks/useDraftWorkspaceSettings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { usePersistedState } from "./usePersistedState";
import { useThinkingLevel } from "./useThinkingLevel";
import { useMode } from "@/browser/contexts/ModeContext";
Expand All @@ -9,6 +9,7 @@ import {
buildRuntimeString,
} from "@/common/types/runtime";
import {
getDraftRuntimeKey,
getModelKey,
getRuntimeKey,
getTrunkBranchKey,
Expand Down Expand Up @@ -52,7 +53,7 @@ export function useDraftWorkspaceSettings(
recommendedTrunk: string | null
): {
settings: DraftWorkspaceSettings;
/** Set the currently selected runtime mode (does not persist) */
/** Set the currently selected runtime mode (persists as part of draft) */
setRuntimeMode: (mode: RuntimeMode) => void;
/** Set the default runtime mode for this project (persists via checkbox) */
setDefaultRuntimeMode: (mode: RuntimeMode) => void;
Expand Down Expand Up @@ -81,14 +82,17 @@ export function useDraftWorkspaceSettings(
// Parse default runtime string into mode (worktree when undefined)
const { mode: defaultRuntimeMode } = parseRuntimeModeAndHost(defaultRuntimeString);

// Currently selected runtime mode for this session (initialized from default)
// This allows user to select a different runtime without changing the default
const [selectedRuntimeMode, setSelectedRuntimeMode] = useState<RuntimeMode>(defaultRuntimeMode);
// Draft runtime selection - persisted so it survives navigation away and back.
// Uses undefined to mean "use default", allowing the default to be respected
// until the user explicitly selects something different.
const [draftRuntimeMode, setDraftRuntimeMode] = usePersistedState<RuntimeMode | undefined>(
getDraftRuntimeKey(projectPath),
undefined,
{ listener: true }
);

// Sync selected mode when default changes (e.g., from checkbox or project switch)
useEffect(() => {
setSelectedRuntimeMode(defaultRuntimeMode);
}, [defaultRuntimeMode]);
// Effective selected mode: draft selection if set, otherwise fall back to default
const selectedRuntimeMode = draftRuntimeMode ?? defaultRuntimeMode;

// Project-scoped trunk branch preference (persisted per project)
const [trunkBranch, setTrunkBranch] = usePersistedState<string>(
Expand All @@ -113,17 +117,17 @@ export function useDraftWorkspaceSettings(
}
}, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]);

// Setter for selected runtime mode (changes current selection, does not persist)
// Setter for selected runtime mode (persists as part of draft)
const setRuntimeMode = (newMode: RuntimeMode) => {
setSelectedRuntimeMode(newMode);
setDraftRuntimeMode(newMode);
};

// Setter for default runtime mode (persists via checkbox in tooltip)
const setDefaultRuntimeMode = (newMode: RuntimeMode) => {
const newRuntimeString = buildRuntimeString(newMode, lastSshHost);
setDefaultRuntimeString(newRuntimeString);
// Also update selection to match new default
setSelectedRuntimeMode(newMode);
// Also update draft selection to match new default
setDraftRuntimeMode(newMode);
};

// Setter for SSH host (persisted separately so it's remembered across mode switches)
Expand Down
79 changes: 53 additions & 26 deletions src/browser/hooks/useWorkspaceName.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
import { useRef, useCallback, useEffect, useMemo } from "react";
import { useAPI } from "@/browser/contexts/API";
import { useGateway, formatAsGatewayModel } from "@/browser/hooks/useGatewayModels";
import { getKnownModel } from "@/common/constants/knownModels";
import {
getDraftNameKey,
getDraftAutoGenerateKey,
getDraftNameGeneratingKey,
getDraftNameErrorKey,
} from "@/common/constants/storage";
import { usePersistedState } from "@/browser/hooks/usePersistedState";

export interface UseWorkspaceNameOptions {
/** Project path for persisting draft state */
projectPath: string;
/** The user's message to generate a name for */
message: string;
/** Debounce delay in milliseconds (default: 500) */
Expand Down Expand Up @@ -54,7 +63,7 @@ export interface UseWorkspaceNameReturn extends WorkspaceNameState {
const PREFERRED_NAME_MODELS = [getKnownModel("HAIKU").id, getKnownModel("GPT_MINI").id];

export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspaceNameReturn {
const { message, debounceMs = 500, fallbackModel } = options;
const { projectPath, message, debounceMs = 500, fallbackModel } = options;
const { api } = useAPI();
// Use global gateway availability (configured + enabled), not per-model toggles.
// Name generation uses utility models (Haiku, GPT-Mini) that users don't explicitly
Expand All @@ -70,13 +79,27 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
return models;
}, [gatewayConfigured]);

// Generated identity (name + title) from AI
const [generatedIdentity, setGeneratedIdentity] = useState<WorkspaceIdentity | null>(null);
// Manual name (user-editable during creation)
const [manualName, setManualName] = useState("");
const [autoGenerate, setAutoGenerate] = useState(true);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Generated identity (name + title) from AI - not persisted since it's regenerated from message
const [generatedIdentity, setGeneratedIdentity] = usePersistedState<WorkspaceIdentity | null>(
getDraftNameKey(projectPath),
null,
{ listener: true }
);
// Manual name persisted together with generated identity in the same key
// When autoGenerate is off, we store the manual name in generatedIdentity.name
const [autoGenerate, setAutoGenerate] = usePersistedState<boolean>(
getDraftAutoGenerateKey(projectPath),
true,
{ listener: true }
);
const [isGenerating, setIsGenerating] = usePersistedState<boolean>(
getDraftNameGeneratingKey(projectPath),
false
);
const [error, setError] = usePersistedState<string | null>(
getDraftNameErrorKey(projectPath),
null
);

// Track the message that was used for the last successful generation
const lastGeneratedForRef = useRef<string>("");
Expand All @@ -93,9 +116,9 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
requestId: number;
} | null>(null);

// Name shown in CreationControls UI: generated name when auto, manual when not
const name = autoGenerate ? (generatedIdentity?.name ?? "") : manualName;
// Title is only shown when auto-generation is enabled (manual mode doesn't have generated title)
// Name shown in CreationControls UI: always from generatedIdentity (which stores both auto and manual)
const name = generatedIdentity?.name ?? "";
// Title is only available when auto-generation is enabled
const title = autoGenerate ? (generatedIdentity?.title ?? null) : null;

// Cancel any pending generation and resolve waiters with null
Expand All @@ -114,7 +137,7 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
generationPromiseRef.current = null;
setIsGenerating(false);
}
}, []);
}, [setIsGenerating]);

const generateIdentity = useCallback(
async (forMessage: string): Promise<WorkspaceIdentity | null> => {
Expand Down Expand Up @@ -181,7 +204,7 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
}
}
},
[api, preferredModels, fallbackModel]
[api, preferredModels, fallbackModel, setError, setGeneratedIdentity, setIsGenerating]
);

// Debounced generation effect
Expand Down Expand Up @@ -230,27 +253,31 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
// Switching to auto: reset so debounced generation will trigger
lastGeneratedForRef.current = "";
setError(null);
} else {
// Switching to manual: copy generated name as starting point for editing
if (generatedIdentity?.name) {
setManualName(generatedIdentity.name);
}
}
// When switching to manual, the current name is already in generatedIdentity
setAutoGenerate(enabled);
},
[generatedIdentity]
[setAutoGenerate, setError]
);

const setNameManual = useCallback((newName: string) => {
setManualName(newName);
// Clear error when user starts typing
setError(null);
}, []);
const setNameManual = useCallback(
(newName: string) => {
// Store manual name in generatedIdentity (preserving any existing title)
setGeneratedIdentity((prev) => ({
name: newName,
title: prev?.title ?? newName,
}));
// Clear error when user starts typing
setError(null);
},
[setGeneratedIdentity, setError]
);

const waitForGeneration = useCallback(async (): Promise<WorkspaceIdentity | null> => {
// If auto-generate is off, user has provided a manual name
// Use that name directly with a generated title from the message
if (!autoGenerate) {
const manualName = generatedIdentity?.name ?? "";
if (!manualName.trim()) {
setError("Please enter a workspace name");
return null;
Expand Down Expand Up @@ -297,7 +324,7 @@ export function useWorkspaceName(options: UseWorkspaceNameOptions): UseWorkspace
}

return null;
}, [autoGenerate, manualName, generatedIdentity, message, generateIdentity]);
}, [autoGenerate, generatedIdentity, message, generateIdentity, setError]);

return useMemo(
() => ({
Expand Down
67 changes: 67 additions & 0 deletions src/common/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,54 @@ export function getRuntimeKey(projectPath: string): string {
return `runtime:${projectPath}`;
}

/**
* Get the localStorage key for the draft runtime selection for a project
* Stores the currently selected runtime during workspace creation (may differ from default).
* Cleared when workspace is created.
* Format: "draftRuntime:{projectPath}"
*/
export function getDraftRuntimeKey(projectPath: string): string {
return `draftRuntime:${projectPath}`;
}

/**
* Get the localStorage key for the draft workspace name for a project.
* Stores the manually entered or auto-generated name during workspace creation.
* Cleared when workspace is created.
* Format: "draftName:{projectPath}"
*/
export function getDraftNameKey(projectPath: string): string {
return `draftName:${projectPath}`;
}

/**
* Get the localStorage key for the draft auto-generate toggle for a project.
* Stores whether auto-generation is enabled during workspace creation.
* Cleared when workspace is created.
* Format: "draftAutoGenerate:{projectPath}"
*/
export function getDraftAutoGenerateKey(projectPath: string): string {
return `draftAutoGenerate:${projectPath}`;
}

/**
* Get the localStorage key for the draft name generation loading state.
* Ephemeral UI state cleared when workspace is created.
* Format: "draftNameGenerating:{projectPath}"
*/
export function getDraftNameGeneratingKey(projectPath: string): string {
return `draftNameGenerating:${projectPath}`;
}

/**
* Get the localStorage key for the draft name generation error.
* Ephemeral UI state cleared when workspace is created.
* Format: "draftNameError:{projectPath}"
*/
export function getDraftNameErrorKey(projectPath: string): string {
return `draftNameError:${projectPath}`;
}

/**
* Get the localStorage key for trunk branch preference for a project
* Stores the last used trunk branch when creating a workspace
Expand Down Expand Up @@ -385,3 +433,22 @@ export function migrateWorkspaceStorage(oldWorkspaceId: string, newWorkspaceId:
copyWorkspaceStorage(oldWorkspaceId, newWorkspaceId);
deleteWorkspaceStorage(oldWorkspaceId);
}

/**
* Get all storage keys that should be cleared when workspace creation completes.
* Returns both project-scoped draft keys and pending-scoped input keys.
*/
export function getCreationDraftKeys(projectPath: string): string[] {
const pendingScopeId = getPendingScopeId(projectPath);
return [
// Project-scoped draft keys
getDraftRuntimeKey(projectPath),
getDraftNameKey(projectPath),
getDraftAutoGenerateKey(projectPath),
getDraftNameGeneratingKey(projectPath),
getDraftNameErrorKey(projectPath),
// Pending-scoped input keys
getInputKey(pendingScopeId),
getInputImagesKey(pendingScopeId),
];
}