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
25 changes: 24 additions & 1 deletion packages/components/src/atoms/doc-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
type AgentConfigMeta,
type SessionId,
} from '@lody/shared';
import { activeWorkspaceRuntimeAtom } from './runtime';
import { activeWorkspaceRuntimeAtom, type WorkspaceRuntime } from './runtime';
import { mergeBootstrapMetaCache } from '@/lib/doc-meta-bootstrap';
import { listDocMetaEntries } from '@/lib/doc-meta-batch';
import { getDocMetaRoomKind, withDerivedDocMetaId } from '@/lib/doc-meta-room';
Expand Down Expand Up @@ -209,6 +209,16 @@ export const machineMetaCacheAtom = atom<Record<string, MachineMeta>>({});
export const agentConfigMetaCacheAtom = atom<Record<string, AgentConfigMeta>>({});
export const docMetaCacheReadyAtom = atom(false);

export type DocMetaCacheScope = {
runtime: WorkspaceRuntime;
workspaceId: WorkspaceRuntime['workspaceId'];
workspaceSlug: string;
ready: boolean;
};

/** Identifies which runtime owns the current singleton metadata projection. */
export const docMetaCacheScopeAtom = atom<DocMetaCacheScope | null>(null);

// 兼容层
// Doc-meta atoms expose durable CRDT state only. Live signals (machine online,
// session working state) come from the presence atoms; the old
Expand Down Expand Up @@ -500,11 +510,18 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => {
if (!runtime) {
set(clearDocMetaCacheAtom);
set(docMetaCacheReadyAtom, false);
set(docMetaCacheScopeAtom, null);
return undefined;
}

let cancelled = false;
set(docMetaCacheReadyAtom, false);
set(docMetaCacheScopeAtom, {
runtime,
workspaceId: runtime.workspaceId,
workspaceSlug: runtime.workspaceSlug,
ready: false,
});

// Track active docs whose initial fetchDocMeta returned null (metadata not
// yet synced). When a subsequent doc-metadata patch event arrives for one of
Expand Down Expand Up @@ -804,6 +821,12 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => {
mergeBootstrapMetaCache(cache.agents, prev, existenceStateByDocId)
);
set(docMetaCacheReadyAtom, true);
set(docMetaCacheScopeAtom, {
runtime,
workspaceId: runtime.workspaceId,
workspaceSlug: runtime.workspaceSlug,
ready: true,
});
});

