Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/components/src/components/chat/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
139 changes: 139 additions & 0 deletions packages/components/src/components/chat/chat-landing-derived.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,3 +575,142 @@ export function getChatLandingVisibleComposerStatus<TMessage>({
// 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<string, unknown>): 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, unknown>
): 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';
}
87 changes: 82 additions & 5 deletions packages/components/src/components/chat/chat-landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,11 @@ import {
useMobileHomeExcludedSetAtom,
} from '@/atoms/mobile-home-state';
import {
buildChatLandingPreSelectionKey,
compareChatLandingLocalProjectByRecency,
getChatLandingSelectionSearch,
getChatLandingSelectionSyncDecision,
type ChatLandingSearch,
compareChatLandingRepositoryByRecency,
getChatLandingBranchSelectorState,
getChatLandingHasAnyOnlineMachine,
Expand All @@ -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;
}
Expand Down Expand Up @@ -546,6 +556,7 @@ function WorkspaceChatLanding({
preSelectedMachine,
preSelectedProject,
preSelectedRepo,
onSelectionUrlSync,
resetDraftKey,
resetDraftOnKeyChange = true,
}: ChatLandingProps) {
Expand Down Expand Up @@ -1145,6 +1156,9 @@ function WorkspaceChatLanding({
const fireProjectSelectedOnChange = useFireOnKeyChange();
const fireAgentConfigOnChange = useFireOnKeyChange();
const preSelectionAppliedRef = useRef<string | null>(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<LocalProjectSelection | null>(null);
selectedLocalProjectRef.current = selectedLocalProject;
// `machines` is read inside fetchLocalGitState only for an offline pre-check.
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
36 changes: 5 additions & 31 deletions packages/components/src/components/loro-app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, unknown>
): 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 =
Expand Down Expand Up @@ -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 },
Expand Down
Loading
Loading