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
3 changes: 3 additions & 0 deletions src/engines/ChatPanel/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ const ResolvedChatView: React.FC<ResolvedChatViewProps> = memo(
currentSession,
root: conversationTargetBinding?.root ?? null,
selectedTarget: conversationTargetBinding?.target ?? null,
importedCloudWorkspace:
conversationTargetBinding?.importedCloudWorkspace ??
(currentSession?.importedFrom ? "pending" : null),
onSurfaceSubmit: handleMainComposerSubmitOverride,
});
const handleConversationSubmitWithTailFollow = useCallback(
Expand Down
3 changes: 3 additions & 0 deletions src/engines/ChatPanel/SideChat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ export const SideChatSessionBody: React.FC<SideChatSessionBodyProps> = ({
currentSession: session,
root: conversationTargetBinding?.root ?? null,
selectedTarget: conversationTargetBinding?.target ?? null,
importedCloudWorkspace:
conversationTargetBinding?.importedCloudWorkspace ??
(session?.importedFrom ? "pending" : null),
onSurfaceSubmit: handleSurfaceSubmit,
});

Expand Down
2 changes: 2 additions & 0 deletions src/engines/ChatPanel/conversationTargetSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface ConversationTargetBinding {
selection: LastModelSelection | null;
runtimeSelection: AgentSelection | null;
target: LocalConversationTarget | null;
/** Only imported Cloud replays need a local checkout before first dispatch. */
importedCloudWorkspace: "pending" | "matched" | "missing" | null;
readiness: ConversationTargetReadiness;
nativeCliTargets: readonly CliAgentType[];
applyRuntimePick: (selection: AgentSelection) => boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ import type {
ConversationRootLocator,
LocalConversationTarget,
} from "@src/engines/SessionCore/conversations/conversationTypes";
import { cloudWorkspaceRequiredDialogAtom } from "@src/features/Org2Cloud/SessionConversation/cloudWorkspaceRequiredDialogAtom";
import type { Session } from "@src/store/session";

import { SubmitValidationError } from "../useInputArea/types";
import {
SubmitHandledByDialogError,
SubmitValidationError,
} from "../useInputArea/types";
import {
buildCanonicalConversationDispatch,
canonicalConversationTargetOrThrow,
Expand Down Expand Up @@ -58,6 +63,9 @@ interface RouterHarnessProps {
isDirectAgentOrgMember: boolean;
onSurfaceSubmit: () => Promise<boolean>;
selectedTarget: LocalConversationTarget | null;
conversationRoot: ConversationRootLocator | null;
currentSession?: Session;
importedCloudWorkspace: "pending" | "matched" | "missing" | null;
onReady: (router: ConversationSubmitRouter) => void;
}

Expand All @@ -66,45 +74,60 @@ function RouterHarness({
isDirectAgentOrgMember,
onSurfaceSubmit,
selectedTarget: runtimeTarget,
conversationRoot,
currentSession,
importedCloudWorkspace,
onReady,
}: RouterHarnessProps): null {
const router = useConversationSubmitRouter({
sessionId,
isDirectAgentOrgMember,
currentSession: undefined,
root,
currentSession,
root: conversationRoot,
selectedTarget: runtimeTarget,
importedCloudWorkspace,
onSurfaceSubmit,
});
useEffect(() => onReady(router), [onReady, router]);
return null;
}

const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = [];
let latestStore = createStore();

function renderRouter(params: {
sessionId?: string;
isDirectAgentOrgMember?: boolean;
onSurfaceSubmit?: () => Promise<boolean>;
selectedTarget?: LocalConversationTarget | null;
conversationRoot?: ConversationRootLocator | null;
currentSession?: Session;
importedCloudWorkspace?: "pending" | "matched" | "missing" | null;
}) {
let router: ConversationSubmitRouter | undefined;
const container = document.createElement("div");
document.body.appendChild(container);
const reactRoot = createRoot(container);
latestStore = createStore();
mountedRoots.push({ container, root: reactRoot });
act(() => {
reactRoot.render(
createElement(
Provider,
{ store: createStore() },
{ store: latestStore },
createElement(RouterHarness, {
sessionId: params.sessionId ?? "root-session",
isDirectAgentOrgMember: params.isDirectAgentOrgMember ?? false,
selectedTarget:
params.selectedTarget === undefined
? selectedTarget
: params.selectedTarget,
conversationRoot:
params.conversationRoot === undefined
? root
: params.conversationRoot,
currentSession: params.currentSession,
importedCloudWorkspace: params.importedCloudWorkspace ?? null,
onSurfaceSubmit:
params.onSurfaceSubmit ?? vi.fn().mockResolvedValue(false),
onReady: (value) => {
Expand Down Expand Up @@ -212,6 +235,54 @@ describe("useConversationSubmitRouter", () => {
});
});

it("prompts to open a workspace before admitting an imported Cloud Agent turn", async () => {
const router = renderRouter({
currentSession: { importedFrom: { orgId: "org-1" } } as Session,
conversationRoot: {
authority: "org2-cloud",
authorityScope: ["org-1"],
conversationId: "source-1",
},
importedCloudWorkspace: "missing",
selectedTarget: { ...selectedTarget, workspaceRepoPath: null },
});

await expect(
router.submit({ displayText: "continue" })
).rejects.toBeInstanceOf(SubmitHandledByDialogError);
expect(mocks.submitUserIntent).not.toHaveBeenCalled();
expect(latestStore.get(cloudWorkspaceRequiredDialogAtom)).toBe(true);
});

it("waits for local workspace resolution and never starts a bare-directory Agent", async () => {
const router = renderRouter({
currentSession: { importedFrom: { orgId: "org-1" } } as Session,
conversationRoot: null,
importedCloudWorkspace: "pending",
selectedTarget: null,
});

await expect(
router.submit({ displayText: "continue" })
).rejects.toBeInstanceOf(SubmitValidationError);
expect(mocks.submitUserIntent).not.toHaveBeenCalled();
expect(latestStore.get(cloudWorkspaceRequiredDialogAtom)).toBe(false);
});

it("keeps human Team Chat available without a matching Agent workspace", async () => {
const router = renderRouter({
currentSession: { importedFrom: { orgId: "org-1" } } as Session,
importedCloudWorkspace: "missing",
onSurfaceSubmit: vi.fn().mockResolvedValue(true),
});

await expect(router.submit({ displayText: "human comment" })).resolves.toBe(
true
);
expect(mocks.submitUserIntent).not.toHaveBeenCalled();
expect(latestStore.get(cloudWorkspaceRequiredDialogAtom)).toBe(false);
});

it.each(["claude_code", "codex"])(
"preserves a dynamic %s source through submission and retry",
async (cliAgentType) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@ import type {
QueuedConversationDispatch,
QueuedConversationDispatchResolution,
} from "@src/engines/SessionCore/conversations/queuedConversationContract";
import { cloudWorkspaceRequiredDialogAtom } from "@src/features/Org2Cloud/SessionConversation/cloudWorkspaceRequiredDialogAtom";
import {
type Org2CloudAuthState,
org2CloudAuthAtom,
org2CloudAuthIdentityKey,
} from "@src/features/Org2Cloud/org2CloudAuthAtom";
import { useCloudSessionDownloadProgressEntry } from "@src/features/Org2Cloud/useCloudSessionDownloadSurface";
import i18n from "@src/i18n";
import type { Session } from "@src/store/session";

import { isImportedSessionSubmitBlocked } from "../importedSessionSubmitReadiness";
import {
SubmitHandledByDialogError,
type SubmitOverrideInput,
SubmitValidationError,
} from "../useInputArea/types";
Expand All @@ -33,6 +36,7 @@ interface UseConversationSubmitRouterOptions {
currentSession: Session | undefined;
root: ConversationRootLocator | null;
selectedTarget: LocalConversationTarget | null;
importedCloudWorkspace: "pending" | "matched" | "missing" | null;
/** Existing human/team-chat routing always gets first refusal. */
onSurfaceSubmit: (input: SubmitOverrideInput) => Promise<boolean>;
}
Expand Down Expand Up @@ -115,6 +119,7 @@ export function useConversationSubmitRouter({
currentSession,
root,
selectedTarget,
importedCloudWorkspace,
onSurfaceSubmit,
}: UseConversationSubmitRouterOptions): ConversationSubmitRouter {
const store = useStore();
Expand All @@ -137,6 +142,24 @@ export function useConversationSubmitRouter({
);
}

if (currentSession?.importedFrom) {
if (
importedCloudWorkspace === "pending" ||
!importedCloudWorkspace ||
!root
) {
throw new SubmitValidationError(
i18n.t("sessions:conversation.workspaceResolving")
);
}
if (importedCloudWorkspace === "missing") {
store.set(cloudWorkspaceRequiredDialogAtom, true);
throw new SubmitHandledByDialogError(
i18n.t("sessions:conversation.workspaceRequiredBody")
);
}
}

const target = canonicalConversationTargetOrThrow(root, selectedTarget);
if (!root || !target) return false;

Expand Down Expand Up @@ -176,6 +199,7 @@ export function useConversationSubmitRouter({
[
currentSession,
downloadProgress,
importedCloudWorkspace,
root,
selectedTarget,
sessionId,
Expand Down
10 changes: 10 additions & 0 deletions src/engines/ChatPanel/hooks/useConversationTargetBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,14 @@ export function useConversationTargetBinding(
nativeCliTargets,
});

const importedCloudWorkspace = session?.importedFrom
? cloudSource.workspacePending || !cloudSource.source
? "pending"
: cloudSource.source.workspaceRepoPath
? "matched"
: "missing"
: null;

return useMemo(
() =>
source
Expand All @@ -222,6 +230,7 @@ export function useConversationTargetBinding(
selection: marketSelection,
runtimeSelection,
target,
importedCloudWorkspace,
readiness,
nativeCliTargets,
applyRuntimePick,
Expand All @@ -235,6 +244,7 @@ export function useConversationTargetBinding(
executionTargetHydrationFailed,
executionTargetHydrationLoading,
executionTargets,
importedCloudWorkspace,
nativeCliTargets,
marketSelection,
readiness,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import { modelSelectorAtom } from "@src/store/ui/modelSelectorAtom";
import { type SmokeRoot, createSmokeRoot } from "@src/test/reactSmokeHarness";

import type { InputAreaRefs } from "../types";
import { SubmitRetainedDeliveryError } from "../types";
import {
SubmitHandledByDialogError,
SubmitRetainedDeliveryError,
} from "../types";
import {
type UseSubmitMessageOptions,
useSubmitMessage,
Expand Down Expand Up @@ -606,6 +609,27 @@ describe("useSubmitMessage composer boundary", () => {
);
});

it("restores the draft without an error toast when a dialog explains the block", async () => {
const editorHarness = createEditor("open the matching workspace first");
const options = optionsFor(editorHarness, {
onSubmitOverride: vi
.fn()
.mockRejectedValue(
new SubmitHandledByDialogError("A local checkout is required")
),
});
await mount(options);

await act(async () => {
await latestSubmit!();
});

expect(editorHarness.readText()).toBe("open the matching workspace first");
expect(options.refs.setHasContent).toHaveBeenLastCalledWith(true);
expect(mocks.messageError).not.toHaveBeenCalled();
expect(options.handleSessChatSubmit).not.toHaveBeenCalled();
});

it("restores each composer payload independently when one restore fails", async () => {
const editorHarness = createEditor("restore everything possible");
editorHarness.editor.setContent = vi.fn(() => {
Expand Down
8 changes: 8 additions & 0 deletions src/engines/ChatPanel/hooks/useInputArea/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ export class SubmitValidationError extends Error {
}
}

/** A blocking dialog already explains this rejected send to the user. */
export class SubmitHandledByDialogError extends SubmitValidationError {
constructor(message: string) {
super(message);
this.name = "SubmitHandledByDialogError";
}
}

/**
* The transport rejected the send after its owning surface had already
* retained the optimistic message as a visible failed row. Callers must not
Expand Down
13 changes: 9 additions & 4 deletions src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ import {
} from "./submissionSnapshot";
import type { UseSubmitMessageOptions } from "./submitMessageOptions";
import type { CiteCodeSnapshot, SubmitMessageOptions } from "./types";
import { SubmitRetainedDeliveryError } from "./types";
import {
SubmitHandledByDialogError,
SubmitRetainedDeliveryError,
} from "./types";
import { useSubmitAttemptLock } from "./useSubmitAttemptLock";

// Re-exported for existing consumers/tests; the implementation moved to the
Expand Down Expand Up @@ -366,9 +369,11 @@ export function useSubmitMessage({
}
}

const reason = err instanceof Error ? err.message : String(err);
const baseMsg = t("chat.failedToSendMessage");
Message.error(reason ? `${baseMsg}: ${reason}` : baseMsg);
if (!(err instanceof SubmitHandledByDialogError)) {
const reason = err instanceof Error ? err.message : String(err);
const baseMsg = t("chat.failedToSendMessage");
Message.error(reason ? `${baseMsg}: ${reason}` : baseMsg);
}
}
} finally {
submitInFlightKeyRef.current = null;
Expand Down
Loading
Loading