return () => {
Expand Down
80 changes: 78 additions & 2 deletions packages/components/src/atoms/workspace-context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,81 @@
import { atom } from 'jotai';
import type { WorkspaceId } from '@lody/shared';

export const currentWorkspaceIdAtom = atom<WorkspaceId | null>(null);
export const currentWorkspaceSlugAtom = atom<string | null>(null);
export type WorkspaceContext = {
slug: string | null;
workspaceId: WorkspaceId | null;
};

type VersionedWorkspaceContext = WorkspaceContext & { revision: number };

const workspaceContextAtom = atom<VersionedWorkspaceContext>({
slug: null,
workspaceId: null,
revision: 0,
});

/** Read the latest identity and revision from an async continuation. */
export const workspaceContextSnapshotAtom = atom((get) => get(workspaceContextAtom));

/** Publish a route target and its resolved id as one observable state change. */
export const setWorkspaceContextAtom = atom(null, (get, set, context: WorkspaceContext) => {
const revision = get(workspaceContextAtom).revision + 1;
set(workspaceContextAtom, { ...context, revision });
return revision;
});

/** Publish only if no newer route, logout, or mutation has replaced the transition. */
export const setWorkspaceContextAtRevisionAtom = atom(
null,
(get, set, update: { revision: number | null; context: WorkspaceContext }): boolean => {
const current = get(workspaceContextAtom);
if (update.revision === null || current.revision !== update.revision) return false;
set(workspaceContextAtom, {
...update.context,
revision: current.revision + 1,
});
return true;
}
);

/** Clear only the route scope that is actually unmounting. */
export const clearWorkspaceContextForSlugAtom = atom(null, (get, set, slug: string) => {
const current = get(workspaceContextAtom);
if (current.slug === slug) {
set(workspaceContextAtom, {
slug: null,
workspaceId: null,
revision: current.revision + 1,
});
}
});

// Compatibility views for existing consumers. A route slug change clears the
// previous route's id in the same Jotai transaction, while an initial slug write
// preserves an id staged by legacy setup code.
export const currentWorkspaceIdAtom = atom(
(get) => get(workspaceContextAtom).workspaceId,
(get, set, workspaceId: WorkspaceId | null) => {
const current = get(workspaceContextAtom);
set(workspaceContextAtom, {
...current,
workspaceId,
revision: current.revision + 1,
});
}
);

export const currentWorkspaceSlugAtom = atom(
(get) => get(workspaceContextAtom).slug,
(get, set, slug: string | null) => {
const current = get(workspaceContextAtom);
set(workspaceContextAtom, {
slug,
workspaceId:
slug === null || (current.slug !== null && current.slug !== slug)
? null
: current.workspaceId,
revision: current.revision + 1,
});
}
);
4 changes: 2 additions & 2 deletions packages/components/src/components/chat/chat-landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ import { getIpcServices, onIpcEvent, sendIpc } from '@/lib/electron-ipc-client';
import {
bugReportDialogOpenAtom,
chatLandingSessionStateAtomFamily,
currentWorkspaceIdAtom,
getAllAgentConfigAtom,
inboxFeatureEnabledAtom,
mobileKeyboardActionAtom,
Expand Down Expand Up @@ -100,6 +99,7 @@ import {
useReconcileAcpSessionConfigSelection,
} from '@/hooks/use-acp-session-config-selection';
import { useOnlineMachineIds } from '@/hooks/use-machine-online-status';
import { useResolvedWorkspaceScope } from '@/hooks/use-resolved-workspace-scope';
import {
getChatLandingAgentSelectionsForMachine,
readChatLandingDefaults,
Expand Down Expand Up @@ -605,7 +605,7 @@ function WorkspaceChatLanding({
controlConnectionState === 'connecting' ||
controlConnectionState === 'syncing';
const executorConfigs = useAtomValue(getAllAgentConfigAtom);
const workspaceId = useAtomValue(currentWorkspaceIdAtom);
const { workspaceId } = useResolvedWorkspaceScope();
const setLocalProjectSharedWithTeam = useCloudMutation(
cloudOperations.localProjects.setLocalProjectSharedWithTeam
);
Expand Down
57 changes: 43 additions & 14 deletions packages/components/src/components/loro-app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type SessionId,
type SessionMeta,
type SessionStatus,
type WorkspaceId,
} from '@lody/shared';
import { useTranslation } from 'react-i18next';
import { cloudOperations } from '@/lib/cloud-api-operations';
Expand Down Expand Up @@ -43,8 +44,11 @@ import {
bugReportDialogOpenAtom,
currentWorkspaceIdAtom,
currentWorkspaceSlugAtom,
setWorkspaceContextAtom,
} from '@/atoms';
import { docMetaCacheReadyAtom } from '@/atoms/doc-meta';
import { docMetaCacheScopeAtom } from '@/atoms/doc-meta';
import { useWorkspaceRouteTargetSlug } from '../providers/workspace-route-target';
import { resolveWorkspaceDataScope } from '@/lib/workspace-data-scope';

import { tasksFeatureEnabledAtom } from '@/atoms/settings';
import { taskQuickAddOpenAtom, taskQuickAddStatusAtom } from '@/atoms/tasks';
Expand Down Expand Up @@ -986,7 +990,10 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) {
const user = useAtomValue(userAtom);
const userId = user?.id ?? null;
const workspaceId = useAtomValue(currentWorkspaceIdAtom);
const workspaceSlug = useAtomValue(currentWorkspaceSlugAtom);
const atomWorkspaceSlug = useAtomValue(currentWorkspaceSlugAtom);
const routeTargetSlug = useWorkspaceRouteTargetSlug();
const workspaceSlug = routeTargetSlug ?? atomWorkspaceSlug;
const setWorkspaceContext = useSetAtom(setWorkspaceContextAtom);
const connectionUiState = useAtomValue(lodyConnectionUiStateAtom);
const setMobileDrawerOpen = useSetAtom(setMobileDrawerOpenAtom);
const language = useAtomValue(languageAtom);
Expand Down Expand Up @@ -1026,7 +1033,7 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) {

const { organizations, activeOrganization, switchOrganization } = useOrganization();
const runtime = useAtomValue(activeWorkspaceRuntimeAtom);
const docMetaCacheReady = useAtomValue(docMetaCacheReadyAtom);
const docMetaScope = useAtomValue(docMetaCacheScopeAtom);
const organizationsReady = Array.isArray(organizations);
const expectedWorkspace = useMemo(() => {
if (!organizationsReady) {
Expand All @@ -1040,22 +1047,38 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) {
}, [organizations, organizationsReady, workspaceSlug]);
const expectedWorkspaceId = expectedWorkspace?.id ?? null;
const expectedWorkspaceName = expectedWorkspace?.name ?? null;
const runtimeWorkspaceId = runtime?.workspaceId ?? null;
const { sessions, allActiveSessions } = useVisibleSessionMetas();
const workspaceDataScope = useMemo(
() =>
workspaceSlug
? resolveWorkspaceDataScope({
targetSlug: workspaceSlug,
runtime,
docMetaScope,
organizationsReady,
expectedWorkspaceId,
})
: null,
[docMetaScope, expectedWorkspaceId, organizationsReady, runtime, workspaceSlug]
);
const workspaceDataReady = workspaceDataScope?.status === 'ready';
const scopedWorkspaceId = workspaceDataReady ? workspaceDataScope.workspaceId : null;
const { sessions, allActiveSessions } = useVisibleSessionMetas({
workspaceId: scopedWorkspaceId,
enabled: workspaceDataReady,
});
useReportVisibleSessionsForEagerSync('loro-app-sidebar', sessions, allActiveSessions);
const sessionsListLoading =
Boolean(workspaceSlug) &&
(!docMetaCacheReady ||
!runtimeWorkspaceId ||
(organizationsReady &&
(expectedWorkspaceId === null || runtimeWorkspaceId !== expectedWorkspaceId)));
const sessionsListLoading = Boolean(workspaceSlug) && !workspaceDataReady;
const {
machines: machineMetaMap,
projects: visibleLocalProjectMap,
showSessionSharing,
resolve: resolveSessionSharing,
shareWithTeam: shareSessionWithTeam,
} = useSessionSharing({ includeLocalProjectDetails: true });
} = useSessionSharing({
includeLocalProjectDetails: true,
workspaceId: scopedWorkspaceId,
enabled: workspaceDataReady,
});
const localMachineId = useAtomValue(localMachineIdAtom);
const onlineMachineIds = useOnlineMachineIds();

Expand Down Expand Up @@ -1563,6 +1586,8 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) {
);

const localProjectSections = useMemo(() => {
if (sessionsListLoading) return [];

const localMachineMeta = localMachineId ? machineMetaMap.get(localMachineId) : undefined;
const localProjects = localMachineId
? Array.from(visibleLocalProjectMap.values()).filter(
Expand Down Expand Up @@ -1636,7 +1661,7 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) {
return a.name.localeCompare(b.name);
}),
}));
}, [localMachineId, machineMetaMap, t, userId, visibleLocalProjectMap]);
}, [localMachineId, machineMetaMap, sessionsListLoading, t, userId, visibleLocalProjectMap]);

