Merge remote-tracking branch 'origin/main' into feat/nested-git-repos
# Conflicts: # packages/ui/src/components/views/GitView.tsx # packages/ui/src/stores/DOCUMENTATION.md # packages/ui/src/stores/useGitStore.ts
This commit is contained in:
@@ -8,6 +8,7 @@ import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -318,6 +319,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
|
||||
useMiniChatKeyboardShortcuts();
|
||||
usePushVisibilityBeacon({ enabled: true });
|
||||
useWindowTitle();
|
||||
useRootScrollLock();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SettingsView } from '@/components/views/SettingsView';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
@@ -21,6 +22,7 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device';
|
||||
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -33,6 +35,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -110,7 +113,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
const [workspaceTab, setWorkspaceTab] = React.useState<MobileWorkspaceTab>('changes');
|
||||
// A plan opened from the workspace drawer's Notes tab, shown as a fullscreen
|
||||
// layer on top of it (back returns to the notes).
|
||||
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null);
|
||||
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string; projectRef: ProjectRef } | null>(null);
|
||||
const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav');
|
||||
// When set, the Changes surface opens directly into the per-file diff for this path.
|
||||
const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null);
|
||||
@@ -541,7 +544,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<PlanView
|
||||
projectPlanId={openPlan.id}
|
||||
savedProjectPlan={{ projectRef: openPlan.projectRef, planId: openPlan.id }}
|
||||
onNavigatedToChat={() => {
|
||||
closeSurface();
|
||||
closeWorkspace();
|
||||
@@ -628,6 +631,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
const clearError = useSessionUIStore((state) => state.clearError);
|
||||
const setIsMobile = useUIStore((state) => state.setIsMobile);
|
||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||
const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus);
|
||||
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const [connectionEpoch, setConnectionEpoch] = React.useState(0);
|
||||
@@ -676,6 +680,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
const refreshInPlace = () => {
|
||||
void initializeApp();
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
void refreshLinearAuthStatus(apis.linear, { force: true });
|
||||
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
|
||||
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
|
||||
};
|
||||
@@ -744,7 +749,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
lastNativeResumeSyncEventAtRef.current = now;
|
||||
window.dispatchEvent(new Event('openchamber:system-resume'));
|
||||
}
|
||||
}, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]);
|
||||
}, [agentsCount, apis.github, apis.linear, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
|
||||
|
||||
useNativeMobileChrome();
|
||||
useNativeMobileLifecycle(handleNativeResume);
|
||||
@@ -772,6 +777,23 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
};
|
||||
}, [isNativeMobileApp, handleNativeResume]);
|
||||
|
||||
// A confirmed mid-session auth expiry (classified centrally from live 401
|
||||
// traffic) runs the same seq-guarded re-probe the resume path uses: it ends
|
||||
// in needs-login → the native welcome screen with the auth-expired notice.
|
||||
// The shared web banner never renders on native (the session gate is not
|
||||
// mounted here), so this is the only surface reacting to the signal.
|
||||
React.useEffect(() => {
|
||||
if (!isNativeMobileApp) return;
|
||||
return useAuthSessionStore.subscribe((store, previous) => {
|
||||
if (store.state === 'expired' && previous.state !== 'expired') {
|
||||
handleNativeResume();
|
||||
// The probe ladder owns the outcome from here; the shared store goes
|
||||
// back to 'ok' so a later expiry can signal again.
|
||||
useAuthSessionStore.getState().markAuthenticated();
|
||||
}
|
||||
});
|
||||
}, [isNativeMobileApp, handleNativeResume]);
|
||||
|
||||
React.useEffect(() => {
|
||||
registerRuntimeAPIs(apis);
|
||||
return () => registerRuntimeAPIs(null);
|
||||
@@ -1012,7 +1034,8 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
}, [apis.github, isConnected, refreshGitHubAuthStatus]);
|
||||
void refreshLinearAuthStatus(apis.linear, { force: true });
|
||||
}, [apis.github, apis.linear, isConnected, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
|
||||
|
||||
// Discover all worktrees for every known project so the draft session's
|
||||
// worktree/branch dropdown can list every available branch — not only the
|
||||
|
||||
@@ -41,6 +41,8 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getProjectLabel, normalizePath } from './mobilePaths';
|
||||
import { CHAT_DRAFT_PROJECT_ID, isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
import { partitionSidebarSessions } from '@/components/session/sidebar/list/sessionCollection';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
@@ -1022,6 +1024,27 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
return merged.filter((session) => !session.time?.archived);
|
||||
}, [globalActiveSessions, liveSessions]);
|
||||
|
||||
// Managed Chats (sessions under ~/.config/openchamber/chats) are not owned
|
||||
// by any registered project; they get their own section above the project
|
||||
// tree, the same split the desktop sidebar makes. Temporary /btw forks are
|
||||
// dropped here as well.
|
||||
const { projectSessions, chatSessions } = React.useMemo(
|
||||
() => partitionSidebarSessions(sessions, false),
|
||||
[sessions],
|
||||
);
|
||||
const chatsBucket = React.useMemo<WorktreeBucket>(() => ({
|
||||
key: CHAT_DRAFT_PROJECT_ID,
|
||||
label: '',
|
||||
path: '',
|
||||
worktree: null,
|
||||
sessions: orderSessionsByLifecycleScopes(chatSessions, pinnedSessionIds, sessionOrderRanks),
|
||||
}), [chatSessions, pinnedSessionIds, sessionOrderRanks]);
|
||||
const chatsBucketKey = `${CHAT_DRAFT_PROJECT_ID}::${CHAT_DRAFT_PROJECT_ID}`;
|
||||
const chatRootCount = React.useMemo(
|
||||
() => chatSessions.filter((session) => !getParentId(session)).length,
|
||||
[chatSessions],
|
||||
);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
// On open, bring the current session (or at least its project) into view —
|
||||
@@ -1070,7 +1093,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
for (const worktree of node.project.worktrees) ensureBucket(node, worktree.path, worktree);
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
for (const session of projectSessions) {
|
||||
const directory = getSessionDirectory(session);
|
||||
if (!directory) continue;
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
@@ -1093,7 +1116,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}, [activeProjectId, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]);
|
||||
}, [activeProjectId, pinnedSessionIds, projectSessions, projectsMeta, sessionOrderRanks]);
|
||||
|
||||
const normalizedDirectory = normalizePath(currentDirectory);
|
||||
|
||||
@@ -1149,8 +1172,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
// Paginated, tree-aware list of a bucket's sessions: top-level sessions paginate,
|
||||
// and a parent with subsessions can be expanded to reveal its children (nested,
|
||||
// recursively). Pagination counts only top-level sessions.
|
||||
const renderBucketSessions = (node: ProjectNode, bucket: WorktreeBucket, indent: number) => {
|
||||
const bucketKey = `${node.project.id}::${bucket.key}`;
|
||||
const renderBucketSessions = (bucketKey: string, bucket: WorktreeBucket, indent: number) => {
|
||||
|
||||
// Group children by parent within this bucket, and treat sessions whose parent
|
||||
// is not in this bucket as top-level so nothing is hidden.
|
||||
@@ -1336,13 +1358,14 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
const buildSessionContextLabel = React.useCallback(
|
||||
(session: Session): string => {
|
||||
const directory = getSessionDirectory(session);
|
||||
if (isChatDirectoryPath(directory)) return t('mobile.sessions.section.chats');
|
||||
const project = findExactProjectMatch(projectsMeta, directory);
|
||||
if (!project) return getProjectLabel(directory) || directory;
|
||||
const matchedWorktree = findExactWorktreeMatch(project, normalizePath(directory));
|
||||
if (matchedWorktree?.branch) return `${project.label} · ${matchedWorktree.branch}`;
|
||||
return project.label;
|
||||
},
|
||||
[projectsMeta],
|
||||
[projectsMeta, t],
|
||||
);
|
||||
|
||||
const handleSelectProject = (project: ProjectMeta) => {
|
||||
@@ -1481,7 +1504,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{projectsMeta.length === 0 ? (
|
||||
{projectsMeta.length === 0 && chatSessions.length === 0 ? (
|
||||
<MobileSessionsEmpty
|
||||
title={t('mobile.sessions.empty.noProjectsTitle')}
|
||||
description={t('mobile.sessions.empty.noProjectsDescription')}
|
||||
@@ -1601,7 +1624,56 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{orderedNodes.map((node, nodeIndex) => {
|
||||
{(() => {
|
||||
const chatsExpanded = projectExpandedMap[CHAT_DRAFT_PROJECT_ID] ?? true;
|
||||
const chatsLabel = t('mobile.sessions.section.chats');
|
||||
return (
|
||||
<section>
|
||||
<div className="flex min-h-12 w-full items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-12 min-w-0 flex-1 items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
onClick={() => {
|
||||
if (revealedRowId) {
|
||||
handleRowKeyRevealedChange(revealedRowId, false);
|
||||
return;
|
||||
}
|
||||
toggleProject(CHAT_DRAFT_PROJECT_ID, chatsExpanded);
|
||||
}}
|
||||
aria-expanded={chatsExpanded}
|
||||
aria-label={
|
||||
chatsExpanded
|
||||
? t('sessions.sidebar.group.collapseAria', { label: chatsLabel })
|
||||
: t('sessions.sidebar.group.expandAria', { label: chatsLabel })
|
||||
}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--surface-muted)] text-muted-foreground">
|
||||
<Icon name="chat-4" className="size-4" />
|
||||
</span>
|
||||
<span className="block min-w-0 flex-1 truncate typography-ui-label font-semibold text-foreground">
|
||||
{chatsLabel}
|
||||
</span>
|
||||
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
|
||||
{chatRootCount}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{chatsExpanded ? (
|
||||
<div className="pb-2">
|
||||
{chatsBucket.sessions.length > 0 ? (
|
||||
renderBucketSessions(chatsBucketKey, chatsBucket, PROJECT_SESSION_INDENT)
|
||||
) : (
|
||||
<p className="px-3 pb-1 typography-micro text-muted-foreground" style={{ paddingLeft: PROJECT_SESSION_INDENT }}>
|
||||
{t('sessions.sidebar.activity.chatsEmpty')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
{orderedNodes.map((node) => {
|
||||
const projectExpanded = isProjectExpanded(node);
|
||||
const buckets = normalizedQuery
|
||||
? node.buckets.filter((bucket) =>
|
||||
@@ -1614,7 +1686,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
return (
|
||||
<section
|
||||
key={node.project.id}
|
||||
className={cn(nodeIndex > 0 && 'border-t border-border/70')}
|
||||
className="border-t border-border/70"
|
||||
>
|
||||
<MobileSwipeActionsRow
|
||||
actionsWidth={96}
|
||||
@@ -1712,7 +1784,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
return (
|
||||
<>
|
||||
{rootBucket && rootBucket.sessions.length > 0
|
||||
? renderBucketSessions(node, rootBucket, PROJECT_SESSION_INDENT)
|
||||
? renderBucketSessions(`${node.project.id}::${rootBucket.key}`, rootBucket, PROJECT_SESSION_INDENT)
|
||||
: null}
|
||||
{worktreeBuckets.map((bucket) => {
|
||||
const worktreeExpanded = isWorktreeExpanded(node, bucket);
|
||||
@@ -1787,7 +1859,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
</button>
|
||||
</MobileSwipeActionsRow>
|
||||
{worktreeExpanded
|
||||
? renderBucketSessions(node, bucket, PROJECT_SESSION_INDENT)
|
||||
? renderBucketSessions(`${node.project.id}::${bucket.key}`, bucket, PROJECT_SESSION_INDENT)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
|
||||
import { TerminalView } from '@/components/views/TerminalView';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
|
||||
@@ -105,7 +106,7 @@ export const MobileWorkspaceDrawer: React.FC<{
|
||||
/** When set, the Changes tab opens directly into the per-file diff. */
|
||||
pendingChangesDiff: { path: string; staged: boolean } | null;
|
||||
/** Notes tab: opens a plan fullscreen (layered above the drawer). */
|
||||
onOpenPlan: (plan: { id: string; title: string }) => void;
|
||||
onOpenPlan: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
|
||||
/** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */
|
||||
onOpenMcpSettings: () => void;
|
||||
variant?: 'drawer' | 'panel';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
|
||||
import { useRouter } from '@/hooks/useRouter';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
@@ -57,6 +58,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
useAppFontEffects();
|
||||
usePushVisibilityBeacon({ enabled: true });
|
||||
useWindowTitle();
|
||||
useRootScrollLock();
|
||||
useRouter();
|
||||
useGlobalSessionsPolling(panelType !== 'agentManager');
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
import { useGlobalSessionStatusStore, replaceGlobalSessionStatusById } from '@/sync/global-session-status';
|
||||
import { replaceGlobalSessionStatusById } from '@/sync/global-session-status';
|
||||
import { resetSessionOrdering } from '@/sync/session-ordering';
|
||||
import { resetSessionActivityTiming } from '@/sync/session-activity-timing';
|
||||
import { syncDesktopSettings } from '@/lib/persistence';
|
||||
|
||||
Reference in New Issue
Block a user