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
26 changes: 9 additions & 17 deletions src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { RuntimeConfig } from "@/common/types/runtime";
import type { MuxDeepLinkPayload } from "@/common/types/deepLink";
import {
deleteWorkspaceStorage,
DRAFT_SCOPED_FIELDS,
getAgentIdKey,
getDraftScopeId,
getInputAttachmentsKey,
Expand All @@ -29,7 +30,6 @@ import {
getTerminalTitlesKey,
getThinkingLevelKey,
getWorkspaceAISettingsByAgentKey,
getWorkspaceNameStateKey,
migrateWorkspaceStorage,
AGENT_AI_DEFAULTS_KEY,
DEFAULT_MODEL_KEY,
Expand Down Expand Up @@ -353,22 +353,14 @@ function createWorkspaceDraftId(): string {
function isDraftEmpty(projectPath: string, draftId: string): boolean {
const scopeId = getDraftScopeId(projectPath, draftId);

// Check for input text
const inputText = readPersistedState<string>(getInputKey(scopeId), "");
if (inputText.trim().length > 0) {
return false;
}

// Check for attachments
const attachments = readPersistedState<unknown[]>(getInputAttachmentsKey(scopeId), []);
if (Array.isArray(attachments) && attachments.length > 0) {
return false;
}

// Check for workspace name state (auto-generated or manual)
const nameState = readPersistedState<unknown>(getWorkspaceNameStateKey(scopeId), null);
if (nameState !== null) {
return false;
// Derive emptiness from the shared draft-field registry so new draft-scoped fields
// automatically participate in reuse detection without editing this function.
for (const field of DRAFT_SCOPED_FIELDS) {
if (!field.keepsDraftAlive) continue;
const value = readPersistedState<unknown>(field.key(scopeId), null);
if (field.keepsDraftAlive(value)) {
return false;
}
}

return true;
Expand Down
9 changes: 7 additions & 2 deletions src/browser/features/ChatInput/useCreationWorkspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getPendingScopeId,
getPendingWorkspaceSendErrorKey,
getProjectScopeId,
getSelectedRuntimeKey,
getThinkingLevelKey,
} from "@/common/constants/storage";
import type { WorkspaceChatMessage } from "@/common/orpc/types";
Expand Down Expand Up @@ -767,7 +768,7 @@ describe("useCreationWorkspace", () => {
const pendingInputKey = getInputKey(pendingScopeId);
const pendingImagesKey = getInputAttachmentsKey(pendingScopeId);
// Thinking is workspace-scoped, but this test doesn't set a project-scoped thinking preference.
expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]);
expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, undefined]);
expect(updatePersistedStateCalls).toContainEqual([pendingImagesKey, undefined]);
});