// Build one complete, mode-independent row model first. Pinned sessions are
// split from this model below so Workspace and Updated cannot accidentally
Expand Down Expand Up @@ -2186,11 +2211,15 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) {
return;
}
writePreferredWorkspaceSlug(slug);
setWorkspaceContext({
slug,
workspaceId: target.id as WorkspaceId,
});
void switchOrganization(target.id);
closeMobileDrawer();
void router.navigate({ to: '/$workspaceName/chat', params: { workspaceName: slug } });
},
[closeMobileDrawer, organizations, router, switchOrganization]
[closeMobileDrawer, organizations, router, setWorkspaceContext, switchOrganization]
);

const labels: Partial<LoroSidebarLabels> = useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import { ChevronRight, Loader2, Wrench } from 'lucide-react';
import { currentWorkspaceIdAtom } from '@/atoms';
import { useGithubProjectWorktreeAdmin } from '@/hooks/use-github-project-worktree-admin';
import type { ProjectSkillsSource } from '@/hooks/use-project-skills';
import { MobileSettingsRow, MobileSettingsSection } from '@/components/mobile/mobile-settings-row';
import { MobileWorktreeConfigSheet } from '@/components/mobile/mobile-worktree-config-sheet';
import { MobileProjectSkillsRow } from '@/components/mobile/mobile-project-skills-sheet';
import { useResolvedWorkspaceScope } from '@/hooks/use-resolved-workspace-scope';

