diff --git a/packages/components/src/components/chat/AGENTS.md b/packages/components/src/components/chat/AGENTS.md index 3512359b0..d7062ef36 100644 --- a/packages/components/src/components/chat/AGENTS.md +++ b/packages/components/src/components/chat/AGENTS.md @@ -81,6 +81,13 @@ ## Invariants +- The chat-route URL declares the composer's selection; it never carries one-shot + event nonces. Once the URL names a selection, the landing mirrors composer + steering back into it via the desktop route's `onSelectionUrlSync` (replace, + incomplete selections map to an empty search), so a sidebar project-row click + is either an identical-URL no-op or an ordinary search change. A plain `/chat` + URL stays plain: restored defaults and auto-selection never rewrite it. Mobile + keeps its base-context model and passes no sync callback. - `use-chat-landing-draft-session.ts` owns the landing's reserved session id. Images, files, ACP preparation, and `startSession({ sessionId }, firstTurn)` MUST consume that same identity. Attachment hooks never reset it independently; reset only diff --git a/packages/components/src/components/chat/chat-landing-derived.ts b/packages/components/src/components/chat/chat-landing-derived.ts index b51b40f6f..c9483db46 100644 --- a/packages/components/src/components/chat/chat-landing-derived.ts +++ b/packages/components/src/components/chat/chat-landing-derived.ts @@ -575,3 +575,142 @@ export function getChatLandingVisibleComposerStatus({ // must not take over the landing composer as status copy. return selectedMachineProjectStatus ?? null; } + +export type ChatLandingPreSelectionIntent = { + context: 'local' | 'github' | 'chat' | undefined; + machine: string | undefined; + project: string | undefined; + repo: string | undefined; +}; + +/** Identity of one URL-named selection (pre-selection intent or mirrored state). */ +export function buildChatLandingPreSelectionKey({ + context, + machine, + project, + repo, +}: ChatLandingPreSelectionIntent): string { + return `${context}|${machine}|${project}|${repo}`; +} + +/** Search-parameter contract of the `/$workspaceName/chat` route. */ +export type ChatLandingSearch = { + context?: 'local' | 'github' | 'chat'; + machine?: string; + project?: string; + repo?: string; + resetDraftKey?: string; +}; + +export function parseChatLandingSearch(search: Record): ChatLandingSearch { + return { + context: + search.context === 'local' || search.context === 'github' || search.context === 'chat' + ? search.context + : undefined, + machine: typeof search.machine === 'string' ? search.machine : undefined, + project: typeof search.project === 'string' ? search.project : undefined, + repo: typeof search.repo === 'string' ? search.repo : undefined, + resetDraftKey: typeof search.resetDraftKey === 'string' ? search.resetDraftKey : undefined, + }; +} + +/** + * `machineId:localProjectId` named by the current URL, or null. Shared by the + * sidebar's row highlight and by the selection-URL mirror's participation + * checks over the same URL contract. + */ +export function getSelectedLocalProjectKey( + pathname: string, + workspaceSlug: string | null, + search?: Record +): string | null { + const workspacePrefix = workspaceSlug ? `/${workspaceSlug}` : ''; + const normalizedPath = + workspaceSlug && pathname.startsWith(workspacePrefix) + ? pathname.slice(workspacePrefix.length) || '/' + : pathname; + + const segments = normalizedPath.split('/').filter(Boolean); + + // New route: /chat?context=local&machine=X&project=Y + if ( + segments[0] === 'chat' && + search?.context === 'local' && + typeof search?.machine === 'string' && + typeof search?.project === 'string' + ) { + return `${search.machine}:${search.project}`; + } + + // Legacy route: /local/$machineId/$localProjectId + if (segments[0] !== 'local') return null; + const machineId = segments[1]; + const localProjectId = segments[2]; + if (!machineId || !localProjectId) return null; + return `${machineId}:${localProjectId}`; +} + +export type ChatLandingEffectiveSelection = { + contextType: 'local' | 'github' | 'chat'; + machineId: string | null; + localProjectId: string | null; + repoFullName: string | null; +}; + +/** + * The chat-route search params that truthfully name the composer's current + * selection. An incomplete selection (a context with nothing chosen yet) maps + * to an empty search: the URL then names nothing rather than something stale. + */ +export function getChatLandingSelectionSearch({ + contextType, + machineId, + localProjectId, + repoFullName, +}: ChatLandingEffectiveSelection): ChatLandingSearch { + if (contextType === 'chat') { + return { context: 'chat' }; + } + if (contextType === 'local' && machineId && localProjectId) { + return { context: 'local', machine: machineId, project: localProjectId }; + } + if (contextType === 'github' && repoFullName) { + return { context: 'github', repo: repoFullName }; + } + return {}; +} + +export type ChatLandingSelectionSyncDecision = 'skip' | 'arm' | 'sync'; + +/** + * Whether the landing should mirror its effective selection back into the URL. + * + * Once the URL names a selection it must keep telling the truth: steering or + * clearing the composer selection would otherwise leave a stale project in the + * URL, making that project's sidebar row an identical-URL no-op. A URL that + * names nothing stays untouched, so restored defaults and auto-selection never + * rewrite a plain landing address. + * + * `arm` covers the commit in which a URL intent was just applied: the observed + * selection still predates the application, so mirroring would race the intent + * and write the stale selection back over it. The caller arms the mirror and + * compares again once the applied selection has rendered. + */ +export function getChatLandingSelectionSyncDecision({ + urlNamesSelection, + intentApplied, + armed, + urlKey, + selectionKey, +}: { + urlNamesSelection: boolean; + intentApplied: boolean; + armed: boolean; + urlKey: string; + selectionKey: string; +}): ChatLandingSelectionSyncDecision { + if (!urlNamesSelection || !intentApplied) return 'skip'; + if (!armed) return 'arm'; + return selectionKey === urlKey ? 'skip' : 'sync'; +} diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index 0bfb71e64..369bb4dcd 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -343,7 +343,11 @@ import { useMobileHomeExcludedSetAtom, } from '@/atoms/mobile-home-state'; import { + buildChatLandingPreSelectionKey, compareChatLandingLocalProjectByRecency, + getChatLandingSelectionSearch, + getChatLandingSelectionSyncDecision, + type ChatLandingSearch, compareChatLandingRepositoryByRecency, getChatLandingBranchSelectorState, getChatLandingHasAnyOnlineMachine, @@ -370,6 +374,12 @@ interface ChatLandingProps { preSelectedMachine?: string; preSelectedProject?: string; preSelectedRepo?: string; + /** + * Mirrors the composer's effective selection back into the chat-route URL + * (with `replace`) once the URL names a selection. Passed by the desktop + * chat route only; mobile keeps its base-context model. + */ + onSelectionUrlSync?: (search: ChatLandingSearch) => void; resetDraftKey?: string; resetDraftOnKeyChange?: boolean; } @@ -546,6 +556,7 @@ function WorkspaceChatLanding({ preSelectedMachine, preSelectedProject, preSelectedRepo, + onSelectionUrlSync, resetDraftKey, resetDraftOnKeyChange = true, }: ChatLandingProps) { @@ -1145,6 +1156,9 @@ function WorkspaceChatLanding({ const fireProjectSelectedOnChange = useFireOnKeyChange(); const fireAgentConfigOnChange = useFireOnKeyChange(); const preSelectionAppliedRef = useRef(null); + // False while a just-applied URL intent has not rendered yet; the selection + // mirror must not compare against that pre-application state. + const selectionSyncArmedRef = useRef(false); const selectedLocalProjectRef = useRef(null); selectedLocalProjectRef.current = selectedLocalProject; // `machines` is read inside fetchLocalGitState only for an offline pre-check. @@ -1407,19 +1421,34 @@ function WorkspaceChatLanding({ }, []); // ── Apply pre-selection from search params ── - const preSelectionKey = `${preSelectedContext}|${preSelectedMachine}|${preSelectedProject}|${preSelectedRepo}`; + const preSelectionKey = buildChatLandingPreSelectionKey({ + context: preSelectedContext, + machine: preSelectedMachine, + project: preSelectedProject, + repo: preSelectedRepo, + }); useEffect(() => { if (preSelectionAppliedRef.current === preSelectionKey) return; preSelectionAppliedRef.current = preSelectionKey; + // The applied selection reaches state next render; disarm the mirror so it + // cannot race this intent with the still-stale selection (see the mirror + // effect below, which must run after this one). + selectionSyncArmedRef.current = false; if (preSelectedContext === 'chat') { setContextType('chat'); } else if (preSelectedContext === 'local' && preSelectedMachine && preSelectedProject) { setContextType('local'); - handleSelectedLocalProjectChange({ - machineId: preSelectedMachine as MachineId, - localProjectId: preSelectedProject as LocalProjectId, - }); + const currentProject = selectedLocalProjectRef.current; + if ( + currentProject?.machineId !== preSelectedMachine || + currentProject?.localProjectId !== preSelectedProject + ) { + handleSelectedLocalProjectChange({ + machineId: preSelectedMachine as MachineId, + localProjectId: preSelectedProject as LocalProjectId, + }); + } } else if (preSelectedRepo) { setContextType('github'); setSelectedRepo(preSelectedRepo); @@ -1433,6 +1462,54 @@ function WorkspaceChatLanding({ handleSelectedLocalProjectChange, ]); + // ── Mirror the effective selection back into the URL ── + // The composer owns the selection once pre-selection is applied. When the + // URL names a selection, it must keep telling the truth: steering or + // clearing the composer would otherwise leave a stale project in the URL, + // and re-activating that project's sidebar row would be an identical-URL + // no-op. A plain /chat URL names nothing and stays plain, so restored + // defaults and auto-selection never rewrite the home landing's address. + const selectionSearch = useMemo( + () => + getChatLandingSelectionSearch({ + contextType, + machineId: selectedLocalProject?.machineId ?? null, + localProjectId: selectedLocalProject?.localProjectId ?? null, + repoFullName: selectedRepo ?? null, + }), + [contextType, selectedLocalProject, selectedRepo] + ); + const urlNamesSelection = + preSelectedContext !== undefined || + preSelectedMachine !== undefined || + preSelectedProject !== undefined || + preSelectedRepo !== undefined; + useEffect(() => { + if (!onSelectionUrlSync) return; + const selectionKey = buildChatLandingPreSelectionKey({ + context: selectionSearch.context, + machine: selectionSearch.machine, + project: selectionSearch.project, + repo: selectionSearch.repo, + }); + const decision = getChatLandingSelectionSyncDecision({ + urlNamesSelection, + intentApplied: preSelectionAppliedRef.current === preSelectionKey, + armed: selectionSyncArmedRef.current, + urlKey: preSelectionKey, + selectionKey, + }); + if (decision === 'arm') { + selectionSyncArmedRef.current = true; + return; + } + if (decision !== 'sync') return; + // The URL will soon name this state-originated selection; stamp it as + // already applied so the pre-selection effect does not re-apply it. + preSelectionAppliedRef.current = selectionKey; + onSelectionUrlSync(selectionSearch); + }, [onSelectionUrlSync, urlNamesSelection, preSelectionKey, selectionSearch]); + // ── Machine-owner authorization check for local projects ── useEffect(() => { if (contextType !== 'local' || !selectedLocalProject) return; diff --git a/packages/components/src/components/loro-app-sidebar.tsx b/packages/components/src/components/loro-app-sidebar.tsx index da5350819..14fc56409 100644 --- a/packages/components/src/components/loro-app-sidebar.tsx +++ b/packages/components/src/components/loro-app-sidebar.tsx @@ -129,6 +129,7 @@ import { getLatestPullRequestInfo, type SessionListScope, } from './sessions/session-list-rows'; +import { getSelectedLocalProjectKey } from './chat/chat-landing-derived'; import { useSessionActions } from '@/hooks/use-session-actions'; import { useLocalProjectRemovalResultNotifications, @@ -473,37 +474,6 @@ function getSelectedSessionId(pathname: string, workspaceSlug: string | null): s return sessionId ? sessionId : null; } -function getSelectedLocalProjectKey( - pathname: string, - workspaceSlug: string | null, - search?: Record -): string | null { - const workspacePrefix = workspaceSlug ? `/${workspaceSlug}` : ''; - const normalizedPath = - workspaceSlug && pathname.startsWith(workspacePrefix) - ? pathname.slice(workspacePrefix.length) || '/' - : pathname; - - const segments = normalizedPath.split('/').filter(Boolean); - - // New route: /chat?context=local&machine=X&project=Y - if ( - segments[0] === 'chat' && - search?.context === 'local' && - typeof search?.machine === 'string' && - typeof search?.project === 'string' - ) { - return `${search.machine}:${search.project}`; - } - - // Legacy route: /local/$machineId/$localProjectId - if (segments[0] !== 'local') return null; - const machineId = segments[1]; - const localProjectId = segments[2]; - if (!machineId || !localProjectId) return null; - return `${machineId}:${localProjectId}`; -} - function isHomeRoute(pathname: string, workspaceSlug: string | null): boolean { const workspacePrefix = workspaceSlug ? `/${workspaceSlug}` : ''; const normalizedPath = @@ -1672,6 +1642,10 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { (machineId: MachineId, localProjectId: string) => { if (!workspaceSlug) return; closeMobileDrawer(); + // The landing mirrors composer steering back into the URL, so the URL + // names the live selection: a click on an already-selected project is an + // identical-URL no-op, and any other click is an ordinary search change + // the landing's pre-selection effect applies. void router.navigate({ to: '/$workspaceName/chat', params: { workspaceName: workspaceSlug }, diff --git a/packages/components/src/routes/$workspaceName/_auth/chat.tsx b/packages/components/src/routes/$workspaceName/_auth/chat.tsx index e98f3eeb3..6b5dd2958 100644 --- a/packages/components/src/routes/$workspaceName/_auth/chat.tsx +++ b/packages/components/src/routes/$workspaceName/_auth/chat.tsx @@ -1,38 +1,37 @@ import { createFileRoute } from '@tanstack/react-router'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useSetAtom } from 'jotai'; import { ChatLanding } from '@/components/chat/chat-landing'; +import { + parseChatLandingSearch, + type ChatLandingSearch, +} from '@/components/chat/chat-landing-derived'; import { useIsMobile } from '@/hooks/use-mobile'; import { mobileWorkspaceBaseContextAtom } from '@/atoms'; -export type ChatSearch = { - context?: 'local' | 'github' | 'chat'; - machine?: string; - project?: string; - repo?: string; - resetDraftKey?: string; -}; +export type ChatSearch = ChatLandingSearch; export const Route = createFileRoute('/$workspaceName/_auth/chat')({ component: ChatRoute, - validateSearch: (search: Record): ChatSearch => ({ - context: - search.context === 'local' || search.context === 'github' || search.context === 'chat' - ? search.context - : undefined, - machine: typeof search.machine === 'string' ? search.machine : undefined, - project: typeof search.project === 'string' ? search.project : undefined, - repo: typeof search.repo === 'string' ? search.repo : undefined, - resetDraftKey: typeof search.resetDraftKey === 'string' ? search.resetDraftKey : undefined, - }), + validateSearch: parseChatLandingSearch, }); function ChatRoute() { const { workspaceName } = Route.useParams(); const search = Route.useSearch(); + const navigate = Route.useNavigate(); const isMobile = useIsMobile(); const setMobileBaseContext = useSetAtom(mobileWorkspaceBaseContextAtom); + // Selection steering is an in-place correction of the current address, not + // a visit to a new page, so the mirror always replaces. + const handleSelectionUrlSync = useCallback( + (selectionSearch: ChatLandingSearch) => { + void navigate({ search: selectionSearch, replace: true }); + }, + [navigate] + ); + /* On mobile the home/project landing is owned by `MobileWorkspaceStack` (so it stays mounted beneath the session overlay). Publish this route's context so the stack can keep rendering the right page once the user @@ -61,6 +60,7 @@ function ChatRoute() { preSelectedProject={search.project} preSelectedRepo={search.repo} resetDraftKey={search.resetDraftKey} + onSelectionUrlSync={handleSelectionUrlSync} /> ); } diff --git a/packages/components/tests/chat-landing-derived.test.ts b/packages/components/tests/chat-landing-derived.test.ts index b39301483..266247d8b 100644 --- a/packages/components/tests/chat-landing-derived.test.ts +++ b/packages/components/tests/chat-landing-derived.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + buildChatLandingPreSelectionKey, compareChatLandingLocalProjectByRecency, compareChatLandingRepositoryByRecency, + getChatLandingSelectionSearch, + getChatLandingSelectionSyncDecision, getChatLandingBranchSelectorState, getChatLandingHasAnyOnlineMachine, getChatLandingHintType, @@ -17,7 +20,9 @@ import { getSharingReviewSourcesReady, getSharingReviewTeamHasNoVisibleLocalResources, getSharingReviewTeamLooksEmpty, + getSelectedLocalProjectKey, isChatLandingMachineReachable, + parseChatLandingSearch, shouldRetrySharingReviewConflict, } from '../src/components/chat/chat-landing-derived'; @@ -1043,3 +1048,198 @@ describe('chat landing machine online state', () => { ).toBe(true); }); }); + +describe('buildChatLandingPreSelectionKey', () => { + const projectIntent = { + context: 'local' as const, + machine: 'machine-1', + project: 'local-project-1', + repo: undefined, + }; + + it('is stable while the URL names the same target', () => { + expect(buildChatLandingPreSelectionKey(projectIntent)).toBe( + buildChatLandingPreSelectionKey({ ...projectIntent }) + ); + }); + + it('separates different targets', () => { + expect(buildChatLandingPreSelectionKey(projectIntent)).not.toBe( + buildChatLandingPreSelectionKey({ ...projectIntent, project: 'local-project-2' }) + ); + }); +}); + +describe('parseChatLandingSearch', () => { + it('keeps the string search params the chat route understands', () => { + expect( + parseChatLandingSearch({ + context: 'local', + machine: 'machine-1', + project: 'local-project-1', + repo: 'owner/repo', + resetDraftKey: 'r1', + }) + ).toEqual({ + context: 'local', + machine: 'machine-1', + project: 'local-project-1', + repo: 'owner/repo', + resetDraftKey: 'r1', + }); + }); + + it('drops unknown contexts and non-string values', () => { + expect( + parseChatLandingSearch({ + context: 'remote', + machine: 7, + project: null, + resetDraftKey: ['r1'], + }) + ).toEqual({ + context: undefined, + machine: undefined, + project: undefined, + repo: undefined, + resetDraftKey: undefined, + }); + }); +}); + +describe('getSelectedLocalProjectKey', () => { + it('reads the chat route search under the workspace prefix', () => { + expect( + getSelectedLocalProjectKey('/acme/chat', 'acme', { + context: 'local', + machine: 'machine-1', + project: 'local-project-1', + }) + ).toBe('machine-1:local-project-1'); + }); + + it('reads the legacy local project route', () => { + expect(getSelectedLocalProjectKey('/acme/local/machine-1/local-project-1', 'acme')).toBe( + 'machine-1:local-project-1' + ); + }); + + it('names no project for other locations', () => { + expect(getSelectedLocalProjectKey('/acme/chat', 'acme', { context: 'github' })).toBeNull(); + expect(getSelectedLocalProjectKey('/acme/sessions/s1', 'acme')).toBeNull(); + }); +}); + +describe('getChatLandingSelectionSearch', () => { + it('names a complete local project selection', () => { + expect( + getChatLandingSelectionSearch({ + contextType: 'local', + machineId: 'machine-1', + localProjectId: 'local-project-1', + repoFullName: null, + }) + ).toEqual({ context: 'local', machine: 'machine-1', project: 'local-project-1' }); + }); + + it('names the chats-only context', () => { + expect( + getChatLandingSelectionSearch({ + contextType: 'chat', + machineId: 'machine-1', + localProjectId: null, + repoFullName: null, + }) + ).toEqual({ context: 'chat' }); + }); + + it('names a complete github selection', () => { + expect( + getChatLandingSelectionSearch({ + contextType: 'github', + machineId: null, + localProjectId: null, + repoFullName: 'owner/repo', + }) + ).toEqual({ context: 'github', repo: 'owner/repo' }); + }); + + it('maps incomplete selections to a URL that names nothing', () => { + expect( + getChatLandingSelectionSearch({ + contextType: 'local', + machineId: 'machine-1', + localProjectId: null, + repoFullName: null, + }) + ).toEqual({}); + expect( + getChatLandingSelectionSearch({ + contextType: 'github', + machineId: null, + localProjectId: null, + repoFullName: null, + }) + ).toEqual({}); + }); +}); + +describe('getChatLandingSelectionSyncDecision', () => { + const drifted = { urlKey: 'url-selection', selectionKey: 'composer-selection' }; + + it('never touches a URL that names nothing', () => { + expect( + getChatLandingSelectionSyncDecision({ + ...drifted, + urlNamesSelection: false, + intentApplied: true, + armed: true, + }) + ).toBe('skip'); + }); + + it('waits while the current URL intent has not been applied yet', () => { + expect( + getChatLandingSelectionSyncDecision({ + ...drifted, + urlNamesSelection: true, + intentApplied: false, + armed: true, + }) + ).toBe('skip'); + }); + + it('arms on the commit that applied an intent instead of racing it', () => { + expect( + getChatLandingSelectionSyncDecision({ + ...drifted, + urlNamesSelection: true, + intentApplied: true, + armed: false, + }) + ).toBe('arm'); + }); + + it('syncs composer drift once armed', () => { + expect( + getChatLandingSelectionSyncDecision({ + ...drifted, + urlNamesSelection: true, + intentApplied: true, + armed: true, + }) + ).toBe('sync'); + }); + + it('leaves a truthful URL alone', () => { + expect( + getChatLandingSelectionSyncDecision({ + urlNamesSelection: true, + intentApplied: true, + armed: true, + urlKey: 'same-selection', + selectionKey: 'same-selection', + }) + ).toBe('skip'); + }); +}); diff --git a/packages/components/tests/chat-landing-selection-url-sync.test.ts b/packages/components/tests/chat-landing-selection-url-sync.test.ts new file mode 100644 index 000000000..9e9eaf469 --- /dev/null +++ b/packages/components/tests/chat-landing-selection-url-sync.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router'; + +import { + buildChatLandingPreSelectionKey, + getChatLandingSelectionSearch, + getSelectedLocalProjectKey, + parseChatLandingSearch, + type ChatLandingEffectiveSelection, + type ChatLandingSearch, +} from '../src/components/chat/chat-landing-derived'; + +const WORKSPACE = 'acme'; + +/** + * Headless router over the real chat-route search contract + * (`parseChatLandingSearch`), driving the same navigations the app wires + * together: sidebar project-row clicks push ordinary pre-selection intents, + * and the landing's selection mirror replaces the URL with + * `getChatLandingSelectionSearch` output when the composer steers away — so + * these tests pin the push/replace/Back semantics of that loop. + */ +function createChatRouter() { + const rootRoute = createRootRoute(); + const workspaceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '$workspaceName', + }); + const chatRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: 'chat', + validateSearch: parseChatLandingSearch, + }); + return createRouter({ + routeTree: rootRoute.addChildren([workspaceRoute.addChildren([chatRoute])]), + history: createMemoryHistory({ initialEntries: [`/${WORKSPACE}/chat`] }), + }); +} + +type ChatRouter = ReturnType; + +/** Mirrors `LoroAppSidebar`'s `handleNavigateToProject` wiring. */ +async function activateProjectRow(router: ChatRouter, machineId: string, localProjectId: string) { + await router.navigate({ + to: '/$workspaceName/chat', + params: { workspaceName: WORKSPACE }, + search: { context: 'local' as const, machine: machineId, project: localProjectId }, + }); +} + +/** Mirrors `ChatRoute`'s `onSelectionUrlSync` handler fed by the landing. */ +async function syncComposerSelection(router: ChatRouter, selection: ChatLandingEffectiveSelection) { + await router.navigate({ + to: '/$workspaceName/chat', + params: { workspaceName: WORKSPACE }, + search: getChatLandingSelectionSearch(selection), + replace: true, + }); +} + +function currentChatSearch(router: ChatRouter): ChatLandingSearch { + return parseChatLandingSearch(router.state.location.search); +} + +/** The chat search at the history's CURRENT entry, bypassing router load state. */ +function historyChatSearch(router: ChatRouter): ChatLandingSearch { + return parseChatLandingSearch( + Object.fromEntries(new URLSearchParams(router.history.location.search)) + ); +} + +function preSelectionKeyOf(search: ChatLandingSearch): string { + return buildChatLandingPreSelectionKey({ + context: search.context, + machine: search.machine, + project: search.project, + repo: search.repo, + }); +} + +describe('chat landing selection URL loop', () => { + it('pushes a project-row click as a fresh pre-selection intent', async () => { + const router = createChatRouter(); + await router.load(); + const plainKey = preSelectionKeyOf(currentChatSearch(router)); + + await activateProjectRow(router, 'machine-1', 'project-a'); + + expect(router.history.length).toBe(2); + const search = currentChatSearch(router); + expect(search).toMatchObject({ context: 'local', machine: 'machine-1', project: 'project-a' }); + expect(preSelectionKeyOf(search)).not.toBe(plainKey); + }); + + it('treats re-activating the still-selected project as an identical-URL no-op', async () => { + const router = createChatRouter(); + await router.load(); + await activateProjectRow(router, 'machine-1', 'project-a'); + + await activateProjectRow(router, 'machine-1', 'project-a'); + + expect(router.history.length).toBe(2); + expect(currentChatSearch(router)).toMatchObject({ project: 'project-a' }); + }); + + it('keeps the URL truthful when the composer steers away, so the row works again', async () => { + const router = createChatRouter(); + await router.load(); + await activateProjectRow(router, 'machine-1', 'project-a'); + + // The user picks "Only chats" in the composer; the mirror replaces in + // place, so no history entry appears and the row highlight clears. + await syncComposerSelection(router, { + contextType: 'chat', + machineId: null, + localProjectId: null, + repoFullName: null, + }); + expect(router.history.length).toBe(2); + expect(currentChatSearch(router)).toEqual({ context: 'chat' }); + expect( + getSelectedLocalProjectKey( + router.state.location.pathname, + WORKSPACE, + router.state.location.search + ) + ).toBeNull(); + + // Clicking the same project row is now an ordinary search change that the + // pre-selection effect applies. + const clearedKey = preSelectionKeyOf(currentChatSearch(router)); + await activateProjectRow(router, 'machine-1', 'project-a'); + expect(router.history.length).toBe(3); + expect(preSelectionKeyOf(currentChatSearch(router))).not.toBe(clearedKey); + + // Back walks real states: the cleared selection, then the plain landing. + router.history.back(); + expect(historyChatSearch(router)).toEqual({ context: 'chat' }); + router.history.back(); + expect(historyChatSearch(router)).toEqual({}); + }); + + it('replaces an incomplete selection with a URL that names nothing', async () => { + const router = createChatRouter(); + await router.load(); + await activateProjectRow(router, 'machine-1', 'project-a'); + + // E.g. the project became unavailable and was cleared before any + // replacement selection existed. + await syncComposerSelection(router, { + contextType: 'github', + machineId: null, + localProjectId: null, + repoFullName: null, + }); + + expect(router.history.length).toBe(2); + expect(currentChatSearch(router)).toEqual({}); + }); +});