Skip to content
Merged
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
1 change: 0 additions & 1 deletion src/app/AppShell.navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import type { Message } from "@/shared/types/messages";
import type { GitState } from "@/shared/types/git";
import { setMultiWorkspaceEnabled } from "@/features/workspaces/multiWorkspacePreference";
import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents";
import { useVoiceConversationStore } from "@/features/voice-conversation/stores/voiceConversationStore";
import { SHORTCUT_PREFERENCES_STORAGE_KEY } from "@/features/shortcuts/lib/shortcutRegistry";
import { useShortcutsDialogStore } from "@/features/shortcuts/stores/shortcutsDialogStore";
import { useProjectStore } from "@/features/projects/stores/projectStore";
Expand Down
2 changes: 0 additions & 2 deletions src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1245,13 +1245,11 @@ export function AppShell({
null,
);
const hydratingPinnedSessionIdsRef = useRef<Set<string>>(new Set());

const hydratePinnedChatSessions = useCallback(
async (sessionIds: string[]) => {
const uniqueSessionIds = [...new Set(sessionIds)].filter(Boolean);
const sessionStore = useChatSessionStore.getState();
const sessionsToLoad: string[] = [];

for (const sessionId of uniqueSessionIds) {
if (hydratingPinnedSessionIdsRef.current.has(sessionId)) {
continue;
Expand Down
1 change: 1 addition & 0 deletions src/app/ui/AppShellContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ export function AppShellContent({
onStartProjectChat={onStartProjectChat}
onCreatePersona={onCreatePersona}
onCreateProject={onCreateProject}
onWorkspaceNameRequest={onWorkspaceNameRequest}
onOpenAutomation={openHomeAutomation}
onOpenSkills={() => onNavigateSkills(null)}
onOpenAutomations={openHomeAutomations}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useConversationComposerBinding } from "./ConversationComposerCapability";
import type { ChatSession } from "@/features/chat/stores/chatSessionStore";

const mocks = vi.hoisted(() => ({
handleSend: vi.fn(() => true),
sendDeferredAnyway: vi.fn(() => true),
steerDraftMessage: vi.fn(() => true),
steerQueuedMessage: vi.fn(() => true),
securityPending: false,
}));

vi.mock("@/features/chat/hooks/useChatSessionController", () => ({
useChatSessionController: () => ({
handleSend: mocks.handleSend,
sendDeferredAnyway: mocks.sendDeferredAnyway,
steerDraftMessage: mocks.steerDraftMessage,
steerQueuedMessage: mocks.steerQueuedMessage,
}),
}));

vi.mock("@/features/chat/stores/chatSessionStore", () => ({
useChatSessionStore: (
selector: (state: { sessions: ChatSession[] }) => unknown,
) => selector({ sessions: [] }),
}));

vi.mock("@/features/chat/stores/sessionWindowStore", () => ({
useSessionWindowStore: (
selector: (state: { isOpenInWindow: () => boolean }) => unknown,
) => selector({ isOpenInWindow: () => false }),
}));

vi.mock("@/features/security/stores/securityConfirmationStore", () => ({
useSecurityConfirmationStore: (
selector: (state: {
pendingBySessionId: Record<string, unknown[]>;
}) => unknown,
) =>
selector({
pendingBySessionId: mocks.securityPending ? { "session-1": [{}] } : {},
}),
}));

vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) =>
key === "toolbar.agentBuilderPrepareFailed"
? "Agent preparation failed"
: "Session creation failed",
}),
}));

const ordinarySession = {
id: "session-1",
title: "Chat",
createdAt: "2026-08-23T00:00:00.000Z",
updatedAt: "2026-08-23T00:00:00.000Z",
messageCount: 1,
} satisfies ChatSession;

function useSurfaceBinding(
surface: "chat" | "canvas",
session: ChatSession,
readOnlyReason?: string,
) {
return useConversationComposerBinding({
target: {
kind: "existingSession",
sessionId: session.id,
sessionSnapshot: session,
readOnlyReason,
readOnlyWhenOpenInAnotherWindow: surface === "canvas",
},
});
}

describe("existing-session composer cross-surface parity", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.securityPending = false;
});

it.each([
["ordinary", ordinarySession, undefined, false],
[
"session creation failure",
{
...ordinarySession,
creationState: "failed" as const,
creationError: "Creation failed",
},
undefined,
true,
],
[
"execution target failure",
{
...ordinarySession,
intent: "build-agent" as const,
targetAgentDraftState: "failed" as const,
},
undefined,
true,
],
["read-only", ordinarySession, "Read only", true],
])("gives ChatView and canvas the same %s admission and ordinary/queue rejection", (_label, session, readOnlyReason, blocked) => {
const chat = renderHook(() =>
useSurfaceBinding("chat", session, readOnlyReason),
).result;
const canvas = renderHook(() =>
useSurfaceBinding("canvas", session, readOnlyReason),
).result;

expect(canvas.current.admissionBlocked).toBe(chat.current.admissionBlocked);
expect(canvas.current.admissionBlockingReason).toBe(
chat.current.admissionBlockingReason,
);
expect(canvas.current.onSend("ordinary")).toBe(!blocked);
expect(chat.current.onSend("ordinary")).toBe(!blocked);
expect(canvas.current.onSendQueue).toBe(
blocked ? undefined : mocks.sendDeferredAnyway,
);
expect(chat.current.onSendQueue).toBe(
blocked ? undefined : mocks.sendDeferredAnyway,
);
});

it("blocks ordinary, queue/deferred, MCP, and voice entry points while security confirmation is pending", () => {
mocks.securityPending = true;
const chat = renderHook(() => useSurfaceBinding("chat", ordinarySession))
.result.current;
const canvas = renderHook(() =>
useSurfaceBinding("canvas", ordinarySession),
).result.current;

for (const binding of [chat, canvas]) {
expect(binding.target).toMatchObject({
admission: {
blocked: true,
securityConfirmationPending: true,
},
});
expect(binding.onSend("ordinary")).toBe(false);
// MCP and voice consume this same admitted onSend handler in full chat;
// canvas has neither extra path, so it cannot bypass the rejection.
expect(binding.onSend("mcp")).toBe(false);
expect(binding.onSend("voice")).toBe(false);
expect(binding.onSendQueue).toBeUndefined();
}
expect(mocks.handleSend).not.toHaveBeenCalled();
expect(mocks.sendDeferredAnyway).not.toHaveBeenCalled();
});
});
Loading