diff --git a/src/engines/ChatPanel/ChatView.tsx b/src/engines/ChatPanel/ChatView.tsx index 199cab23bb..d86d29feec 100644 --- a/src/engines/ChatPanel/ChatView.tsx +++ b/src/engines/ChatPanel/ChatView.tsx @@ -349,6 +349,9 @@ const ResolvedChatView: React.FC = memo( currentSession, root: conversationTargetBinding?.root ?? null, selectedTarget: conversationTargetBinding?.target ?? null, + importedCloudWorkspace: + conversationTargetBinding?.importedCloudWorkspace ?? + (currentSession?.importedFrom ? "pending" : null), onSurfaceSubmit: handleMainComposerSubmitOverride, }); const handleConversationSubmitWithTailFollow = useCallback( diff --git a/src/engines/ChatPanel/SideChat/index.tsx b/src/engines/ChatPanel/SideChat/index.tsx index a9ca2a07dd..c76a8fda65 100644 --- a/src/engines/ChatPanel/SideChat/index.tsx +++ b/src/engines/ChatPanel/SideChat/index.tsx @@ -386,6 +386,9 @@ export const SideChatSessionBody: React.FC = ({ currentSession: session, root: conversationTargetBinding?.root ?? null, selectedTarget: conversationTargetBinding?.target ?? null, + importedCloudWorkspace: + conversationTargetBinding?.importedCloudWorkspace ?? + (session?.importedFrom ? "pending" : null), onSurfaceSubmit: handleSurfaceSubmit, }); diff --git a/src/engines/ChatPanel/conversationTargetSelection.ts b/src/engines/ChatPanel/conversationTargetSelection.ts index a1e8090d8b..c17d1d3683 100644 --- a/src/engines/ChatPanel/conversationTargetSelection.ts +++ b/src/engines/ChatPanel/conversationTargetSelection.ts @@ -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; diff --git a/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.test.ts b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.test.ts index 662ce3a909..cf5e03af19 100644 --- a/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.test.ts +++ b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.test.ts @@ -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, @@ -58,6 +63,9 @@ interface RouterHarnessProps { isDirectAgentOrgMember: boolean; onSurfaceSubmit: () => Promise; selectedTarget: LocalConversationTarget | null; + conversationRoot: ConversationRootLocator | null; + currentSession?: Session; + importedCloudWorkspace: "pending" | "matched" | "missing" | null; onReady: (router: ConversationSubmitRouter) => void; } @@ -66,14 +74,18 @@ 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]); @@ -81,23 +93,28 @@ function RouterHarness({ } const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []; +let latestStore = createStore(); function renderRouter(params: { sessionId?: string; isDirectAgentOrgMember?: boolean; onSurfaceSubmit?: () => Promise; 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, @@ -105,6 +122,12 @@ function renderRouter(params: { 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) => { @@ -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) => { diff --git a/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts index fd5481931a..ea6c45dcf2 100644 --- a/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts +++ b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts @@ -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"; @@ -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; } @@ -115,6 +119,7 @@ export function useConversationSubmitRouter({ currentSession, root, selectedTarget, + importedCloudWorkspace, onSurfaceSubmit, }: UseConversationSubmitRouterOptions): ConversationSubmitRouter { const store = useStore(); @@ -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; @@ -176,6 +199,7 @@ export function useConversationSubmitRouter({ [ currentSession, downloadProgress, + importedCloudWorkspace, root, selectedTarget, sessionId, diff --git a/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts b/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts index d75ea5b53d..b41409b70f 100644 --- a/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts +++ b/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts @@ -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 @@ -222,6 +230,7 @@ export function useConversationTargetBinding( selection: marketSelection, runtimeSelection, target, + importedCloudWorkspace, readiness, nativeCliTargets, applyRuntimePick, @@ -235,6 +244,7 @@ export function useConversationTargetBinding( executionTargetHydrationFailed, executionTargetHydrationLoading, executionTargets, + importedCloudWorkspace, nativeCliTargets, marketSelection, readiness, diff --git a/src/engines/ChatPanel/hooks/useInputArea/__tests__/useSubmitMessage.test.ts b/src/engines/ChatPanel/hooks/useInputArea/__tests__/useSubmitMessage.test.ts index 256866f49e..d17b7d7a2d 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/__tests__/useSubmitMessage.test.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/__tests__/useSubmitMessage.test.ts @@ -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, @@ -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(() => { diff --git a/src/engines/ChatPanel/hooks/useInputArea/types.ts b/src/engines/ChatPanel/hooks/useInputArea/types.ts index 6734c05862..7a9324b8e3 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/types.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/types.ts @@ -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 diff --git a/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts b/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts index c1b7702432..5e3947cd07 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts @@ -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 @@ -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; diff --git a/src/features/Org2Cloud/SessionConversation/CloudWorkspaceRequiredDialog.test.ts b/src/features/Org2Cloud/SessionConversation/CloudWorkspaceRequiredDialog.test.ts new file mode 100644 index 0000000000..66acb0011c --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/CloudWorkspaceRequiredDialog.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { afterAll, beforeAll, expect, it, vi } from "vitest"; + +import { openWorkingDirectorySpotlight } from "@src/scaffold/GlobalSpotlight/openSpotlight"; + +import { CloudWorkspaceRequiredDialog } from "./CloudWorkspaceRequiredDialog"; +import { cloudWorkspaceRequiredDialogAtom } from "./cloudWorkspaceRequiredDialogAtom"; + +vi.mock("@src/scaffold/GlobalSpotlight/openSpotlight", () => ({ + openWorkingDirectorySpotlight: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "conversation.workspaceRequiredTitle": "Open a matching workspace", + "conversation.workspaceRequiredBody": + "Open the local repository checkout", + "conversation.openWorkspace": "Open workspace", + }; + return translations[key] ?? key; + }, + }), +})); + +const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); +}); + +it("opens the normal workspace picker from the missing-checkout prompt", () => { + const store = createStore(); + store.set(cloudWorkspaceRequiredDialogAtom, true); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + createElement( + Provider, + { store }, + createElement(CloudWorkspaceRequiredDialog) + ) + ); + }); + + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain("Open a matching workspace"); + const open = [...(dialog?.querySelectorAll("button") ?? [])].find((button) => + button.textContent?.includes("Open workspace") + ); + expect(open).toBeDefined(); + act(() => open?.click()); + expect(openWorkingDirectorySpotlight).toHaveBeenCalledWith("open"); + expect(store.get(cloudWorkspaceRequiredDialogAtom)).toBe(false); + + act(() => root.unmount()); + container.remove(); +}); diff --git a/src/features/Org2Cloud/SessionConversation/CloudWorkspaceRequiredDialog.tsx b/src/features/Org2Cloud/SessionConversation/CloudWorkspaceRequiredDialog.tsx new file mode 100644 index 0000000000..8cd8001dfa --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/CloudWorkspaceRequiredDialog.tsx @@ -0,0 +1,29 @@ +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; + +import { openWorkingDirectorySpotlight } from "@src/scaffold/GlobalSpotlight/openSpotlight"; +import Modal from "@src/scaffold/ModalSystem"; + +import { cloudWorkspaceRequiredDialogAtom } from "./cloudWorkspaceRequiredDialogAtom"; + +export function CloudWorkspaceRequiredDialog() { + const { t } = useTranslation("sessions"); + const [visible, setVisible] = useAtom(cloudWorkspaceRequiredDialogAtom); + const close = () => setVisible(false); + + return ( + { + close(); + openWorkingDirectorySpotlight("open"); + }} + okText={t("conversation.openWorkspace")} + width={420} + > + {t("conversation.workspaceRequiredBody")} + + ); +} diff --git a/src/features/Org2Cloud/SessionConversation/cloudWorkspaceRequiredDialogAtom.ts b/src/features/Org2Cloud/SessionConversation/cloudWorkspaceRequiredDialogAtom.ts new file mode 100644 index 0000000000..7c885ea99b --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/cloudWorkspaceRequiredDialogAtom.ts @@ -0,0 +1,6 @@ +import { atom } from "jotai"; + +/** One dialog per app store, opened only by an attempted Agent continuation. */ +export const cloudWorkspaceRequiredDialogAtom = atom(false); +cloudWorkspaceRequiredDialogAtom.debugLabel = + "cloudWorkspaceRequiredDialogAtom"; diff --git a/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.hydration.test.ts b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.hydration.test.ts new file mode 100644 index 0000000000..322119d39d --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.hydration.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act, createElement, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { afterAll, beforeAll, expect, it, vi } from "vitest"; + +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; +import { reposHydratedAtom } from "@src/store/repo"; +import type { Session } from "@src/store/session"; + +import { org2CloudRemoteSessionsAtom } from "../org2CloudRemoteSessionsAtom"; +import { useCloudConversationSource } from "./useCloudConversationSource"; + +vi.mock("@src/features/TeamCollaboration/forkWorkspaceResolution", () => ({ + resolveForkWorkspacePath: vi.fn(async () => null), +})); +vi.mock("@src/features/Org2Cloud/useCloudSessionDownloadSurface", () => ({ + useCloudSessionLoadingSource: () => undefined, +})); + +const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); +}); + +it("keeps an imported checkout unresolved until remote metadata and the repo inventory finish loading", async () => { + const orgId = "cloud-org"; + const sourceSessionId = "source-session"; + const session = { + session_id: "local-import", + importedFrom: { + orgId, + sourceSessionId, + ownerMemberId: "member-1", + epoch: 1, + seq: 1, + count: 2, + }, + } as Session; + const remoteRow = { + id: "remote-row", + orgId, + sourceSessionId, + repoScopeKey: "github.com/example/unique-repo", + repoPath: "/source-machine/repo", + } as RemoteTeammateSessionMetadata; + const store = createStore(); + store.set(org2CloudRemoteSessionsAtom, { + [orgId]: { state: "loading", rows: [], fetchedAt: 0 }, + }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const observedRef: { + current: ReturnType | null; + } = { current: null }; + function Probe() { + const result = useCloudConversationSource({ + sessionId: session.session_id, + session, + target: { orgId, sessionId: sourceSessionId }, + sessions: [session], + repos: [], + }); + useEffect(() => { + observedRef.current = result; + }, [result]); + return null; + } + + await act(async () => { + root.render(createElement(Provider, { store }, createElement(Probe))); + }); + expect(observedRef.current?.workspacePending).toBe(true); + + await act(async () => { + store.set(org2CloudRemoteSessionsAtom, { + [orgId]: { state: "ready", rows: [remoteRow], fetchedAt: 1 }, + }); + }); + expect(observedRef.current?.workspacePending).toBe(true); + + await act(async () => { + store.set(reposHydratedAtom, true); + }); + expect(observedRef.current?.workspacePending).toBe(false); + expect(observedRef.current?.source?.workspaceRepoPath).toBe(null); + + act(() => root.unmount()); + container.remove(); +}); diff --git a/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts index 3724cb247f..f9cc69c660 100644 --- a/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts +++ b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts @@ -7,6 +7,7 @@ import { resolveForkWorkspacePath } from "@src/features/TeamCollaboration/forkWo import { createLogger } from "@src/hooks/logger"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; import type { Repo } from "@src/store/repo"; +import { reposHydratedAtom, selectedRepoAtom } from "@src/store/repo"; import type { Session } from "@src/store/session"; import { getExternalHistoryCliAgentType } from "@src/util/session/sessionDispatch"; @@ -95,6 +96,8 @@ export function useCloudConversationSource({ repos, }: CloudConversationSourceInput): CloudConversationSourceResolution { const auth = useAtomValue(org2CloudAuthAtom); + const selectedRepo = useAtomValue(selectedRepoAtom); + const reposHydrated = useAtomValue(reposHydratedAtom); const loadingSource = useCloudSessionLoadingSource(sessionId); const importedFrom = session?.importedFrom; const remoteEntries = useAtomValue( @@ -139,6 +142,7 @@ export function useCloudConversationSource({ const localWorkspaceInventoryKey = useMemo( () => [ + selectedRepo?.path ?? selectedRepo?.fs_uri, ...repos.map((repo) => repo.path), ...sessions // Imported rows may contain another device's absolute path. They @@ -154,20 +158,33 @@ export function useCloudConversationSource({ .filter((path): path is string => Boolean(path)) .sort() .join("\n"), - [repos, sessions] + [repos, selectedRepo?.fs_uri, selectedRepo?.path, sessions] ); + const workspaceResolutionKey = importedWorkspaceKey + ? JSON.stringify([ + importedWorkspaceKey, + importedRemoteRow?.repoScopeKey, + importedRemoteRow?.repoPath, + localWorkspaceInventoryKey, + ]) + : null; useEffect(() => { let cancelled = false; - if (!importedRemoteRow || !importedWorkspaceKey) return; + if (!importedRemoteRow || !workspaceResolutionKey) return; // A scoped Team Session needs the local repo/session inventory before a // missing match is authoritative. Keep the prior durable choice pending // during cold-start hydration instead of collapsing it to null. - if (importedRemoteRow.repoScopeKey && !localWorkspaceInventoryKey) return; + if ( + importedRemoteRow.repoScopeKey && + !localWorkspaceInventoryKey && + !reposHydrated + ) + return; void resolveForkWorkspacePath(importedRemoteRow) .then((path) => { if (!cancelled) { - setImportedWorkspaceResolution({ key: importedWorkspaceKey, path }); + setImportedWorkspaceResolution({ key: workspaceResolutionKey, path }); } }) .catch((error: unknown) => { @@ -178,17 +195,26 @@ export function useCloudConversationSource({ return () => { cancelled = true; }; - }, [importedRemoteRow, importedWorkspaceKey, localWorkspaceInventoryKey]); + }, [ + importedRemoteRow, + localWorkspaceInventoryKey, + reposHydrated, + workspaceResolutionKey, + ]); - const workspacePending = Boolean( - importedRemoteRow && - importedWorkspaceKey && - importedWorkspaceResolution?.key !== importedWorkspaceKey - ); const importedWorkspacePath = - importedWorkspaceResolution?.key === importedWorkspaceKey + importedWorkspaceResolution?.key === workspaceResolutionKey ? importedWorkspaceResolution.path : null; + const workspacePending = Boolean( + (importedFrom || loadingSource) && + (!importedRemoteRow || + !workspaceResolutionKey || + importedWorkspaceResolution?.key !== workspaceResolutionKey || + // A partial cold-start inventory may prove a match, but cannot yet + // prove that no checkout exists. Wait for the completed repo scan. + (!reposHydrated && !importedWorkspacePath)) + ); const source = useMemo( () => !authorityLive diff --git a/src/features/TaskKanban/index.tsx b/src/features/TaskKanban/index.tsx index 98496b54c2..aee2abeaf7 100644 --- a/src/features/TaskKanban/index.tsx +++ b/src/features/TaskKanban/index.tsx @@ -175,11 +175,8 @@ const Kanban: React.FC = ({ sessionIdFilter, followSidebarOrgScope, }); - const { - replaySession: openRemoteSession, - forkSession, - busySessionRows, - } = useCloudSessionActions(cloudOrgId); + const { replaySession: openRemoteSession, busySessionRows } = + useCloudSessionActions(cloudOrgId); const renderListRowAction = useCallback( (task: KanbanTask): React.ReactNode => { @@ -192,14 +189,14 @@ const Kanban: React.FC = ({ size="small" disabled={busySessionRows.has(remoteSession.id)} loading={busySessionRows.has(remoteSession.id)} - data-testid={`kanban-list-session-take-over-${remoteSession.sourceSessionId}`} - onClick={() => void forkSession(remoteSession)} + data-testid={`kanban-list-session-continue-${remoteSession.sourceSessionId}`} + onClick={() => void openRemoteSession(remoteSession)} > - {tCommon("workstation.takeOver")} + {tCommon("actions.continue")} ); }, - [busySessionRows, forkSession, remoteSessionsByTaskId, tCommon] + [busySessionRows, openRemoteSession, remoteSessionsByTaskId, tCommon] ); const { visibleTasks, visibleDiaryTasks, visibleColumns, selectedTask } = diff --git a/src/features/TeamCollaboration/components/SessionForkHeaderExtras/index.tsx b/src/features/TeamCollaboration/components/SessionForkHeaderExtras/index.tsx index 830d81fb7a..17fe9c26b2 100644 --- a/src/features/TeamCollaboration/components/SessionForkHeaderExtras/index.tsx +++ b/src/features/TeamCollaboration/components/SessionForkHeaderExtras/index.tsx @@ -52,7 +52,10 @@ const SessionForkHeaderExtras: React.FC = ({ ); if (!session) return null; - const showForkButton = Boolean(session.importedFrom); + // Cloud members continue directly through the canonical Agent composer. + // Guest-share imports still need a separate local fork because they cannot + // write turns to the member-only Cloud conversation. + const showForkButton = Boolean(session.importedFrom?.shareToken); if (!showForkButton && !forkedFrom) return null; const handleFork = async (): Promise => { diff --git a/src/features/TeamCollaboration/forkSession.test.ts b/src/features/TeamCollaboration/forkSession.test.ts index d902328d14..866e1a45d1 100644 --- a/src/features/TeamCollaboration/forkSession.test.ts +++ b/src/features/TeamCollaboration/forkSession.test.ts @@ -10,7 +10,7 @@ import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; import { org2CloudOrgsAtom } from "@src/features/Org2Cloud/org2CloudOrgsAtom"; import { COLLAB_IDENTITY_KIND } from "@src/store/collaboration/types"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; -import { reposAtom } from "@src/store/repo"; +import { reposAtom, selectedRepoIdAtom } from "@src/store/repo"; import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; import type { Session, @@ -209,6 +209,7 @@ beforeEach(() => { eventStoreMock.getPersistedEvents.mockResolvedValue([]); store.set(sessionsAtom, []); store.set(reposAtom, []); + store.set(selectedRepoIdAtom, ""); store.set(org2CloudOrgsAtom, []); store.set(org2CloudAccessSettingsAtom, {}); store.set(sessionOrgTagsAtom, {}); @@ -217,6 +218,30 @@ beforeEach(() => { }); describe("resolveForkWorkspacePath", () => { + it("uses the selected matching workspace before another local clone or the source path", async () => { + store.set(reposAtom, [ + { id: "other", path: "/repo/other-clone" }, + { id: "selected", path: "/repo/selected-clone" }, + ] as never); + store.set(selectedRepoIdAtom, "selected"); + store.set(sessionsAtom, [ + { session_id: "source", repoPath: "/repo/shared" } as Session, + ]); + resolveCheckoutMock.mockImplementation( + async (_scopeKey, candidates) => candidates[0] ?? null + ); + + await expect( + resolveForkWorkspacePath( + makeRemote({ repoScopeKey: "github.com/example/repo" }) + ) + ).resolves.toBe("/repo/selected-clone"); + expect(resolveCheckoutMock).toHaveBeenCalledWith( + "github.com/example/repo", + ["/repo/selected-clone", "/repo/shared", "/repo/other-clone"] + ); + }); + it("prefers the known source checkout over another clone of the same repo", async () => { store.set(sessionsAtom, [ { session_id: "other", repoPath: "/repo/other-clone" } as Session, @@ -258,6 +283,19 @@ describe("resolveForkWorkspacePath", () => { ).resolves.toBe("/repo/other-clone"); }); + it("does not use an existing but repo-mismatched owner path as a scoped fallback", async () => { + store.set(sessionsAtom, [ + { session_id: "source", repoPath: "/repo/shared" } as Session, + ]); + resolveCheckoutMock.mockResolvedValue(null); + + await expect( + resolveForkWorkspacePath( + makeRemote({ repoScopeKey: "github.com/example/repo" }) + ) + ).resolves.toBeNull(); + }); + it("ignores stale imported paths and probes only checkouts that exist locally", async () => { store.set(sessionsAtom, [ { session_id: "stale", repoPath: "/Users/owner/ORG2" } as Session, diff --git a/src/features/TeamCollaboration/forkWorkspaceResolution.ts b/src/features/TeamCollaboration/forkWorkspaceResolution.ts index d58e57df79..129f6a371e 100644 --- a/src/features/TeamCollaboration/forkWorkspaceResolution.ts +++ b/src/features/TeamCollaboration/forkWorkspaceResolution.ts @@ -11,7 +11,7 @@ import { exists } from "@tauri-apps/plugin-fs"; import Message from "@src/components/Message"; import i18n from "@src/i18n"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; -import { reposAtom } from "@src/store/repo"; +import { reposAtom, selectedRepoAtom } from "@src/store/repo"; import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; import type { Session } from "@src/store/session/sessionAtom/types"; import { @@ -59,6 +59,9 @@ export async function resolveForkWorkspacePath( if (!isStoreInitialized()) return null; const store = getInstrumentedStore(); const candidates: string[] = []; + const selectedRepo = store.get(selectedRepoAtom); + const selectedPath = selectedRepo?.path ?? selectedRepo?.fs_uri; + if (selectedPath) candidates.push(selectedPath); for (const repo of store.get(reposAtom)) { if (repo.path) candidates.push(repo.path); } @@ -85,18 +88,22 @@ export async function resolveForkWorkspacePath( } } - // Several local clones can share the same remote. Prefer the source's - // checkout when it is already a known, existing local candidate, rather - // than letting repo-list order silently move a continuation to a sibling. - // It still goes through scope verification below; a foreign path is never - // introduced as a new candidate by this preference. + // Several local clones can share the same remote. The window's selected + // workspace is an explicit local choice; otherwise prefer the source's + // exact checkout on a same-machine continuation. Both still pass git-remote + // scope verification below. const sourcePath = normalizeRepoScopeKey(remoteSession.repoPath ?? ""); const sourceIndex = existingCandidates.findIndex( (candidate) => normalizeRepoScopeKey(candidate) === sourcePath ); - if (sourceIndex > 0) { + const selectedIsFirst = + selectedPath && + normalizeRepoScopeKey(existingCandidates[0] ?? "") === + normalizeRepoScopeKey(selectedPath); + const sourcePriorityIndex = selectedIsFirst ? 1 : 0; + if (sourceIndex > sourcePriorityIndex) { const [sourceCandidate] = existingCandidates.splice(sourceIndex, 1); - existingCandidates.unshift(sourceCandidate); + existingCandidates.splice(sourcePriorityIndex, 0, sourceCandidate); } const byScopeKey = await resolveLocalCheckoutForScopeKey( @@ -105,10 +112,9 @@ export async function resolveForkWorkspacePath( ); if (byScopeKey) return byScopeKey; - // Same-machine fallback: exact path identity against our known local - // paths proves the checkout exists here even when there is no scope key - // to match (repo without a git remote) or remote resolution hiccuped. - if (remoteSession.repoPath) { + // Exact path is enough only for unscoped sessions. If a scope key exists, + // a moved/repointed local repo must not bypass the remote identity check. + if (!remoteSession.repoScopeKey && remoteSession.repoPath) { const normalizedOwnerPath = normalizeRepoScopeKey(remoteSession.repoPath); if ( normalizedOwnerPath && diff --git a/src/hooks/git/useRepoSelection/useRepoLoader.ts b/src/hooks/git/useRepoSelection/useRepoLoader.ts index 36d070b3e4..c5d0f8319b 100644 --- a/src/hooks/git/useRepoSelection/useRepoLoader.ts +++ b/src/hooks/git/useRepoSelection/useRepoLoader.ts @@ -13,6 +13,7 @@ import { cachedReposAtom, lastUsedRepoAtom, reposAtom, + reposHydratedAtom, selectedRepoIdAtom, updateCachedRepos, validRepoIdsAtom, @@ -80,6 +81,7 @@ function resolveStartupRepo( export function useRepoLoader(): UseRepoLoaderReturn { const [repos, setRepos] = useAtom(reposAtom); + const setReposHydrated = useSetAtom(reposHydratedAtom); const [selectedRepoId, setSelectedRepoId] = useAtom(selectedRepoIdAtom); const setValidRepoIds = useSetAtom(validRepoIdsAtom); const [_cachedRepos, setCachedRepos] = useAtom(cachedReposAtom); @@ -176,6 +178,7 @@ export function useRepoLoader(): UseRepoLoaderReturn { }); setRepos(repoList); + setReposHydrated(true); setValidRepoIds(new Set(repoList.map((repo) => repo.id))); loadedReposRef.current = true; loadSucceeded = true; @@ -221,6 +224,7 @@ export function useRepoLoader(): UseRepoLoaderReturn { }, [ repos.length, setRepos, + setReposHydrated, setValidRepoIds, setSelectedRepoId, setLastUsedRepo, @@ -229,10 +233,11 @@ export function useRepoLoader(): UseRepoLoaderReturn { const forceRefreshRepos = useCallback(async () => { loadedReposRef.current = false; + setReposHydrated(false); setGlobalReposLoaded(false); setGlobalLoadInProgress(false); await loadRepos(); - }, [loadRepos]); + }, [loadRepos, setReposHydrated]); // Handle hot reload triggered reload useEffect(() => { diff --git a/src/i18n/locales/de/sessions.json b/src/i18n/locales/de/sessions.json index 2e95dd92c1..14d45364dc 100644 --- a/src/i18n/locales/de/sessions.json +++ b/src/i18n/locales/de/sessions.json @@ -2624,6 +2624,10 @@ "teamChatMode": "Team-Chat", "promptTooltip": "An den Agent senden", "teamChatTooltip": "Nachricht an Teammitglieder — löst den Agent nicht aus", + "workspaceResolving": "Lokaler Arbeitsbereich wird gesucht. Bitte gleich erneut versuchen.", + "workspaceRequiredTitle": "Passenden Arbeitsbereich öffnen", + "workspaceRequiredBody": "Um diese geteilte Unterhaltung mit einem Agenten fortzusetzen, öffnen Sie einen lokalen Checkout des Repositorys.", + "openWorkspace": "Arbeitsbereich öffnen", "imagesUnsupported": "Team-Chat-Nachrichten können keine Bilder enthalten", "participantsTooltip": "Teilnehmer der Unterhaltung und Diskussionsnachrichten", "mentionGroup": "Teammitglieder" diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index 02b23cc975..9dd23fe2c8 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -2613,6 +2613,10 @@ "teamChatMode": "Team chat", "promptTooltip": "Send to the agent", "teamChatTooltip": "Message teammates — does not prompt the agent", + "workspaceResolving": "Checking for a matching local workspace. Try again in a moment.", + "workspaceRequiredTitle": "Open a matching workspace", + "workspaceRequiredBody": "This shared conversation needs a local checkout of its repository before an agent can continue it.", + "openWorkspace": "Open workspace", "imagesUnsupported": "Team chat messages cannot include images", "participantsTooltip": "Conversation participants and discussion messages", "mentionGroup": "Teammates" diff --git a/src/i18n/locales/es/sessions.json b/src/i18n/locales/es/sessions.json index 6b0794c3d3..f28458044d 100644 --- a/src/i18n/locales/es/sessions.json +++ b/src/i18n/locales/es/sessions.json @@ -2624,6 +2624,10 @@ "teamChatMode": "Chat de equipo", "promptTooltip": "Enviar al agent", "teamChatTooltip": "Escribe a tus compañeros — no envía un prompt al agent", + "workspaceResolving": "Buscando un espacio de trabajo local que coincida. Inténtalo de nuevo en un momento.", + "workspaceRequiredTitle": "Abrir un espacio de trabajo que coincida", + "workspaceRequiredBody": "Para que un agente continúe esta conversación compartida, abre una copia local del repositorio.", + "openWorkspace": "Abrir espacio de trabajo", "imagesUnsupported": "Los mensajes del chat de equipo no pueden incluir imágenes", "participantsTooltip": "Participantes de la conversación y mensajes de discusión", "mentionGroup": "Compañeros" diff --git a/src/i18n/locales/fr/sessions.json b/src/i18n/locales/fr/sessions.json index 2e91902ace..8f61c824ca 100644 --- a/src/i18n/locales/fr/sessions.json +++ b/src/i18n/locales/fr/sessions.json @@ -2624,6 +2624,10 @@ "teamChatMode": "Chat d’équipe", "promptTooltip": "Envoyer à l’agent", "teamChatTooltip": "Écrire aux coéquipiers — ne sollicite pas l’agent", + "workspaceResolving": "Recherche d’un espace de travail local correspondant. Réessayez dans un instant.", + "workspaceRequiredTitle": "Ouvrir un espace de travail correspondant", + "workspaceRequiredBody": "Pour qu’un agent poursuive cette conversation partagée, ouvrez une copie locale du dépôt.", + "openWorkspace": "Ouvrir un espace de travail", "imagesUnsupported": "Les messages du chat d’équipe ne peuvent pas contenir d’images", "participantsTooltip": "Participants à la conversation et messages de discussion", "mentionGroup": "Coéquipiers" diff --git a/src/i18n/locales/hi/sessions.json b/src/i18n/locales/hi/sessions.json index 022d9d7432..39054a5363 100644 --- a/src/i18n/locales/hi/sessions.json +++ b/src/i18n/locales/hi/sessions.json @@ -2613,6 +2613,10 @@ "teamChatMode": "टीम चैट", "promptTooltip": "एजेंट को भेजें", "teamChatTooltip": "साथियों को संदेश भेजें — एजेंट को प्रॉम्प्ट नहीं करता", + "workspaceResolving": "मिलता हुआ स्थानीय कार्यक्षेत्र ढूँढा जा रहा है। कुछ देर बाद फिर कोशिश करें।", + "workspaceRequiredTitle": "मिलता हुआ कार्यक्षेत्र खोलें", + "workspaceRequiredBody": "इस साझा बातचीत को एजेंट से जारी रखने के लिए रिपॉज़िटरी की स्थानीय कॉपी खोलें।", + "openWorkspace": "कार्यस्थान खोलें", "imagesUnsupported": "टीम चैट संदेशों में इमेज शामिल नहीं की जा सकतीं", "participantsTooltip": "वार्तालाप के प्रतिभागी और चर्चा संदेश", "mentionGroup": "साथी" diff --git a/src/i18n/locales/id/sessions.json b/src/i18n/locales/id/sessions.json index 6b7ab45dc0..ad2a5f122f 100644 --- a/src/i18n/locales/id/sessions.json +++ b/src/i18n/locales/id/sessions.json @@ -2613,6 +2613,10 @@ "teamChatMode": "Chat tim", "promptTooltip": "Kirim ke agen", "teamChatTooltip": "Kirim pesan ke rekan tim — tidak memberi prompt ke agen", + "workspaceResolving": "Mencari ruang kerja lokal yang cocok. Coba lagi sebentar lagi.", + "workspaceRequiredTitle": "Buka ruang kerja yang cocok", + "workspaceRequiredBody": "Agar agen dapat melanjutkan percakapan bersama ini, buka salinan lokal repositorinya.", + "openWorkspace": "Buka ruang kerja", "imagesUnsupported": "Pesan chat tim tidak dapat menyertakan gambar", "participantsTooltip": "Peserta percakapan dan pesan diskusi", "mentionGroup": "Rekan tim" diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json index 128f533422..f71ceab6d7 100644 --- a/src/i18n/locales/ja/sessions.json +++ b/src/i18n/locales/ja/sessions.json @@ -2624,6 +2624,10 @@ "teamChatMode": "チームチャット", "promptTooltip": "Agent に送信", "teamChatTooltip": "チームメイトにメッセージ — Agent には送られません", + "workspaceResolving": "一致するローカルワークスペースを確認中です。少し待ってから再試行してください。", + "workspaceRequiredTitle": "一致するワークスペースを開く", + "workspaceRequiredBody": "この共有会話をエージェントが続けるには、リポジトリのローカルチェックアウトを開いてください。", + "openWorkspace": "ワークスペースを開く", "imagesUnsupported": "チームチャットのメッセージには画像を含められません", "participantsTooltip": "会話の参加者とディスカッションメッセージ", "mentionGroup": "チームメイト" diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json index dbf8dad8ef..a775fb404c 100644 --- a/src/i18n/locales/ko/sessions.json +++ b/src/i18n/locales/ko/sessions.json @@ -2624,6 +2624,10 @@ "teamChatMode": "팀 채팅", "promptTooltip": "Agent에게 보내기", "teamChatTooltip": "팀원에게 메시지 보내기 — Agent에게 프롬프트를 보내지 않습니다", + "workspaceResolving": "일치하는 로컬 작업 공간을 찾는 중입니다. 잠시 후 다시 시도하세요.", + "workspaceRequiredTitle": "일치하는 작업 공간 열기", + "workspaceRequiredBody": "에이전트가 이 공유 대화를 계속하려면 저장소의 로컬 체크아웃을 여세요.", + "openWorkspace": "작업 공간 열기", "imagesUnsupported": "팀 채팅 메시지에는 이미지를 포함할 수 없습니다", "participantsTooltip": "대화 참여자 및 토론 메시지", "mentionGroup": "팀원" diff --git a/src/i18n/locales/pl/sessions.json b/src/i18n/locales/pl/sessions.json index 4df3bd2876..a9f43a0e0e 100644 --- a/src/i18n/locales/pl/sessions.json +++ b/src/i18n/locales/pl/sessions.json @@ -2624,6 +2624,10 @@ "teamChatMode": "Czat zespołu", "promptTooltip": "Wyślij do Agenta", "teamChatTooltip": "Napisz do członków zespołu — nie wywołuje Agenta", + "workspaceResolving": "Trwa wyszukiwanie pasującego lokalnego obszaru roboczego. Spróbuj ponownie za chwilę.", + "workspaceRequiredTitle": "Otwórz pasujący obszar roboczy", + "workspaceRequiredBody": "Aby agent mógł kontynuować tę udostępnioną rozmowę, otwórz lokalną kopię repozytorium.", + "openWorkspace": "Otwórz obszar roboczy", "imagesUnsupported": "Wiadomości czatu zespołu nie mogą zawierać obrazów", "participantsTooltip": "Uczestnicy rozmowy i wiadomości dyskusji", "mentionGroup": "Członkowie zespołu" diff --git a/src/i18n/locales/pt/sessions.json b/src/i18n/locales/pt/sessions.json index 889454d267..19a5386f3d 100644 --- a/src/i18n/locales/pt/sessions.json +++ b/src/i18n/locales/pt/sessions.json @@ -2624,6 +2624,10 @@ "teamChatMode": "Chat da equipe", "promptTooltip": "Enviar ao agent", "teamChatTooltip": "Mensagem para colegas de equipe — não aciona o agent", + "workspaceResolving": "Procurando um espaço de trabalho local correspondente. Tente novamente em instantes.", + "workspaceRequiredTitle": "Abrir espaço de trabalho correspondente", + "workspaceRequiredBody": "Para que um agente continue esta conversa compartilhada, abra uma cópia local do repositório.", + "openWorkspace": "Abrir espaço de trabalho", "imagesUnsupported": "Mensagens do chat da equipe não podem incluir imagens", "participantsTooltip": "Participantes da conversa e mensagens de discussão", "mentionGroup": "Colegas de equipe" diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json index 4c0ae2341b..39ebdda601 100644 --- a/src/i18n/locales/ru/sessions.json +++ b/src/i18n/locales/ru/sessions.json @@ -2624,6 +2624,10 @@ "teamChatMode": "Чат команды", "promptTooltip": "Отправить агенту", "teamChatTooltip": "Сообщение коллегам — не запускает агента", + "workspaceResolving": "Поиск подходящей локальной рабочей области. Повторите попытку через несколько секунд.", + "workspaceRequiredTitle": "Открыть подходящую рабочую область", + "workspaceRequiredBody": "Чтобы агент продолжил этот общий диалог, откройте локальную копию репозитория.", + "openWorkspace": "Открыть рабочую область", "imagesUnsupported": "Сообщения чата команды не могут содержать изображения", "participantsTooltip": "Участники разговора и сообщения обсуждения", "mentionGroup": "Коллеги" diff --git a/src/i18n/locales/tr/sessions.json b/src/i18n/locales/tr/sessions.json index aa5951a789..fba0dda252 100644 --- a/src/i18n/locales/tr/sessions.json +++ b/src/i18n/locales/tr/sessions.json @@ -2624,6 +2624,10 @@ "teamChatMode": "Ekip sohbeti", "promptTooltip": "Agent'a gönder", "teamChatTooltip": "Takım arkadaşlarına mesaj gönder — agent'ı tetiklemez", + "workspaceResolving": "Eşleşen yerel çalışma alanı aranıyor. Birazdan tekrar deneyin.", + "workspaceRequiredTitle": "Eşleşen çalışma alanını aç", + "workspaceRequiredBody": "Bir ajanın bu paylaşılan konuşmayı sürdürmesi için deponun yerel bir kopyasını açın.", + "openWorkspace": "Çalışma alanını aç", "imagesUnsupported": "Ekip sohbeti mesajları görsel içeremez", "participantsTooltip": "Konuşma katılımcıları ve tartışma mesajları", "mentionGroup": "Takım arkadaşları" diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json index 1751d3e99a..6d905e30d7 100644 --- a/src/i18n/locales/vi/sessions.json +++ b/src/i18n/locales/vi/sessions.json @@ -2624,6 +2624,10 @@ "teamChatMode": "Trò chuyện nhóm", "promptTooltip": "Gửi cho agent", "teamChatTooltip": "Nhắn cho đồng đội — không kích hoạt agent", + "workspaceResolving": "Đang tìm không gian làm việc cục bộ phù hợp. Hãy thử lại sau giây lát.", + "workspaceRequiredTitle": "Mở không gian làm việc phù hợp", + "workspaceRequiredBody": "Để agent tiếp tục cuộc trò chuyện chung này, hãy mở bản sao cục bộ của kho mã.", + "openWorkspace": "Mở không gian làm việc", "imagesUnsupported": "Tin nhắn trò chuyện nhóm không thể chứa hình ảnh", "participantsTooltip": "Người tham gia cuộc trò chuyện và tin nhắn thảo luận", "mentionGroup": "Đồng đội" diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index bea53794fc..0ea0eb4a5c 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -2642,6 +2642,10 @@ "teamChatMode": "團隊聊天", "promptTooltip": "傳送給 agent", "teamChatTooltip": "傳訊息給隊友 — 不會觸發 agent", + "workspaceResolving": "正在尋找符合的本機工作區,請稍後再試", + "workspaceRequiredTitle": "開啟符合的工作區", + "workspaceRequiredBody": "要讓 Agent 繼續此共享對話,請先開啟該儲存庫的本機工作區", + "openWorkspace": "開啟工作區", "imagesUnsupported": "團隊聊天訊息不能包含圖片", "participantsTooltip": "對話參與者與討論訊息", "mentionGroup": "隊友" diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index 029c3a24b5..55b5fb1fd2 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -2613,6 +2613,10 @@ "teamChatMode": "团队讨论", "promptTooltip": "发给 agent 执行", "teamChatTooltip": "发给队友的讨论,不触发 agent", + "workspaceResolving": "正在查找匹配的本地工作区,请稍后重试", + "workspaceRequiredTitle": "打开匹配的工作区", + "workspaceRequiredBody": "要让 Agent 继续这个共享对话,请先打开该仓库的本地工作区", + "openWorkspace": "打开工作区", "imagesUnsupported": "团队讨论暂不支持图片", "participantsTooltip": "对话参与者与讨论消息数", "mentionGroup": "队友" diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/SidebarDialogs.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/SidebarDialogs.tsx index 4de9185412..9c8d4557da 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/SidebarDialogs.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/SidebarDialogs.tsx @@ -8,6 +8,7 @@ import CloudShareImportDialog from "@src/features/Org2Cloud/CloudShareImportDial import CloudSyncLevelDialog from "@src/features/Org2Cloud/CloudSyncLevelDialog"; import type { useCloudSyncLevelDialog } from "@src/features/Org2Cloud/CloudSyncLevelDialog/useCloudSyncLevelDialog"; import JoinCloudOrgDialog from "@src/features/Org2Cloud/JoinCloudOrgDialog"; +import { CloudWorkspaceRequiredDialog } from "@src/features/Org2Cloud/SessionConversation/CloudWorkspaceRequiredDialog"; import ForkCheckoutPickerDialog from "@src/features/TeamCollaboration/components/ForkCheckoutPickerDialog"; import ForkSessionSetupDialog from "@src/features/TeamCollaboration/components/ForkSessionSetupDialog"; import MoveToOrgDialog from "@src/features/TeamCollaboration/components/MoveToOrgDialog"; @@ -69,6 +70,7 @@ export const SidebarDialogs: React.FC = ({ + {cloudChannelsDialogs} {localChannelsDialogs} {cloudMemberFilterDropdown} diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionNativeMenuItems.test.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionNativeMenuItems.test.ts index 0b701ef830..8adc4067ef 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionNativeMenuItems.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionNativeMenuItems.test.ts @@ -8,7 +8,6 @@ describe("buildCloudSessionNativeMenuItems", () => { const onOpenInNewWindow = vi.fn(); const onOpenInMyStation = vi.fn(); const onCopyUrl = vi.fn(); - const onFork = vi.fn(); const onTogglePin = vi.fn(); const onRemove = vi.fn(); @@ -16,7 +15,6 @@ describe("buildCloudSessionNativeMenuItems", () => { isPinned: false, labels: { openIn: "Open in", - fork: "Take over", openInNewTab: "Open in New Tab", openInNewWindow: "Open in New Window", openInMyStation: "Open in My Station", @@ -28,21 +26,13 @@ describe("buildCloudSessionNativeMenuItems", () => { onOpenInNewWindow, onOpenInMyStation, onCopyUrl, - onFork, onTogglePin, onRemove, }); expect( items.map((item) => ("item" in item ? item.item : item.text)) - ).toEqual([ - "Open in", - "Take over", - "Copy URL", - "Pin", - "Separator", - "Remove", - ]); + ).toEqual(["Open in", "Copy URL", "Pin", "Separator", "Remove"]); const submenu = items[0]; if (!("items" in submenu)) throw new Error("Missing Open in submenu"); @@ -58,7 +48,6 @@ describe("buildCloudSessionNativeMenuItems", () => { expect(onOpenInNewWindow).toHaveBeenCalledOnce(); expect(onOpenInMyStation).toHaveBeenCalledOnce(); expect(onCopyUrl).toHaveBeenCalledOnce(); - expect(onFork).toHaveBeenCalledOnce(); expect(onTogglePin).toHaveBeenCalledOnce(); expect(onRemove).toHaveBeenCalledOnce(); }); diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionNativeMenuItems.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionNativeMenuItems.ts index 0465fd80b4..206478b0cd 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionNativeMenuItems.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionNativeMenuItems.ts @@ -5,7 +5,6 @@ import { Copy01Icon, CursorInWindowIcon, Delete02Icon, - Login02Icon, PinIcon, PinOffIcon, } from "@src/icons"; @@ -15,7 +14,6 @@ interface BuildCloudSessionNativeMenuItemsParams { isPinned: boolean; labels: { openIn: string; - fork: string; openInNewTab: string; openInNewWindow: string; openInMyStation: string; @@ -27,7 +25,6 @@ interface BuildCloudSessionNativeMenuItemsParams { onOpenInNewWindow: () => void; onOpenInMyStation: () => void; onCopyUrl: () => void; - onFork: () => void; onTogglePin: () => void; onRemove: () => void; } @@ -43,7 +40,6 @@ export function buildCloudSessionNativeMenuItems({ onOpenInNewWindow, onOpenInMyStation, onCopyUrl, - onFork, onTogglePin, onRemove, }: BuildCloudSessionNativeMenuItemsParams): SidebarMenuItem[] { @@ -69,7 +65,6 @@ export function buildCloudSessionNativeMenuItems({ }, ], }, - { text: labels.fork, icon: Login02Icon, action: onFork }, { text: labels.copyUrl, icon: Copy01Icon, action: onCopyUrl }, { text: labels.togglePin, diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.remoteMenuItems.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.remoteMenuItems.ts index 36c0bdfaea..1a2f881b71 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.remoteMenuItems.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.remoteMenuItems.ts @@ -38,13 +38,11 @@ export function useCloudRemoteSessionMenuItems({ tCommon, openTeamSessionAtDestination, hideRemoteSession, - runFork, findRow, }: { t: TFunction; tCommon: TFunction; openTeamSessionAtDestination: CloudSessionOpenHandlers["openTeamSessionAtDestination"]; - runFork: (row: RemoteTeammateSessionMetadata) => void; hideRemoteSession: (row: RemoteTeammateSessionMetadata) => void; findRow: (rowId: string) => RemoteTeammateSessionMetadata | undefined; }): CloudRemoteSessionMenuItems { @@ -73,7 +71,6 @@ export function useCloudRemoteSessionMenuItems({ isPinned, labels: { openIn: tCommon("actions.openIn"), - fork: t("cloud.orgPanel.fork"), openInNewTab: tCommon("actions.openTargetNewTab"), openInNewWindow: tCommon("actions.openTargetNewWindow"), openInMyStation: tCommon("actions.openTargetMyStation"), @@ -88,7 +85,6 @@ export function useCloudRemoteSessionMenuItems({ openTeamSessionAtDestination(row, "new-window"), onOpenInMyStation: () => openTeamSessionAtDestination(row, "my-station"), - onFork: () => runFork(row), onCopyUrl: () => { void copyText(buildCloudSessionReference(row)) .then(() => { @@ -104,7 +100,6 @@ export function useCloudRemoteSessionMenuItems({ }, [ hideRemoteSession, - runFork, openTeamSessionAtDestination, pinnedRemoteSessionIds, t, diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx index 198c314695..59113e46c9 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx @@ -17,10 +17,10 @@ * toggles the fork thread — without the flag the primitive treats a * children-bearing row as a group header whose whole body only toggles, * which stranded fork sources as unclickable once a fork added a child row. - * The primitive renders hover rowActions on LEAF rows only, so Replay/Fork + * The primitive renders hover rowActions on LEAF rows only, so Replay * hover buttons appear on descendants and on single-row threads (rendered * as leaves); a multi-row thread's root keeps click-to-replay but has no - * hover fork button — no self-duplicate child row is injected. + * hover action — no self-duplicate child row is injected. * * * This hook is a coordinator: thread derivation, selection, open/click @@ -196,13 +196,6 @@ export function useCloudSessionsSection({ }); }, [downloadStartRequest, findRow, forkSession, orgId, runReplay, store]); - const runFork = useCallback( - (row: RemoteTeammateSessionMetadata) => { - void forkSession(row); - }, - [forkSession] - ); - const { openTeamSessionAtDestination, handleCloudSessionItemClick } = useCloudSessionOpenHandlers({ selfUserId, @@ -238,7 +231,6 @@ export function useCloudSessionsSection({ t, tCommon, openTeamSessionAtDestination, - runFork, hideRemoteSession, findRow, }); diff --git a/src/store/repo/atoms.ts b/src/store/repo/atoms.ts index 56429e4afd..8b45b599f8 100644 --- a/src/store/repo/atoms.ts +++ b/src/store/repo/atoms.ts @@ -53,6 +53,10 @@ export const reposAtom = atom( ); reposAtom.debugLabel = "reposAtom"; +/** True after the local repository inventory has loaded, including an empty list. */ +export const reposHydratedAtom = atom(false); +reposHydratedAtom.debugLabel = "reposHydratedAtom"; + /** Valid repo IDs (for validation before API calls) */ export const validRepoIdsAtom = atom>(new Set()); validRepoIdsAtom.debugLabel = "validRepoIdsAtom"; diff --git a/src/store/repo/index.ts b/src/store/repo/index.ts index d5e0a2aed8..7590090a7d 100644 --- a/src/store/repo/index.ts +++ b/src/store/repo/index.ts @@ -49,6 +49,7 @@ export { REPO_KIND } from "./types"; export { // Repo atoms reposAtom, + reposHydratedAtom, validRepoIdsAtom, // Persisted atoms (window-scoped) selectedRepoIdAtom, diff --git a/tests/e2e/specs/core/cloud-org-ui.spec.mjs b/tests/e2e/specs/core/cloud-org-ui.spec.mjs index eabbb710b8..c20cf4f95a 100644 --- a/tests/e2e/specs/core/cloud-org-ui.spec.mjs +++ b/tests/e2e/specs/core/cloud-org-ui.spec.mjs @@ -613,10 +613,10 @@ describe("Cloud org rendered UI (managed ORG2 Cloud)", function () { ); await clickRendered('[data-testid="kanban-view-list"]', "Kanban List view"); const tableOrdinaryRowSelector = `[data-testid="kanban-list-session-row"]`; - const takeOverSelector = `[data-testid="kanban-list-session-take-over-e2e-team-all-${RUN_ID}"]`; + const continueSelector = `[data-testid="kanban-list-session-continue-e2e-team-all-${RUN_ID}"]`; await waitForRendered( - takeOverSelector, - "Take over action for ordinary cloud session in Kanban List" + continueSelector, + "Continue action for ordinary cloud session in Kanban List" ); const sessionsTableEvidence = await execJS(` const scope = document.querySelector('[data-testid="kanban-org-scope-select"]'); @@ -627,7 +627,7 @@ describe("Cloud org rendered UI (managed ORG2 Cloud)", function () { return { scopedOrg: scope?.textContent ?? '', hasExpectedText: row?.textContent?.includes('Teammate A') === true, - hasTakeOver: !!document.querySelector(${JSON.stringify(takeOverSelector)}), + hasContinue: !!document.querySelector(${JSON.stringify(continueSelector)}), fillsSection: !!table && !!tableHost && Math.abs(table.getBoundingClientRect().width - tableHost.getBoundingClientRect().width) < 2, @@ -635,14 +635,13 @@ describe("Cloud org rendered UI (managed ORG2 Cloud)", function () { `); if ( !sessionsTableEvidence?.hasExpectedText || - !sessionsTableEvidence?.hasTakeOver || + !sessionsTableEvidence?.hasContinue || !sessionsTableEvidence?.fillsSection ) { throw new Error( `scoped Kanban List is missing cloud session data/action: ${JSON.stringify(sessionsTableEvidence)}` ); } - // Return to the cloud sidebar scope to exercise its rendered filter and // refresh lifecycle against the same fixture below. await selectCloudOrgScopeFromSidebar(