Expand Down Expand Up @@ -1128,8 +1129,12 @@ describe("useCreationWorkspace", () => {
const pendingInputKey = getInputKey(pendingScopeId);
const pendingImagesKey = getInputAttachmentsKey(pendingScopeId);
const pendingErrorKey = getPendingWorkspaceSendErrorKey(TEST_WORKSPACE_ID);
expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]);
const pendingRuntimeKey = getSelectedRuntimeKey(pendingScopeId);
expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, undefined]);
expect(updatePersistedStateCalls).toContainEqual([pendingImagesKey, undefined]);
// Regression guard: the pending runtime selection must also be cleared after creation so a
// returning blank composer doesn't restore a stale one-off runtime choice.
expect(updatePersistedStateCalls).toContainEqual([pendingRuntimeKey, undefined]);
expect(updatePersistedStateCalls).toContainEqual([pendingErrorKey, sendError]);
});
test("onWorkspaceCreated is called before sendMessage resolves (no blocking)", async () => {
Expand Down
27 changes: 16 additions & 11 deletions src/browser/features/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
import {
DRAFT_SCOPED_FIELDS,
getAgentIdKey,
getInputKey,
getInputAttachmentsKey,
getModelKey,
getNotifyOnResponseAutoEnableKey,
getNotifyOnResponseKey,
Expand Down Expand Up @@ -257,6 +256,13 @@ export function useCreationWorkspace({
const [runtimeAvailabilityState, setRuntimeAvailabilityState] =
useState<RuntimeAvailabilityState>({ status: "loading" });

const creationDraftScopeId =
projectPath.trim().length > 0
? typeof draftId === "string" && draftId.trim().length > 0
? getDraftScopeId(projectPath, draftId)
: getPendingScopeId(projectPath)
: null;
Comment thread
ammar-agent marked this conversation as resolved.

// Centralized draft workspace settings with automatic persistence
const {
settings,
Expand All @@ -265,16 +271,11 @@ export function useCreationWorkspace({
setSelectedRuntime,
setDefaultRuntimeChoice,
setTrunkBranch,
} = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk);
} = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk, creationDraftScopeId);

// Persist draft workspace name generation state per draft (so multiple drafts don't share a
// single auto-naming/manual-name state).
const workspaceNameScopeId =
projectPath.trim().length > 0
? typeof draftId === "string" && draftId.trim().length > 0
? getDraftScopeId(projectPath, draftId)
: getPendingScopeId(projectPath)
: null;
const workspaceNameScopeId = creationDraftScopeId;

// Project scope ID for reading send options at send time
const projectScopeId = getProjectScopeId(projectPath);
Expand Down Expand Up @@ -567,8 +568,12 @@ export function useCreationWorkspace({
return;
}

updatePersistedState(getInputKey(pendingScopeId), "");
updatePersistedState(getInputAttachmentsKey(pendingScopeId), undefined);
// Clear every draft-scoped field (input, attachments, name, runtime, ...) so a
// returning blank composer for this pending scope starts from defaults rather than
// restoring stale draft state. Registry-driven so new draft fields clear automatically.
for (const field of DRAFT_SCOPED_FIELDS) {
updatePersistedState(field.key(pendingScopeId), undefined);
}
};

// Sync preferences before switching (keeps workspace settings consistent).
Expand Down
83 changes: 83 additions & 0 deletions src/browser/hooks/useDraftWorkspaceSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
getModelKey,
getProjectScopeId,
getRuntimeKey,
getSelectedRuntimeKey,
} from "@/common/constants/storage";
import { CODER_RUNTIME_PLACEHOLDER } from "@/common/types/runtime";
import type * as DraftWorkspaceSettingsModule from "./useDraftWorkspaceSettings";
Expand Down Expand Up @@ -269,6 +270,88 @@ describe("useDraftWorkspaceSettings", () => {
});
});

test("restores selected runtime from draft state", async () => {
const projectPath = "/tmp/project";
const draftScopeId = "__draft__/tmp/project/draft-a";

const wrapper = createWrapper(projectPath);

const first = renderHook(
() => useDraftWorkspaceSettings(projectPath, ["main"], "main", draftScopeId),
{ wrapper }
);

act(() => {
first.result.current.setSelectedRuntime({
mode: "docker",
image: "node:20",
shareCredentials: true,
});
});

await waitFor(() => {
expect(first.result.current.settings.selectedRuntime).toEqual({
mode: "docker",
image: "node:20",
shareCredentials: true,
});
});

first.unmount();

const second = renderHook(
() => useDraftWorkspaceSettings(projectPath, ["main"], "main", draftScopeId),
{ wrapper }
);

await waitFor(() => {
expect(second.result.current.settings.selectedRuntime).toEqual({
mode: "docker",
image: "node:20",
shareCredentials: true,
});
});

expect(readPersistedState<unknown>(getSelectedRuntimeKey(draftScopeId), null)).toEqual({
mode: "docker",
image: "node:20",
shareCredentials: true,
});
});

test("keeps selected runtime scoped to each draft", async () => {
const projectPath = "/tmp/project";
const draftScopeA = "__draft__/tmp/project/draft-a";
const draftScopeB = "__draft__/tmp/project/draft-b";

updatePersistedState(getSelectedRuntimeKey(draftScopeA), {
mode: "ssh",
host: "dev@host",
});
updatePersistedState(getSelectedRuntimeKey(draftScopeB), {
mode: "local",
});

const wrapper = createWrapper(projectPath);

const first = renderHook(
() => useDraftWorkspaceSettings(projectPath, ["main"], "main", draftScopeA),
{ wrapper }
);
const second = renderHook(
() => useDraftWorkspaceSettings(projectPath, ["main"], "main", draftScopeB),
{ wrapper }
);

await waitFor(() => {
expect(first.result.current.settings.selectedRuntime).toEqual({
mode: "ssh",
host: "dev@host",
});
expect(second.result.current.settings.selectedRuntime).toEqual({ mode: "local" });
});
});

test("seeds SSH host from the remembered value when switching modes", async () => {
const projectPath = "/tmp/project";

Expand Down
44 changes: 39 additions & 5 deletions src/browser/hooks/useDraftWorkspaceSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ParsedRuntime,
type CoderWorkspaceConfig,
buildRuntimeString,
ParsedRuntimeSchema,
RUNTIME_MODE,
CODER_RUNTIME_PLACEHOLDER,
} from "@/common/types/runtime";
Expand All @@ -24,6 +25,7 @@ import {
getAgentIdKey,
getModelKey,
getRuntimeKey,
getSelectedRuntimeKey,
getTrunkBranchKey,
getLastRuntimeConfigKey,
getProjectScopeId,
Expand Down Expand Up @@ -206,12 +208,14 @@ const buildRuntimeFromChoice = (choice: RuntimeChoice): ParsedRuntime => {
* @param projectPath - Path to the project (used as key prefix for localStorage)
* @param branches - Available branches (used to set default trunk branch)
* @param recommendedTrunk - Backend-recommended trunk branch
* @param selectedRuntimeScopeId - Optional draft/pending scope that owns the creation runtime selection
* @returns Settings object and setters
*/
export function useDraftWorkspaceSettings(
projectPath: string,
branches: string[],
recommendedTrunk: string | null
recommendedTrunk: string | null,
selectedRuntimeScopeId?: string | null
): {
settings: DraftWorkspaceSettings;
/** Restores prior Coder selections when re-entering Coder mode. */
Expand Down Expand Up @@ -474,15 +478,36 @@ export function useDraftWorkspaceSettings(
lastDevcontainerShareCredentials
);

// Currently selected runtime for this session (initialized from default)
const selectedRuntimeStorageKey = selectedRuntimeScopeId
? getSelectedRuntimeKey(selectedRuntimeScopeId)
: null;
const [persistedSelectedRuntime, setPersistedSelectedRuntime] = usePersistedState<unknown>(
selectedRuntimeStorageKey ?? "__unused_selected_runtime__",
null,
{ listener: true }
);
// Validate/normalize the persisted draft runtime via the schema (single source of truth),
// discarding corrupt values so a bad localStorage entry self-heals to the default.
const parsedPersistedRuntime = ParsedRuntimeSchema.safeParse(persistedSelectedRuntime);
const persistedRuntimeSelection = parsedPersistedRuntime.success
? parsedPersistedRuntime.data
: null;
const hasPersistedRuntimeSelectionRef = useRef(persistedRuntimeSelection !== null);

// Currently selected runtime for this session (initialized from draft state, then default)
// Uses discriminated union: SSH has host, Docker has image
const [selectedRuntime, setSelectedRuntimeState] = useState<ParsedRuntime>(() => defaultRuntime);
const [selectedRuntime, setSelectedRuntimeState] = useState<ParsedRuntime>(
() => persistedRuntimeSelection ?? defaultRuntime
);

// Project changes remount ChatInput (key includes projectPath), so this effect only handles
// live Settings updates to the default runtime while staying on the same project.
const appliedDefaultRuntimeChoiceRef = useRef<RuntimeChoice>(settingsDefaultRuntime);
useEffect(() => {
if (appliedDefaultRuntimeChoiceRef.current === settingsDefaultRuntime) {
if (
appliedDefaultRuntimeChoiceRef.current === settingsDefaultRuntime ||
hasPersistedRuntimeSelectionRef.current
) {
return;
}

Expand Down Expand Up @@ -524,6 +549,13 @@ export function useDraftWorkspaceSettings(
}
}, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]);

const persistRuntimeSelection = (runtime: ParsedRuntime) => {
hasPersistedRuntimeSelectionRef.current = true;
if (selectedRuntimeStorageKey) {
setPersistedSelectedRuntime(runtime);
}
};

// Setter for selected runtime (also persists host/image/coder for future mode switches)
const setSelectedRuntime = (runtime: ParsedRuntime) => {
const mergedRuntime = mergeRememberedRuntimeConfig(
Expand All @@ -533,6 +565,7 @@ export function useDraftWorkspaceSettings(
);

setSelectedRuntimeState(mergedRuntime);
persistRuntimeSelection(mergedRuntime);

// Persist host/image/coder so they're remembered when switching modes.
// Avoid wiping the remembered value when the UI switches modes with an empty field.
Expand Down Expand Up @@ -595,8 +628,9 @@ export function useDraftWorkspaceSettings(
);
const newRuntimeString = buildRuntimeString(newRuntime);
setDefaultRuntimeString(newRuntimeString);
// Also update selection to match new default
// Also update this draft's selection so navigating away and back keeps the user's choice.
setSelectedRuntimeState(newRuntime);
persistRuntimeSelection(newRuntime);
};

// Helper to get runtime string for IPC calls
Expand Down
32 changes: 32 additions & 0 deletions src/common/constants/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
copyWorkspaceStorage,
deleteWorkspaceStorage,
DRAFT_SCOPED_FIELDS,
getDraftScopeId,
getInputAttachmentsKey,
getSelectedRuntimeKey,
normalizeTranscriptDensity,
} from "@/common/constants/storage";

Expand Down Expand Up @@ -96,4 +98,34 @@ describe("storage workspace-scoped keys", () => {

expect(localStorage.getItem(key)).toBeNull();
});

// Guards that draft-scoped registry fields automatically participate in fork-copy and
// delete-cleanup. If a new draft field is added without persistence wiring, or the runtime
// selection regresses to project/global scope, these expectations break.
test("copyWorkspaceStorage copies the draft-scoped runtime selection", () => {
const source = "ws-source";
const dest = "ws-dest";
const value = JSON.stringify({ mode: "docker", image: "node:20" });

localStorage.setItem(getSelectedRuntimeKey(source), value);
copyWorkspaceStorage(source, dest);

expect(localStorage.getItem(getSelectedRuntimeKey(dest))).toBe(value);
});

test("deleteWorkspaceStorage removes every persistent draft-scoped field", () => {
const workspaceId = "ws-draft-delete";

for (const field of DRAFT_SCOPED_FIELDS) {
if (field.persistence !== "persistent") continue;
localStorage.setItem(field.key(workspaceId), "value");
}

deleteWorkspaceStorage(workspaceId);

for (const field of DRAFT_SCOPED_FIELDS) {
if (field.persistence !== "persistent") continue;
expect(localStorage.getItem(field.key(workspaceId))).toBeNull();
}
});
});
Loading
Loading