export type MobileGithubProjectSettingsProps = {
/** "owner/repo" — the workspace repo whose worktree scripts we're editing. */
Expand All @@ -23,7 +22,7 @@ export type MobileGithubProjectSettingsProps = {
*/
export function MobileGithubProjectSettings({ repoFullName }: MobileGithubProjectSettingsProps) {
const { t } = useTranslation();
const workspaceId = useAtomValue(currentWorkspaceIdAtom);
const { workspaceId } = useResolvedWorkspaceScope();
const { rowByRepoFullName, isLoading, onWorktreeSetupChange, onWorktreeCleanupChange } =
useGithubProjectWorktreeAdmin();
const row = rowByRepoFullName.get(repoFullName) ?? null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useSetAtom } from 'jotai';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowRight, Building2, Check, Loader2, Plus } from 'lucide-react';
import type { WorkspaceId } from '@lody/shared';
import { currentWorkspaceIdAtom, currentWorkspaceSlugAtom } from '@/atoms/workspace-context';
import { setWorkspaceContextAtom } from '@/atoms/workspace-context';
import { cloudOperations } from '@/lib/cloud-api-operations';
import { toast } from 'sonner';
import { useCloudQuery, usePlatform, usePlatformWorkspaces } from '@lody/platform/react';
Expand Down Expand Up @@ -355,8 +355,7 @@ export function WorkspaceScreen({ onBack, onNext }: WorkspaceScreenProps) {
const { t } = useTranslation();
const platform = usePlatform();
const workspaceState = usePlatformWorkspaces();
const setCurrentWorkspaceId = useSetAtom(currentWorkspaceIdAtom);
const setCurrentWorkspaceSlug = useSetAtom(currentWorkspaceSlugAtom);
const setWorkspaceContext = useSetAtom(setWorkspaceContextAtom);

const workspaces = useMemo<WorkspaceListEntry[]>(
() =>
Expand All @@ -374,10 +373,12 @@ export function WorkspaceScreen({ onBack, onNext }: WorkspaceScreenProps) {

const commitWorkspaceContext = useCallback(
(workspace: WorkspaceListEntry | null) => {
setCurrentWorkspaceId(workspace ? (workspace.id as WorkspaceId) : null);
setCurrentWorkspaceSlug(workspace?.slug || null);
setWorkspaceContext({
slug: workspace?.slug || null,
workspaceId: workspace ? (workspace.id as WorkspaceId) : null,
});
},
[setCurrentWorkspaceId, setCurrentWorkspaceSlug]
[setWorkspaceContext]
);

const [creating, setCreating] = useState(workspaces.length === 0);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cloudOperations } from '@/lib/cloud-api-operations';
import { atom, useAtom, useAtomValue } from 'jotai';
import { atom, useAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { useLocation } from '@tanstack/react-router';
import {
Expand All @@ -12,8 +12,8 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/ui/alert-dialog';
import { currentWorkspaceIdAtom } from '@/atoms';
import { useOpenSettings } from '@/hooks/use-open-settings';
import { useResolvedWorkspaceScope } from '@/hooks/use-resolved-workspace-scope';
import { useCloudQuery } from '@lody/platform/react';
import { useIsMobile } from '@/hooks/use-mobile';
import { isNativeAppShell } from '@/lib/native-platform';
Expand All @@ -39,7 +39,7 @@ export function WorkspaceCheckoutPendingDialog() {
// safety net so a stray mount can never query billing on the local platform.
const billingAvailable = useAppCapability('billing');
const hidesBillingUi = isMobile || isNativeAppShell() || !billingAvailable;
const workspaceId = useAtomValue(currentWorkspaceIdAtom);
const { workspaceId } = useResolvedWorkspaceScope();
const location = useLocation();
const { openSettings } = useOpenSettings();
const [dismissed, setDismissed] = useAtom(dismissedCheckoutPendingWorkspacesAtom);
Expand Down
Loading