From ae01afc8b9f8531553300ba43c680690b18a529d Mon Sep 17 00:00:00 2001 From: Acbox Date: Thu, 27 Aug 2026 23:23:32 +0800 Subject: [PATCH 1/4] feat(components): start a session from a project row Add a hover-revealed new-session button to each local project row in the sidebar, beside the existing remove button, so a folder can be composed against without first hunting for it in the composer's project picker. The row's own click asks to LOOK at a project and keeps navigating to the plain project URL. This button asks to COMPOSE there, which must work even when that URL is already the current one: the composer applies a URL pre-selection once per target and then lets the user steer, so re-asserting a project the user has since cleared changes no search param and would otherwise be a no-op. The button therefore carries a `newSession` nonce that marks the navigation as a fresh intent, and the pre-selection identity now includes it. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- locales/en.json | 1 + locales/zh_CN.json | 1 + .../components/chat/chat-landing-derived.ts | 26 +++++ .../src/components/chat/chat-landing.tsx | 16 ++- .../src/components/loro-app-sidebar.tsx | 107 ++++++++++++++---- .../src/routes/$workspaceName/_auth/chat.tsx | 7 ++ .../src/stories/LoroSidebar.stories.tsx | 2 + .../tests/chat-landing-derived.test.ts | 38 +++++++ 8 files changed, 172 insertions(+), 26 deletions(-) diff --git a/locales/en.json b/locales/en.json index 469784097..148f98e3f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2736,6 +2736,7 @@ "sidebar.localProjects.empty": "No local projects yet", "sidebar.localProjects.forbidden": "Local project is not available", "sidebar.localProjects.import": "Import local project folder", + "sidebar.localProjects.newSession": "New session", "sidebar.localProjects.pending": "Imported. Waiting for local agent…", "sidebar.localProjects.remove": "Remove project", "sidebar.localProjects.remove.description": "This removes the project from Lody.", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 86834a228..8e4915147 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -2736,6 +2736,7 @@ "sidebar.localProjects.empty": "暂无本地项目", "sidebar.localProjects.forbidden": "无法访问本地项目", "sidebar.localProjects.import": "导入本地项目文件夹", + "sidebar.localProjects.newSession": "新建会话", "sidebar.localProjects.pending": "已导入,等待本地代理…", "sidebar.localProjects.remove": "删除项目", "sidebar.localProjects.remove.description": "这只会从 Lody 中移除该项目。", diff --git a/packages/components/src/components/chat/chat-landing-derived.ts b/packages/components/src/components/chat/chat-landing-derived.ts index b51b40f6f..f2bb7d140 100644 --- a/packages/components/src/components/chat/chat-landing-derived.ts +++ b/packages/components/src/components/chat/chat-landing-derived.ts @@ -575,3 +575,29 @@ 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; + /** + * Nonce marking an explicit "compose here" navigation. The composer applies a + * URL pre-selection once per key and then lets the user steer freely, so a + * caller that re-asserts the target the URL ALREADY names — the sidebar's + * per-project new-session button, after the user cleared the composer's + * project — changes no search param and would otherwise be a no-op. + */ + newSessionKey?: string | undefined; +}; + +/** Identity of one URL-driven pre-selection intent. */ +export function buildChatLandingPreSelectionKey({ + context, + machine, + project, + repo, + newSessionKey, +}: ChatLandingPreSelectionIntent): string { + return `${context}|${machine}|${project}|${repo}|${newSessionKey}`; +} diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index cfada3bbd..7488923e0 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -335,6 +335,7 @@ import { useMobileHomeExcludedSetAtom, } from '@/atoms/mobile-home-state'; import { + buildChatLandingPreSelectionKey, compareChatLandingLocalProjectByRecency, compareChatLandingRepositoryByRecency, getChatLandingBranchSelectorState, @@ -362,6 +363,12 @@ interface ChatLandingProps { preSelectedMachine?: string; preSelectedProject?: string; preSelectedRepo?: string; + /** + * Nonce marking an explicit "compose a new session here" navigation. Without + * it, re-asserting the project the URL already names changes no search param + * and leaves a composer the user has since cleared untouched. + */ + newSessionKey?: string; resetDraftKey?: string; resetDraftOnKeyChange?: boolean; } @@ -538,6 +545,7 @@ function WorkspaceChatLanding({ preSelectedMachine, preSelectedProject, preSelectedRepo, + newSessionKey, resetDraftKey, resetDraftOnKeyChange = true, }: ChatLandingProps) { @@ -1396,7 +1404,13 @@ 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, + newSessionKey, + }); useEffect(() => { if (preSelectionAppliedRef.current === preSelectionKey) return; preSelectionAppliedRef.current = preSelectionKey; diff --git a/packages/components/src/components/loro-app-sidebar.tsx b/packages/components/src/components/loro-app-sidebar.tsx index e9457d13f..c7c5f3a99 100644 --- a/packages/components/src/components/loro-app-sidebar.tsx +++ b/packages/components/src/components/loro-app-sidebar.tsx @@ -140,6 +140,7 @@ import { Pencil, Pin, PinOff, + Plus, Trash2, Users, } from 'lucide-react'; @@ -706,6 +707,7 @@ export type LocalProjectItemProps = { isMobile: boolean; toggleLabel: string; onNavigateProject: (machineId: MachineId, localProjectId: string) => void; + onStartProjectSession: (machineId: MachineId, localProjectId: string) => void; onNavigateSession: (sessionId: string, tabSessionId?: string) => void; onArchive: (sessionId: string) => void; onRenameSession?: (sessionId: string, nextTitle: string) => void | Promise; @@ -728,6 +730,18 @@ export type LocalProjectItemProps = { onRequestRemoval: (info: LocalProjectRemovalRequest) => void; }; +/** + * Hover-revealed actions on a project row (new session, remove). Both share one + * geometry so the pair reads as a single cluster and neither shifts the name. + */ +const LOCAL_PROJECT_ROW_ACTION_CLASS = cn( + 'absolute right-0 top-0 inline-flex h-5 w-5 items-center justify-center rounded-sm', + 'text-muted-foreground/70 transition-[opacity,background-color,color] duration-100', + 'opacity-0 pointer-events-none', + 'group-hover:opacity-100 group-hover:pointer-events-auto', + 'hover:text-foreground hover:bg-muted/30 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/60' +); + const LOCAL_PROJECT_SELECTION_PROP_KEYS: ReadonlySet = new Set(['selectedSessionId']); /** @@ -766,6 +780,7 @@ export const LocalProjectItem = memo(function LocalProjectItem({ isMobile, toggleLabel, onNavigateProject, + onStartProjectSession, onNavigateSession, onArchive, onRenameSession, @@ -781,6 +796,7 @@ export const LocalProjectItem = memo(function LocalProjectItem({ onRequestRemoval, }: LocalProjectItemProps) { const { t } = useTranslation(); + const newSessionLabel = t('sidebar.localProjects.newSession', 'New session'); // Same opened-by presentation the GitHub/Chats groups use: MCP-opened // independent Sessions indent under the Session that created them, and a // list with no such relationship keeps its previous flat geometry. @@ -879,31 +895,46 @@ export const LocalProjectItem = memo(function LocalProjectItem({ {project.name} - {canRemoveProject ? ( -
- + {canNavigateProject || canRemoveProject ? ( +
+ {canRemoveProject ? ( +
+ +
+ ) : null} + + {canNavigateProject ? ( +
+ +
+ ) : null}
) : null}
@@ -1420,6 +1451,31 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { [closeMobileDrawer, router, workspaceSlug] ); + /** + * The row's own click only asks to look at the project, so it navigates to + * the plain project URL. This button asks to COMPOSE there, which has to work + * even when that URL is already the current one — after clearing the + * composer's project, the search params alone carry no new information. The + * nonce is what marks this as a fresh intent for the composer to apply. + */ + const handleStartProjectSession = useCallback( + (machineId: MachineId, localProjectId: string) => { + if (!workspaceSlug) return; + closeMobileDrawer(); + void router.navigate({ + to: '/$workspaceName/chat', + params: { workspaceName: workspaceSlug }, + search: { + context: 'local' as const, + machine: machineId, + project: localProjectId, + newSession: `s${Date.now()}`, + }, + }); + }, + [closeMobileDrawer, router, workspaceSlug] + ); + const handleImportLocalProject = useCallback(async () => { if (!isElectron || !runtime) return; const selectDirectory = getIpcServices()?.localProjects.selectDirectory.bind( @@ -1940,6 +1996,7 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { isMobile={isMobile} toggleLabel={toggleLabel} onNavigateProject={handleNavigateToProject} + onStartProjectSession={handleStartProjectSession} onNavigateSession={handleNavigateToSession} onArchive={handleArchiveSession} onRenameSession={handleRenameSession} diff --git a/packages/components/src/routes/$workspaceName/_auth/chat.tsx b/packages/components/src/routes/$workspaceName/_auth/chat.tsx index e98f3eeb3..39e9fd1cd 100644 --- a/packages/components/src/routes/$workspaceName/_auth/chat.tsx +++ b/packages/components/src/routes/$workspaceName/_auth/chat.tsx @@ -11,6 +11,11 @@ export type ChatSearch = { project?: string; repo?: string; resetDraftKey?: string; + /** + * Nonce written by an explicit "compose a new session here" navigation, so a + * target the URL already names still reaches the composer as a fresh intent. + */ + newSession?: string; }; export const Route = createFileRoute('/$workspaceName/_auth/chat')({ @@ -24,6 +29,7 @@ export const Route = createFileRoute('/$workspaceName/_auth/chat')({ project: typeof search.project === 'string' ? search.project : undefined, repo: typeof search.repo === 'string' ? search.repo : undefined, resetDraftKey: typeof search.resetDraftKey === 'string' ? search.resetDraftKey : undefined, + newSession: typeof search.newSession === 'string' ? search.newSession : undefined, }), }); @@ -61,6 +67,7 @@ function ChatRoute() { preSelectedProject={search.project} preSelectedRepo={search.repo} resetDraftKey={search.resetDraftKey} + newSessionKey={search.newSession} /> ); } diff --git a/packages/components/src/stories/LoroSidebar.stories.tsx b/packages/components/src/stories/LoroSidebar.stories.tsx index 07b55089f..2e0e4c5b9 100644 --- a/packages/components/src/stories/LoroSidebar.stories.tsx +++ b/packages/components/src/stories/LoroSidebar.stories.tsx @@ -1010,6 +1010,7 @@ function ProductionLikeTopContent({ isMobile={isMobile} toggleLabel="Toggle" onNavigateProject={() => {}} + onStartProjectSession={() => {}} onNavigateSession={() => {}} onArchive={() => {}} collapsedOpenedBySessionIds={collapsedOpenedBySessionIds} @@ -1063,6 +1064,7 @@ function ProductionLikeTopContent({ isMobile={isMobile} toggleLabel="Toggle" onNavigateProject={() => {}} + onStartProjectSession={() => {}} onNavigateSession={() => {}} onArchive={() => {}} collapsedOpenedBySessionIds={collapsedOpenedBySessionIds} diff --git a/packages/components/tests/chat-landing-derived.test.ts b/packages/components/tests/chat-landing-derived.test.ts index b39301483..44358b4a0 100644 --- a/packages/components/tests/chat-landing-derived.test.ts +++ b/packages/components/tests/chat-landing-derived.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + buildChatLandingPreSelectionKey, compareChatLandingLocalProjectByRecency, compareChatLandingRepositoryByRecency, getChatLandingBranchSelectorState, @@ -1043,3 +1044,40 @@ 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' }) + ); + }); + + // The sidebar's per-project "new session" button can navigate to the project + // the URL already names, after the user cleared the composer's project. The + // search params are then unchanged, so the nonce is the only thing that marks + // this as a fresh intent the composer must re-apply. + it('treats a new session nonce for the same target as a new intent', () => { + expect(buildChatLandingPreSelectionKey({ ...projectIntent, newSessionKey: 'a' })).not.toBe( + buildChatLandingPreSelectionKey({ ...projectIntent, newSessionKey: 'b' }) + ); + }); + + it('keeps one intent stable across re-renders', () => { + expect(buildChatLandingPreSelectionKey({ ...projectIntent, newSessionKey: 'a' })).toBe( + buildChatLandingPreSelectionKey({ ...projectIntent, newSessionKey: 'a' }) + ); + }); +}); From 309599e4bc367d20c3241116671670048ea12268 Mon Sep 17 00:00:00 2001 From: sheepbox8646 Date: Sat, 29 Aug 2026 20:54:39 +0800 Subject: [PATCH 2/4] fix(components): reapply project selection from sidebar Remove the overlapping hover action and make the project row itself carry a fresh selection intent on every activation. Preserve draft text while re-applying the URL-selected project, including repeated clicks within the same millisecond. Model: gpt-5 --- locales/en.json | 1 - locales/zh_CN.json | 1 - .../components/chat/chat-landing-derived.ts | 22 ++-- .../src/components/chat/chat-landing.tsx | 12 +- .../src/components/loro-app-sidebar.tsx | 107 +++++------------- .../src/routes/$workspaceName/_auth/chat.tsx | 12 +- .../stories/LocalProjectRemovalUX.stories.tsx | 2 - .../src/stories/LoroSidebar.stories.tsx | 2 - .../tests/chat-landing-derived.test.ts | 23 ++-- 9 files changed, 64 insertions(+), 118 deletions(-) diff --git a/locales/en.json b/locales/en.json index 84c802dc0..bfe01376c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2761,7 +2761,6 @@ "sidebar.localProjects.empty": "No local projects yet", "sidebar.localProjects.forbidden": "Local project is not available", "sidebar.localProjects.import": "Import local project folder", - "sidebar.localProjects.newSession": "New session", "sidebar.localProjects.pending": "Imported. Waiting for local agent…", "sidebar.localProjects.remove": "Remove project", "sidebar.localProjects.remove.archiveDescription": "{{count}} conversations will move to Archive.", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 588c65c26..0be59881e 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -2761,7 +2761,6 @@ "sidebar.localProjects.empty": "暂无本地项目", "sidebar.localProjects.forbidden": "无法访问本地项目", "sidebar.localProjects.import": "导入本地项目文件夹", - "sidebar.localProjects.newSession": "新建会话", "sidebar.localProjects.pending": "已导入,等待本地代理…", "sidebar.localProjects.remove": "移除项目", "sidebar.localProjects.remove.archiveDescription": "{{count}} 个对话将移到“归档”。", diff --git a/packages/components/src/components/chat/chat-landing-derived.ts b/packages/components/src/components/chat/chat-landing-derived.ts index f2bb7d140..f6bcbaa2b 100644 --- a/packages/components/src/components/chat/chat-landing-derived.ts +++ b/packages/components/src/components/chat/chat-landing-derived.ts @@ -582,22 +582,28 @@ export type ChatLandingPreSelectionIntent = { project: string | undefined; repo: string | undefined; /** - * Nonce marking an explicit "compose here" navigation. The composer applies a - * URL pre-selection once per key and then lets the user steer freely, so a - * caller that re-asserts the target the URL ALREADY names — the sidebar's - * per-project new-session button, after the user cleared the composer's - * project — changes no search param and would otherwise be a no-op. + * Nonce marking a project-row selection. The composer applies URL + * pre-selection once per key and then lets the user steer freely, so a + * repeated click on the project the URL already names needs a new identity. */ - newSessionKey?: string | undefined; + projectSelectionKey?: string | undefined; }; +let projectSelectionSequence = 0; + +/** A fresh identity for every project-row activation, including same-millisecond clicks. */ +export function createChatLandingProjectSelectionKey(now = Date.now()): string { + projectSelectionSequence += 1; + return `p${now}-${projectSelectionSequence}`; +} + /** Identity of one URL-driven pre-selection intent. */ export function buildChatLandingPreSelectionKey({ context, machine, project, repo, - newSessionKey, + projectSelectionKey, }: ChatLandingPreSelectionIntent): string { - return `${context}|${machine}|${project}|${repo}|${newSessionKey}`; + return `${context}|${machine}|${project}|${repo}|${projectSelectionKey}`; } diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index aa197fcd7..e6b2098d8 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -371,12 +371,8 @@ interface ChatLandingProps { preSelectedMachine?: string; preSelectedProject?: string; preSelectedRepo?: string; - /** - * Nonce marking an explicit "compose a new session here" navigation. Without - * it, re-asserting the project the URL already names changes no search param - * and leaves a composer the user has since cleared untouched. - */ - newSessionKey?: string; + /** Makes a repeated project-row selection a fresh composer intent. */ + projectSelectionKey?: string; resetDraftKey?: string; resetDraftOnKeyChange?: boolean; } @@ -553,7 +549,7 @@ function WorkspaceChatLanding({ preSelectedMachine, preSelectedProject, preSelectedRepo, - newSessionKey, + projectSelectionKey, resetDraftKey, resetDraftOnKeyChange = true, }: ChatLandingProps) { @@ -1420,7 +1416,7 @@ function WorkspaceChatLanding({ machine: preSelectedMachine, project: preSelectedProject, repo: preSelectedRepo, - newSessionKey, + projectSelectionKey, }); useEffect(() => { if (preSelectionAppliedRef.current === preSelectionKey) return; diff --git a/packages/components/src/components/loro-app-sidebar.tsx b/packages/components/src/components/loro-app-sidebar.tsx index bd835cb2f..f0bc1ca5a 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 { createChatLandingProjectSelectionKey } from './chat/chat-landing-derived'; import { useSessionActions } from '@/hooks/use-session-actions'; import { useLocalProjectRemovalResultNotifications, @@ -148,7 +149,6 @@ import { Pencil, Pin, PinOff, - Plus, Trash2, Users, } from 'lucide-react'; @@ -939,7 +939,6 @@ export type LocalProjectItemProps = { isMobile: boolean; toggleLabel: string; onNavigateProject: (machineId: MachineId, localProjectId: string) => void; - onStartProjectSession: (machineId: MachineId, localProjectId: string) => void; onNavigateSession: (sessionId: string, tabSessionId?: string) => void; onArchive: (sessionId: string) => void; onRenameSession?: (sessionId: string, nextTitle: string) => void | Promise; @@ -962,18 +961,6 @@ export type LocalProjectItemProps = { onRequestRemoval: (info: LocalProjectRemovalRequest) => void; }; -/** - * Hover-revealed actions on a project row (new session, remove). Both share one - * geometry so the pair reads as a single cluster and neither shifts the name. - */ -const LOCAL_PROJECT_ROW_ACTION_CLASS = cn( - 'absolute right-0 top-0 inline-flex h-5 w-5 items-center justify-center rounded-sm', - 'text-muted-foreground/70 transition-[opacity,background-color,color] duration-100', - 'opacity-0 pointer-events-none', - 'group-hover:opacity-100 group-hover:pointer-events-auto', - 'hover:text-foreground hover:bg-muted/30 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/60' -); - const LOCAL_PROJECT_SELECTION_PROP_KEYS: ReadonlySet = new Set(['selectedSessionId']); /** @@ -1013,7 +1000,6 @@ export const LocalProjectItem = memo(function LocalProjectItem({ isMobile, toggleLabel, onNavigateProject, - onStartProjectSession, onNavigateSession, onArchive, onRenameSession, @@ -1029,7 +1015,6 @@ export const LocalProjectItem = memo(function LocalProjectItem({ onRequestRemoval, }: LocalProjectItemProps) { const { t } = useTranslation(); - const newSessionLabel = t('sidebar.localProjects.newSession', 'New session'); // Same opened-by presentation the GitHub/Chats groups use: MCP-opened // independent Sessions indent under the Session that created them, and a // list with no such relationship keeps its previous flat geometry. @@ -1151,47 +1136,32 @@ export const LocalProjectItem = memo(function LocalProjectItem({ {removalStateLabel} - ) : canNavigateProject || canRemoveProject ? ( -
- {canRemoveProject ? ( -
- -
- ) : null} - - {canNavigateProject ? ( -
- -
- ) : null} + ) : canRemoveProject ? ( +
+
) : null}
@@ -1700,26 +1670,6 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { ); const handleNavigateToProject = useCallback( - (machineId: MachineId, localProjectId: string) => { - if (!workspaceSlug) return; - closeMobileDrawer(); - void router.navigate({ - to: '/$workspaceName/chat', - params: { workspaceName: workspaceSlug }, - search: { context: 'local' as const, machine: machineId, project: localProjectId }, - }); - }, - [closeMobileDrawer, router, workspaceSlug] - ); - - /** - * The row's own click only asks to look at the project, so it navigates to - * the plain project URL. This button asks to COMPOSE there, which has to work - * even when that URL is already the current one — after clearing the - * composer's project, the search params alone carry no new information. The - * nonce is what marks this as a fresh intent for the composer to apply. - */ - const handleStartProjectSession = useCallback( (machineId: MachineId, localProjectId: string) => { if (!workspaceSlug) return; closeMobileDrawer(); @@ -1730,7 +1680,7 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { context: 'local' as const, machine: machineId, project: localProjectId, - newSession: `s${Date.now()}`, + projectSelection: createChatLandingProjectSelectionKey(), }, }); }, @@ -2289,7 +2239,6 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { isMobile={isMobile} toggleLabel={toggleLabel} onNavigateProject={handleNavigateToProject} - onStartProjectSession={handleStartProjectSession} onNavigateSession={handleNavigateToSession} onArchive={handleArchiveSession} onRenameSession={handleRenameSession} diff --git a/packages/components/src/routes/$workspaceName/_auth/chat.tsx b/packages/components/src/routes/$workspaceName/_auth/chat.tsx index 39e9fd1cd..7e15ec766 100644 --- a/packages/components/src/routes/$workspaceName/_auth/chat.tsx +++ b/packages/components/src/routes/$workspaceName/_auth/chat.tsx @@ -11,11 +11,8 @@ export type ChatSearch = { project?: string; repo?: string; resetDraftKey?: string; - /** - * Nonce written by an explicit "compose a new session here" navigation, so a - * target the URL already names still reaches the composer as a fresh intent. - */ - newSession?: string; + /** Makes a repeated project-row selection a fresh composer intent. */ + projectSelection?: string; }; export const Route = createFileRoute('/$workspaceName/_auth/chat')({ @@ -29,7 +26,8 @@ export const Route = createFileRoute('/$workspaceName/_auth/chat')({ project: typeof search.project === 'string' ? search.project : undefined, repo: typeof search.repo === 'string' ? search.repo : undefined, resetDraftKey: typeof search.resetDraftKey === 'string' ? search.resetDraftKey : undefined, - newSession: typeof search.newSession === 'string' ? search.newSession : undefined, + projectSelection: + typeof search.projectSelection === 'string' ? search.projectSelection : undefined, }), }); @@ -67,7 +65,7 @@ function ChatRoute() { preSelectedProject={search.project} preSelectedRepo={search.repo} resetDraftKey={search.resetDraftKey} - newSessionKey={search.newSession} + projectSelectionKey={search.projectSelection} /> ); } diff --git a/packages/components/src/stories/LocalProjectRemovalUX.stories.tsx b/packages/components/src/stories/LocalProjectRemovalUX.stories.tsx index 03e6c2ac2..aeef3fe4e 100644 --- a/packages/components/src/stories/LocalProjectRemovalUX.stories.tsx +++ b/packages/components/src/stories/LocalProjectRemovalUX.stories.tsx @@ -82,7 +82,6 @@ function ProjectRow({ state }: { state?: LocalProjectRemovalState }) { isMobile={false} toggleLabel="展开或收起对话" onNavigateProject={() => {}} - onStartProjectSession={() => {}} onNavigateSession={() => {}} onArchive={() => {}} collapsedOpenedBySessionIds={{}} @@ -158,7 +157,6 @@ function DesktopSidebar({ isMobile={false} toggleLabel="展开或收起对话" onNavigateProject={() => {}} - onStartProjectSession={() => {}} onNavigateSession={() => {}} onArchive={() => {}} collapsedOpenedBySessionIds={{}} diff --git a/packages/components/src/stories/LoroSidebar.stories.tsx b/packages/components/src/stories/LoroSidebar.stories.tsx index 183bf3a27..25b5d3d4f 100644 --- a/packages/components/src/stories/LoroSidebar.stories.tsx +++ b/packages/components/src/stories/LoroSidebar.stories.tsx @@ -1026,7 +1026,6 @@ function ProductionLikeTopContent({ isMobile={isMobile} toggleLabel="Toggle" onNavigateProject={() => {}} - onStartProjectSession={() => {}} onNavigateSession={() => {}} onArchive={() => {}} collapsedOpenedBySessionIds={collapsedOpenedBySessionIds} @@ -1080,7 +1079,6 @@ function ProductionLikeTopContent({ isMobile={isMobile} toggleLabel="Toggle" onNavigateProject={() => {}} - onStartProjectSession={() => {}} onNavigateSession={() => {}} onArchive={() => {}} collapsedOpenedBySessionIds={collapsedOpenedBySessionIds} diff --git a/packages/components/tests/chat-landing-derived.test.ts b/packages/components/tests/chat-landing-derived.test.ts index 44358b4a0..76248d307 100644 --- a/packages/components/tests/chat-landing-derived.test.ts +++ b/packages/components/tests/chat-landing-derived.test.ts @@ -20,6 +20,7 @@ import { getSharingReviewTeamLooksEmpty, isChatLandingMachineReachable, shouldRetrySharingReviewConflict, + createChatLandingProjectSelectionKey, } from '../src/components/chat/chat-landing-derived'; const onlineMachineIds = new Set(['github-runner']); @@ -1065,19 +1066,21 @@ describe('buildChatLandingPreSelectionKey', () => { ); }); - // The sidebar's per-project "new session" button can navigate to the project - // the URL already names, after the user cleared the composer's project. The - // search params are then unchanged, so the nonce is the only thing that marks - // this as a fresh intent the composer must re-apply. - it('treats a new session nonce for the same target as a new intent', () => { - expect(buildChatLandingPreSelectionKey({ ...projectIntent, newSessionKey: 'a' })).not.toBe( - buildChatLandingPreSelectionKey({ ...projectIntent, newSessionKey: 'b' }) - ); + it('treats each project-row selection for the same target as a new intent', () => { + expect( + buildChatLandingPreSelectionKey({ ...projectIntent, projectSelectionKey: 'a' }) + ).not.toBe(buildChatLandingPreSelectionKey({ ...projectIntent, projectSelectionKey: 'b' })); }); it('keeps one intent stable across re-renders', () => { - expect(buildChatLandingPreSelectionKey({ ...projectIntent, newSessionKey: 'a' })).toBe( - buildChatLandingPreSelectionKey({ ...projectIntent, newSessionKey: 'a' }) + expect(buildChatLandingPreSelectionKey({ ...projectIntent, projectSelectionKey: 'a' })).toBe( + buildChatLandingPreSelectionKey({ ...projectIntent, projectSelectionKey: 'a' }) + ); + }); + + it('creates distinct project selection keys inside the same millisecond', () => { + expect(createChatLandingProjectSelectionKey(123)).not.toBe( + createChatLandingProjectSelectionKey(123) ); }); }); From 68fce19091d07493c2925dd69c4b99c1602662d2 Mon Sep 17 00:00:00 2001 From: sheepbox8646 Date: Sat, 29 Aug 2026 22:07:42 +0800 Subject: [PATCH 3/4] fix(components): replace repeated project selection instead of pushing Every project-row activation still carries a fresh projectSelection nonce, but re-activating the project the URL already names now replaces the current history entry instead of pushing a duplicate, so Back leaves the project page instead of replaying older selection intents. The chat route's search contract (parseChatLandingSearch), the URL-selected project derivation (getSelectedLocalProjectKey), and the push/replace decision (buildChatLandingProjectSelectionNavigation) now live in chat-landing-derived, and a headless TanStack Router test exercises the wired chain over real memory-history semantics. Model: claude-fable-5 Co-Authored-By: Claude Fable 5 --- .../components/chat/chat-landing-derived.ts | 101 +++++++++++++ .../src/components/loro-app-sidebar.tsx | 51 ++----- .../src/routes/$workspaceName/_auth/chat.tsx | 27 +--- .../tests/chat-landing-derived.test.ts | 115 +++++++++++++++ ...nding-project-selection-navigation.test.ts | 134 ++++++++++++++++++ 5 files changed, 368 insertions(+), 60 deletions(-) create mode 100644 packages/components/tests/chat-landing-project-selection-navigation.test.ts diff --git a/packages/components/src/components/chat/chat-landing-derived.ts b/packages/components/src/components/chat/chat-landing-derived.ts index f6bcbaa2b..c1e0cadf0 100644 --- a/packages/components/src/components/chat/chat-landing-derived.ts +++ b/packages/components/src/components/chat/chat-landing-derived.ts @@ -607,3 +607,104 @@ export function buildChatLandingPreSelectionKey({ }: ChatLandingPreSelectionIntent): string { return `${context}|${machine}|${project}|${repo}|${projectSelectionKey}`; } + +/** Search-parameter contract of the `/$workspaceName/chat` route. */ +export type ChatLandingSearch = { + context?: 'local' | 'github' | 'chat'; + machine?: string; + project?: string; + repo?: string; + resetDraftKey?: string; + /** Makes a repeated project-row selection a fresh composer intent. */ + projectSelection?: 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, + projectSelection: + typeof search.projectSelection === 'string' ? search.projectSelection : undefined, + }; +} + +/** + * `machineId:localProjectId` named by the current URL, or null. Shared by the + * sidebar's row highlight and by project-row activation, which uses it to pick + * push versus replace (see `buildChatLandingProjectSelectionNavigation`). + */ +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 ChatLandingProjectSelectionNavigation = { + search: { + context: 'local'; + machine: string; + project: string; + projectSelection: string; + }; + replace: boolean; +}; + +/** + * Navigation for one project-row activation. The nonce makes every activation + * a fresh composer intent, so re-activating the project the URL already names + * must REPLACE: pushing would stack visually identical history entries whose + * nonces make Back re-apply the selection instead of leaving the page. + */ +export function buildChatLandingProjectSelectionNavigation({ + machineId, + localProjectId, + selectedLocalProjectKey, + now, +}: { + machineId: string; + localProjectId: string; + /** From `getSelectedLocalProjectKey` over the current location. */ + selectedLocalProjectKey: string | null; + now?: number; +}): ChatLandingProjectSelectionNavigation { + return { + search: { + context: 'local', + machine: machineId, + project: localProjectId, + projectSelection: createChatLandingProjectSelectionKey(now), + }, + replace: selectedLocalProjectKey === `${machineId}:${localProjectId}`, + }; +} diff --git a/packages/components/src/components/loro-app-sidebar.tsx b/packages/components/src/components/loro-app-sidebar.tsx index f0bc1ca5a..035e6ff9e 100644 --- a/packages/components/src/components/loro-app-sidebar.tsx +++ b/packages/components/src/components/loro-app-sidebar.tsx @@ -129,7 +129,10 @@ import { getLatestPullRequestInfo, type SessionListScope, } from './sessions/session-list-rows'; -import { createChatLandingProjectSelectionKey } from './chat/chat-landing-derived'; +import { + buildChatLandingProjectSelectionNavigation, + getSelectedLocalProjectKey, +} from './chat/chat-landing-derived'; import { useSessionActions } from '@/hooks/use-session-actions'; import { useLocalProjectRemovalResultNotifications, @@ -474,37 +477,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 = @@ -1673,18 +1645,19 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { (machineId: MachineId, localProjectId: string) => { if (!workspaceSlug) return; closeMobileDrawer(); + // Re-activating the project the URL already names replaces in place, so + // the fresh selection nonce never stacks history entries behind Back. void router.navigate({ to: '/$workspaceName/chat', params: { workspaceName: workspaceSlug }, - search: { - context: 'local' as const, - machine: machineId, - project: localProjectId, - projectSelection: createChatLandingProjectSelectionKey(), - }, + ...buildChatLandingProjectSelectionNavigation({ + machineId, + localProjectId, + selectedLocalProjectKey, + }), }); }, - [closeMobileDrawer, router, workspaceSlug] + [closeMobileDrawer, router, selectedLocalProjectKey, workspaceSlug] ); const handleImportLocalProject = useCallback(async () => { diff --git a/packages/components/src/routes/$workspaceName/_auth/chat.tsx b/packages/components/src/routes/$workspaceName/_auth/chat.tsx index 7e15ec766..280a2c03e 100644 --- a/packages/components/src/routes/$workspaceName/_auth/chat.tsx +++ b/packages/components/src/routes/$workspaceName/_auth/chat.tsx @@ -2,33 +2,18 @@ import { createFileRoute } from '@tanstack/react-router'; import { 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; - /** Makes a repeated project-row selection a fresh composer intent. */ - projectSelection?: 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, - projectSelection: - typeof search.projectSelection === 'string' ? search.projectSelection : undefined, - }), + validateSearch: parseChatLandingSearch, }); function ChatRoute() { diff --git a/packages/components/tests/chat-landing-derived.test.ts b/packages/components/tests/chat-landing-derived.test.ts index 76248d307..ef3a768f4 100644 --- a/packages/components/tests/chat-landing-derived.test.ts +++ b/packages/components/tests/chat-landing-derived.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { buildChatLandingPreSelectionKey, + buildChatLandingProjectSelectionNavigation, compareChatLandingLocalProjectByRecency, compareChatLandingRepositoryByRecency, getChatLandingBranchSelectorState, @@ -18,7 +19,9 @@ import { getSharingReviewSourcesReady, getSharingReviewTeamHasNoVisibleLocalResources, getSharingReviewTeamLooksEmpty, + getSelectedLocalProjectKey, isChatLandingMachineReachable, + parseChatLandingSearch, shouldRetrySharingReviewConflict, createChatLandingProjectSelectionKey, } from '../src/components/chat/chat-landing-derived'; @@ -1084,3 +1087,115 @@ describe('buildChatLandingPreSelectionKey', () => { ); }); }); + +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', + projectSelection: 'p1', + }) + ).toEqual({ + context: 'local', + machine: 'machine-1', + project: 'local-project-1', + repo: 'owner/repo', + resetDraftKey: 'r1', + projectSelection: 'p1', + }); + }); + + it('drops unknown contexts and non-string values', () => { + expect( + parseChatLandingSearch({ + context: 'remote', + machine: 7, + project: null, + projectSelection: ['p1'], + }) + ).toEqual({ + context: undefined, + machine: undefined, + project: undefined, + repo: undefined, + resetDraftKey: undefined, + projectSelection: 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('buildChatLandingProjectSelectionNavigation', () => { + const activation = { + machineId: 'machine-1', + localProjectId: 'local-project-1', + }; + + it('replaces when the URL already names the activated project', () => { + const navigation = buildChatLandingProjectSelectionNavigation({ + ...activation, + selectedLocalProjectKey: 'machine-1:local-project-1', + }); + expect(navigation.replace).toBe(true); + expect(navigation.search).toMatchObject({ + context: 'local', + machine: 'machine-1', + project: 'local-project-1', + }); + }); + + it('pushes when coming from another project or from no project', () => { + expect( + buildChatLandingProjectSelectionNavigation({ + ...activation, + selectedLocalProjectKey: 'machine-1:local-project-2', + }).replace + ).toBe(false); + expect( + buildChatLandingProjectSelectionNavigation({ + ...activation, + selectedLocalProjectKey: null, + }).replace + ).toBe(false); + }); + + it('carries a fresh selection nonce on every activation', () => { + const first = buildChatLandingProjectSelectionNavigation({ + ...activation, + selectedLocalProjectKey: null, + now: 123, + }); + const second = buildChatLandingProjectSelectionNavigation({ + ...activation, + selectedLocalProjectKey: 'machine-1:local-project-1', + now: 123, + }); + expect(second.search.projectSelection).not.toBe(first.search.projectSelection); + }); +}); diff --git a/packages/components/tests/chat-landing-project-selection-navigation.test.ts b/packages/components/tests/chat-landing-project-selection-navigation.test.ts new file mode 100644 index 000000000..d996f150f --- /dev/null +++ b/packages/components/tests/chat-landing-project-selection-navigation.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router'; + +import { + buildChatLandingPreSelectionKey, + buildChatLandingProjectSelectionNavigation, + getSelectedLocalProjectKey, + parseChatLandingSearch, + type ChatLandingSearch, +} from '../src/components/chat/chat-landing-derived'; + +const WORKSPACE = 'acme'; + +/** + * Headless router over the real chat-route search contract + * (`parseChatLandingSearch`), so these tests exercise the same chain the app + * wires together: project-row activation → URL search → validated route + * search → composer pre-selection key — including real push/replace history + * semantics. + */ +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 }, + ...buildChatLandingProjectSelectionNavigation({ + machineId, + localProjectId, + selectedLocalProjectKey: getSelectedLocalProjectKey( + router.state.location.pathname, + WORKSPACE, + router.state.location.search + ), + }), + }); +} + +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, + projectSelectionKey: search.projectSelection, + }); +} + +describe('project-row activation history semantics', () => { + it('pushes the first selection as a complete pre-selection intent', async () => { + const router = createChatRouter(); + await router.load(); + expect(router.history.length).toBe(1); + + await activateProjectRow(router, 'machine-1', 'project-a'); + + expect(router.history.length).toBe(2); + const search = currentChatSearch(router); + expect(search.context).toBe('local'); + expect(search.machine).toBe('machine-1'); + expect(search.project).toBe('project-a'); + expect(search.projectSelection).toBeTruthy(); + }); + + it('re-activating the selected project refreshes the intent without stacking history', async () => { + const router = createChatRouter(); + await router.load(); + await activateProjectRow(router, 'machine-1', 'project-a'); + const first = currentChatSearch(router); + + await activateProjectRow(router, 'machine-1', 'project-a'); + const second = currentChatSearch(router); + + // A fresh intent, so the landing re-applies the visible project… + expect(second.projectSelection).toBeTruthy(); + expect(second.projectSelection).not.toBe(first.projectSelection); + expect(preSelectionKeyOf(second)).not.toBe(preSelectionKeyOf(first)); + // …but in place: no second history entry for the same page. + expect(router.history.length).toBe(2); + + // Back therefore leaves the project page instead of replaying older nonces. + router.history.back(); + const afterBack = historyChatSearch(router); + expect(afterBack.project).toBeUndefined(); + expect(afterBack.projectSelection).toBeUndefined(); + }); + + it('switching projects pushes so Back returns to the previous project', async () => { + const router = createChatRouter(); + await router.load(); + await activateProjectRow(router, 'machine-1', 'project-a'); + await activateProjectRow(router, 'machine-1', 'project-b'); + + expect(router.history.length).toBe(3); + expect(currentChatSearch(router).project).toBe('project-b'); + + router.history.back(); + expect(historyChatSearch(router).project).toBe('project-a'); + }); +}); From 48eed49017b93e60ed2f3b48ee24f92d775ddab8 Mon Sep 17 00:00:00 2001 From: sheepbox8646 Date: Sat, 29 Aug 2026 22:33:58 +0800 Subject: [PATCH 4/4] fix(components): mirror composer selection into the chat URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the projectSelection URL nonce with a declarative model: the chat-route URL names the composer's current selection and nothing else. Once the URL names a selection, the desktop chat route's onSelectionUrlSync callback keeps it truthful — steering or clearing the composer replaces the URL in place, and an incomplete selection maps to an empty search. A sidebar project-row click is then either an identical-URL no-op or an ordinary search change applied by the pre-selection effect, so the repeated-click bug cannot recur in any steering corner. A plain /chat URL stays plain, preserving the home landing's address and nav highlight; mobile keeps its base-context model and passes no sync callback. Re-applying an already-selected project is now also guarded, fixing a latent wedge where a repeated application flagged local git state as loading without a load left to clear it. Headless TanStack Router tests drive the sidebar click and mirror navigations over real memory-history semantics; the sync decision and selection-search mapping are unit-tested. Model: claude-fable-5 Co-Authored-By: Claude Fable 5 --- .../components/src/components/chat/AGENTS.md | 7 + .../components/chat/chat-landing-derived.ts | 112 ++++++------ .../src/components/chat/chat-landing.tsx | 83 ++++++++- .../src/components/loro-app-sidebar.tsx | 19 +- .../src/routes/$workspaceName/_auth/chat.tsx | 14 +- .../tests/chat-landing-derived.test.ts | 170 +++++++++++------- ...nding-project-selection-navigation.test.ts | 134 -------------- .../chat-landing-selection-url-sync.test.ts | 165 +++++++++++++++++ 8 files changed, 432 insertions(+), 272 deletions(-) delete mode 100644 packages/components/tests/chat-landing-project-selection-navigation.test.ts create mode 100644 packages/components/tests/chat-landing-selection-url-sync.test.ts 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 c1e0cadf0..c9483db46 100644 --- a/packages/components/src/components/chat/chat-landing-derived.ts +++ b/packages/components/src/components/chat/chat-landing-derived.ts @@ -581,31 +581,16 @@ export type ChatLandingPreSelectionIntent = { machine: string | undefined; project: string | undefined; repo: string | undefined; - /** - * Nonce marking a project-row selection. The composer applies URL - * pre-selection once per key and then lets the user steer freely, so a - * repeated click on the project the URL already names needs a new identity. - */ - projectSelectionKey?: string | undefined; }; -let projectSelectionSequence = 0; - -/** A fresh identity for every project-row activation, including same-millisecond clicks. */ -export function createChatLandingProjectSelectionKey(now = Date.now()): string { - projectSelectionSequence += 1; - return `p${now}-${projectSelectionSequence}`; -} - -/** Identity of one URL-driven pre-selection intent. */ +/** Identity of one URL-named selection (pre-selection intent or mirrored state). */ export function buildChatLandingPreSelectionKey({ context, machine, project, repo, - projectSelectionKey, }: ChatLandingPreSelectionIntent): string { - return `${context}|${machine}|${project}|${repo}|${projectSelectionKey}`; + return `${context}|${machine}|${project}|${repo}`; } /** Search-parameter contract of the `/$workspaceName/chat` route. */ @@ -615,8 +600,6 @@ export type ChatLandingSearch = { project?: string; repo?: string; resetDraftKey?: string; - /** Makes a repeated project-row selection a fresh composer intent. */ - projectSelection?: string; }; export function parseChatLandingSearch(search: Record): ChatLandingSearch { @@ -629,15 +612,13 @@ export function parseChatLandingSearch(search: Record): ChatLan project: typeof search.project === 'string' ? search.project : undefined, repo: typeof search.repo === 'string' ? search.repo : undefined, resetDraftKey: typeof search.resetDraftKey === 'string' ? search.resetDraftKey : undefined, - projectSelection: - typeof search.projectSelection === 'string' ? search.projectSelection : undefined, }; } /** * `machineId:localProjectId` named by the current URL, or null. Shared by the - * sidebar's row highlight and by project-row activation, which uses it to pick - * push versus replace (see `buildChatLandingProjectSelectionNavigation`). + * sidebar's row highlight and by the selection-URL mirror's participation + * checks over the same URL contract. */ export function getSelectedLocalProjectKey( pathname: string, @@ -670,41 +651,66 @@ export function getSelectedLocalProjectKey( return `${machineId}:${localProjectId}`; } -export type ChatLandingProjectSelectionNavigation = { - search: { - context: 'local'; - machine: string; - project: string; - projectSelection: string; - }; - replace: boolean; +export type ChatLandingEffectiveSelection = { + contextType: 'local' | 'github' | 'chat'; + machineId: string | null; + localProjectId: string | null; + repoFullName: string | null; }; /** - * Navigation for one project-row activation. The nonce makes every activation - * a fresh composer intent, so re-activating the project the URL already names - * must REPLACE: pushing would stack visually identical history entries whose - * nonces make Back re-apply the selection instead of leaving the page. + * 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 buildChatLandingProjectSelectionNavigation({ +export function getChatLandingSelectionSearch({ + contextType, machineId, localProjectId, - selectedLocalProjectKey, - now, + 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, }: { - machineId: string; - localProjectId: string; - /** From `getSelectedLocalProjectKey` over the current location. */ - selectedLocalProjectKey: string | null; - now?: number; -}): ChatLandingProjectSelectionNavigation { - return { - search: { - context: 'local', - machine: machineId, - project: localProjectId, - projectSelection: createChatLandingProjectSelectionKey(now), - }, - replace: selectedLocalProjectKey === `${machineId}:${localProjectId}`, - }; + 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 e6b2098d8..369bb4dcd 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -345,6 +345,9 @@ import { import { buildChatLandingPreSelectionKey, compareChatLandingLocalProjectByRecency, + getChatLandingSelectionSearch, + getChatLandingSelectionSyncDecision, + type ChatLandingSearch, compareChatLandingRepositoryByRecency, getChatLandingBranchSelectorState, getChatLandingHasAnyOnlineMachine, @@ -371,8 +374,12 @@ interface ChatLandingProps { preSelectedMachine?: string; preSelectedProject?: string; preSelectedRepo?: string; - /** Makes a repeated project-row selection a fresh composer intent. */ - projectSelectionKey?: 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; } @@ -549,7 +556,7 @@ function WorkspaceChatLanding({ preSelectedMachine, preSelectedProject, preSelectedRepo, - projectSelectionKey, + onSelectionUrlSync, resetDraftKey, resetDraftOnKeyChange = true, }: ChatLandingProps) { @@ -1149,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. @@ -1416,20 +1426,29 @@ function WorkspaceChatLanding({ machine: preSelectedMachine, project: preSelectedProject, repo: preSelectedRepo, - projectSelectionKey, }); 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); @@ -1443,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 035e6ff9e..14fc56409 100644 --- a/packages/components/src/components/loro-app-sidebar.tsx +++ b/packages/components/src/components/loro-app-sidebar.tsx @@ -129,10 +129,7 @@ import { getLatestPullRequestInfo, type SessionListScope, } from './sessions/session-list-rows'; -import { - buildChatLandingProjectSelectionNavigation, - getSelectedLocalProjectKey, -} from './chat/chat-landing-derived'; +import { getSelectedLocalProjectKey } from './chat/chat-landing-derived'; import { useSessionActions } from '@/hooks/use-session-actions'; import { useLocalProjectRemovalResultNotifications, @@ -1645,19 +1642,17 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { (machineId: MachineId, localProjectId: string) => { if (!workspaceSlug) return; closeMobileDrawer(); - // Re-activating the project the URL already names replaces in place, so - // the fresh selection nonce never stacks history entries behind Back. + // 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 }, - ...buildChatLandingProjectSelectionNavigation({ - machineId, - localProjectId, - selectedLocalProjectKey, - }), + search: { context: 'local' as const, machine: machineId, project: localProjectId }, }); }, - [closeMobileDrawer, router, selectedLocalProjectKey, workspaceSlug] + [closeMobileDrawer, router, workspaceSlug] ); const handleImportLocalProject = useCallback(async () => { diff --git a/packages/components/src/routes/$workspaceName/_auth/chat.tsx b/packages/components/src/routes/$workspaceName/_auth/chat.tsx index 280a2c03e..6b5dd2958 100644 --- a/packages/components/src/routes/$workspaceName/_auth/chat.tsx +++ b/packages/components/src/routes/$workspaceName/_auth/chat.tsx @@ -1,5 +1,5 @@ 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 { @@ -19,9 +19,19 @@ export const Route = createFileRoute('/$workspaceName/_auth/chat')({ 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 @@ -50,7 +60,7 @@ function ChatRoute() { preSelectedProject={search.project} preSelectedRepo={search.repo} resetDraftKey={search.resetDraftKey} - projectSelectionKey={search.projectSelection} + onSelectionUrlSync={handleSelectionUrlSync} /> ); } diff --git a/packages/components/tests/chat-landing-derived.test.ts b/packages/components/tests/chat-landing-derived.test.ts index ef3a768f4..266247d8b 100644 --- a/packages/components/tests/chat-landing-derived.test.ts +++ b/packages/components/tests/chat-landing-derived.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest'; import { buildChatLandingPreSelectionKey, - buildChatLandingProjectSelectionNavigation, compareChatLandingLocalProjectByRecency, compareChatLandingRepositoryByRecency, + getChatLandingSelectionSearch, + getChatLandingSelectionSyncDecision, getChatLandingBranchSelectorState, getChatLandingHasAnyOnlineMachine, getChatLandingHintType, @@ -23,7 +24,6 @@ import { isChatLandingMachineReachable, parseChatLandingSearch, shouldRetrySharingReviewConflict, - createChatLandingProjectSelectionKey, } from '../src/components/chat/chat-landing-derived'; const onlineMachineIds = new Set(['github-runner']); @@ -1068,24 +1068,6 @@ describe('buildChatLandingPreSelectionKey', () => { buildChatLandingPreSelectionKey({ ...projectIntent, project: 'local-project-2' }) ); }); - - it('treats each project-row selection for the same target as a new intent', () => { - expect( - buildChatLandingPreSelectionKey({ ...projectIntent, projectSelectionKey: 'a' }) - ).not.toBe(buildChatLandingPreSelectionKey({ ...projectIntent, projectSelectionKey: 'b' })); - }); - - it('keeps one intent stable across re-renders', () => { - expect(buildChatLandingPreSelectionKey({ ...projectIntent, projectSelectionKey: 'a' })).toBe( - buildChatLandingPreSelectionKey({ ...projectIntent, projectSelectionKey: 'a' }) - ); - }); - - it('creates distinct project selection keys inside the same millisecond', () => { - expect(createChatLandingProjectSelectionKey(123)).not.toBe( - createChatLandingProjectSelectionKey(123) - ); - }); }); describe('parseChatLandingSearch', () => { @@ -1097,7 +1079,6 @@ describe('parseChatLandingSearch', () => { project: 'local-project-1', repo: 'owner/repo', resetDraftKey: 'r1', - projectSelection: 'p1', }) ).toEqual({ context: 'local', @@ -1105,7 +1086,6 @@ describe('parseChatLandingSearch', () => { project: 'local-project-1', repo: 'owner/repo', resetDraftKey: 'r1', - projectSelection: 'p1', }); }); @@ -1115,7 +1095,7 @@ describe('parseChatLandingSearch', () => { context: 'remote', machine: 7, project: null, - projectSelection: ['p1'], + resetDraftKey: ['r1'], }) ).toEqual({ context: undefined, @@ -1123,7 +1103,6 @@ describe('parseChatLandingSearch', () => { project: undefined, repo: undefined, resetDraftKey: undefined, - projectSelection: undefined, }); }); }); @@ -1151,51 +1130,116 @@ describe('getSelectedLocalProjectKey', () => { }); }); -describe('buildChatLandingProjectSelectionNavigation', () => { - const activation = { - machineId: 'machine-1', - localProjectId: 'local-project-1', - }; +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('replaces when the URL already names the activated project', () => { - const navigation = buildChatLandingProjectSelectionNavigation({ - ...activation, - selectedLocalProjectKey: 'machine-1:local-project-1', - }); - expect(navigation.replace).toBe(true); - expect(navigation.search).toMatchObject({ - 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('pushes when coming from another project or from no project', () => { + it('names a complete github selection', () => { expect( - buildChatLandingProjectSelectionNavigation({ - ...activation, - selectedLocalProjectKey: 'machine-1:local-project-2', - }).replace - ).toBe(false); + 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( - buildChatLandingProjectSelectionNavigation({ - ...activation, - selectedLocalProjectKey: null, - }).replace - ).toBe(false); + getChatLandingSelectionSearch({ + contextType: 'local', + machineId: 'machine-1', + localProjectId: null, + repoFullName: null, + }) + ).toEqual({}); + expect( + getChatLandingSelectionSearch({ + contextType: 'github', + machineId: null, + localProjectId: null, + repoFullName: null, + }) + ).toEqual({}); }); +}); - it('carries a fresh selection nonce on every activation', () => { - const first = buildChatLandingProjectSelectionNavigation({ - ...activation, - selectedLocalProjectKey: null, - now: 123, - }); - const second = buildChatLandingProjectSelectionNavigation({ - ...activation, - selectedLocalProjectKey: 'machine-1:local-project-1', - now: 123, - }); - expect(second.search.projectSelection).not.toBe(first.search.projectSelection); +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-project-selection-navigation.test.ts b/packages/components/tests/chat-landing-project-selection-navigation.test.ts deleted file mode 100644 index d996f150f..000000000 --- a/packages/components/tests/chat-landing-project-selection-navigation.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from '@tanstack/react-router'; - -import { - buildChatLandingPreSelectionKey, - buildChatLandingProjectSelectionNavigation, - getSelectedLocalProjectKey, - parseChatLandingSearch, - type ChatLandingSearch, -} from '../src/components/chat/chat-landing-derived'; - -const WORKSPACE = 'acme'; - -/** - * Headless router over the real chat-route search contract - * (`parseChatLandingSearch`), so these tests exercise the same chain the app - * wires together: project-row activation → URL search → validated route - * search → composer pre-selection key — including real push/replace history - * semantics. - */ -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 }, - ...buildChatLandingProjectSelectionNavigation({ - machineId, - localProjectId, - selectedLocalProjectKey: getSelectedLocalProjectKey( - router.state.location.pathname, - WORKSPACE, - router.state.location.search - ), - }), - }); -} - -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, - projectSelectionKey: search.projectSelection, - }); -} - -describe('project-row activation history semantics', () => { - it('pushes the first selection as a complete pre-selection intent', async () => { - const router = createChatRouter(); - await router.load(); - expect(router.history.length).toBe(1); - - await activateProjectRow(router, 'machine-1', 'project-a'); - - expect(router.history.length).toBe(2); - const search = currentChatSearch(router); - expect(search.context).toBe('local'); - expect(search.machine).toBe('machine-1'); - expect(search.project).toBe('project-a'); - expect(search.projectSelection).toBeTruthy(); - }); - - it('re-activating the selected project refreshes the intent without stacking history', async () => { - const router = createChatRouter(); - await router.load(); - await activateProjectRow(router, 'machine-1', 'project-a'); - const first = currentChatSearch(router); - - await activateProjectRow(router, 'machine-1', 'project-a'); - const second = currentChatSearch(router); - - // A fresh intent, so the landing re-applies the visible project… - expect(second.projectSelection).toBeTruthy(); - expect(second.projectSelection).not.toBe(first.projectSelection); - expect(preSelectionKeyOf(second)).not.toBe(preSelectionKeyOf(first)); - // …but in place: no second history entry for the same page. - expect(router.history.length).toBe(2); - - // Back therefore leaves the project page instead of replaying older nonces. - router.history.back(); - const afterBack = historyChatSearch(router); - expect(afterBack.project).toBeUndefined(); - expect(afterBack.projectSelection).toBeUndefined(); - }); - - it('switching projects pushes so Back returns to the previous project', async () => { - const router = createChatRouter(); - await router.load(); - await activateProjectRow(router, 'machine-1', 'project-a'); - await activateProjectRow(router, 'machine-1', 'project-b'); - - expect(router.history.length).toBe(3); - expect(currentChatSearch(router).project).toBe('project-b'); - - router.history.back(); - expect(historyChatSearch(router).project).toBe('project-a'); - }); -}); 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({}); + }); +});