feat(mobile): list managed Chats in the sessions sheet

Chat sessions without a project were dropped from the mobile session tree
because no registered project owned their directory. The sheet now partitions
sessions like the desktop sidebar and shows Chats as a collapsible section above
the project tree, with the same rows, swipe actions and paging; search results
label them "Chats" instead of the raw directory name.

Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 20:08:58 +03:00
parent 010ee59f60
commit 505f9b9e8c
14 changed files with 100 additions and 13 deletions
+82 -10
View File
@@ -41,6 +41,8 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getProjectLabel, normalizePath } from './mobilePaths'; 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 { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; 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); return merged.filter((session) => !session.time?.archived);
}, [globalActiveSessions, liveSessions]); }, [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(); const normalizedQuery = query.trim().toLowerCase();
// On open, bring the current session (or at least its project) into view — // 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 worktree of node.project.worktrees) ensureBucket(node, worktree.path, worktree);
} }
for (const session of sessions) { for (const session of projectSessions) {
const directory = getSessionDirectory(session); const directory = getSessionDirectory(session);
if (!directory) continue; if (!directory) continue;
const normalizedDirectory = normalizePath(directory); const normalizedDirectory = normalizePath(directory);
@@ -1093,7 +1116,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
} }
return nodes; return nodes;
}, [activeProjectId, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]); }, [activeProjectId, pinnedSessionIds, projectSessions, projectsMeta, sessionOrderRanks]);
const normalizedDirectory = normalizePath(currentDirectory); 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, // 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, // and a parent with subsessions can be expanded to reveal its children (nested,
// recursively). Pagination counts only top-level sessions. // recursively). Pagination counts only top-level sessions.
const renderBucketSessions = (node: ProjectNode, bucket: WorktreeBucket, indent: number) => { const renderBucketSessions = (bucketKey: string, bucket: WorktreeBucket, indent: number) => {
const bucketKey = `${node.project.id}::${bucket.key}`;
// Group children by parent within this bucket, and treat sessions whose parent // Group children by parent within this bucket, and treat sessions whose parent
// is not in this bucket as top-level so nothing is hidden. // 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( const buildSessionContextLabel = React.useCallback(
(session: Session): string => { (session: Session): string => {
const directory = getSessionDirectory(session); const directory = getSessionDirectory(session);
if (isChatDirectoryPath(directory)) return t('mobile.sessions.section.chats');
const project = findExactProjectMatch(projectsMeta, directory); const project = findExactProjectMatch(projectsMeta, directory);
if (!project) return getProjectLabel(directory) || directory; if (!project) return getProjectLabel(directory) || directory;
const matchedWorktree = findExactWorktreeMatch(project, normalizePath(directory)); const matchedWorktree = findExactWorktreeMatch(project, normalizePath(directory));
if (matchedWorktree?.branch) return `${project.label} · ${matchedWorktree.branch}`; if (matchedWorktree?.branch) return `${project.label} · ${matchedWorktree.branch}`;
return project.label; return project.label;
}, },
[projectsMeta], [projectsMeta, t],
); );
const handleSelectProject = (project: ProjectMeta) => { const handleSelectProject = (project: ProjectMeta) => {
@@ -1481,7 +1504,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
) : null} ) : null}
</div> </div>
</div> </div>
{projectsMeta.length === 0 ? ( {projectsMeta.length === 0 && chatSessions.length === 0 ? (
<MobileSessionsEmpty <MobileSessionsEmpty
title={t('mobile.sessions.empty.noProjectsTitle')} title={t('mobile.sessions.empty.noProjectsTitle')}
description={t('mobile.sessions.empty.noProjectsDescription')} description={t('mobile.sessions.empty.noProjectsDescription')}
@@ -1601,7 +1624,56 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
</div> </div>
) : ( ) : (
<div className="flex flex-col"> <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 projectExpanded = isProjectExpanded(node);
const buckets = normalizedQuery const buckets = normalizedQuery
? node.buckets.filter((bucket) => ? node.buckets.filter((bucket) =>
@@ -1614,7 +1686,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
return ( return (
<section <section
key={node.project.id} key={node.project.id}
className={cn(nodeIndex > 0 && 'border-t border-border/70')} className="border-t border-border/70"
> >
<MobileSwipeActionsRow <MobileSwipeActionsRow
actionsWidth={96} actionsWidth={96}
@@ -1712,7 +1784,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
return ( return (
<> <>
{rootBucket && rootBucket.sessions.length > 0 {rootBucket && rootBucket.sessions.length > 0
? renderBucketSessions(node, rootBucket, PROJECT_SESSION_INDENT) ? renderBucketSessions(`${node.project.id}::${rootBucket.key}`, rootBucket, PROJECT_SESSION_INDENT)
: null} : null}
{worktreeBuckets.map((bucket) => { {worktreeBuckets.map((bucket) => {
const worktreeExpanded = isWorktreeExpanded(node, bucket); const worktreeExpanded = isWorktreeExpanded(node, bucket);
@@ -1787,7 +1859,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
</button> </button>
</MobileSwipeActionsRow> </MobileSwipeActionsRow>
{worktreeExpanded {worktreeExpanded
? renderBucketSessions(node, bucket, PROJECT_SESSION_INDENT) ? renderBucketSessions(`${node.project.id}::${bucket.key}`, bucket, PROJECT_SESSION_INDENT)
: null} : null}
</div> </div>
); );
@@ -42,9 +42,12 @@ existing data; it is never treated as an authoritative empty list.
Web and desktop show managed Chats before optional Recent activity. Chats use Web and desktop show managed Chats before optional Recent activity. Chats use
their shared managed root for folders and never expose worktree actions. Project their shared managed root for folders and never expose worktree actions. Project
display can be all projects or one selected project. VS Code excludes worktrees display can be all projects or one selected project. The mobile sessions sheet
and managed Chats, while retaining its workspace-scoped grouped list and inline (`apps/MobileSessionsSheet.tsx`) partitions the same way through
archived buckets. `partitionSidebarSessions` and lists Chats as a collapsible section above the
project tree, with no Recent projection. VS Code excludes worktrees and managed
Chats, while retaining its workspace-scoped grouped list and inline archived
buckets.
Directory demand always includes known project roots and worktrees. Visibility Directory demand always includes known project roots and worktrees. Visibility
only changes priority. Row mounts must not start bootstrap work. Selection and only changes priority. Row mounts must not start bootstrap work. Selection and
+1
View File
@@ -103,6 +103,7 @@ export const dict = {
'mobile.sessions.section.worktrees': 'Worktrees', 'mobile.sessions.section.worktrees': 'Worktrees',
'mobile.sessions.section.otherProjects': 'Projekt wechseln', 'mobile.sessions.section.otherProjects': 'Projekt wechseln',
'mobile.sessions.section.projects': 'Projekte', 'mobile.sessions.section.projects': 'Projekte',
'mobile.sessions.section.chats': 'Chats',
'mobile.sessions.empty.noProjectsTitle': 'Noch keine Projekte', 'mobile.sessions.empty.noProjectsTitle': 'Noch keine Projekte',
'mobile.sessions.empty.noProjectsDescription': 'Füge ein Projekt hinzu, um mit deinem Code zu chatten.', 'mobile.sessions.empty.noProjectsDescription': 'Füge ein Projekt hinzu, um mit deinem Code zu chatten.',
'mobile.sessions.empty.noSessionsTitle': 'Noch keine Sitzungen', 'mobile.sessions.empty.noSessionsTitle': 'Noch keine Sitzungen',
+1
View File
@@ -130,6 +130,7 @@ export const dict = {
'mobile.sessions.section.worktrees': 'Worktrees', 'mobile.sessions.section.worktrees': 'Worktrees',
'mobile.sessions.section.otherProjects': 'Switch project', 'mobile.sessions.section.otherProjects': 'Switch project',
'mobile.sessions.section.projects': 'Projects', 'mobile.sessions.section.projects': 'Projects',
'mobile.sessions.section.chats': 'Chats',
'mobile.sessions.empty.noProjectsTitle': 'No projects yet', 'mobile.sessions.empty.noProjectsTitle': 'No projects yet',
'mobile.sessions.empty.noProjectsDescription': 'Add a project to start chatting with your code.', 'mobile.sessions.empty.noProjectsDescription': 'Add a project to start chatting with your code.',
'mobile.sessions.empty.noSessionsTitle': 'No sessions yet', 'mobile.sessions.empty.noSessionsTitle': 'No sessions yet',
+1
View File
@@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.sessions.section.worktrees": "Worktrees", "mobile.sessions.section.worktrees": "Worktrees",
"mobile.sessions.section.otherProjects": "Cambiar de proyecto", "mobile.sessions.section.otherProjects": "Cambiar de proyecto",
"mobile.sessions.section.projects": "Proyectos", "mobile.sessions.section.projects": "Proyectos",
"mobile.sessions.section.chats": "Chats",
"mobile.sessions.empty.noProjectsTitle": "Sin proyectos", "mobile.sessions.empty.noProjectsTitle": "Sin proyectos",
"mobile.sessions.empty.noProjectsDescription": "Agrega un proyecto para empezar a chatear con tu código.", "mobile.sessions.empty.noProjectsDescription": "Agrega un proyecto para empezar a chatear con tu código.",
"mobile.sessions.empty.noSessionsTitle": "Sin sesiones", "mobile.sessions.empty.noSessionsTitle": "Sin sesiones",
+1
View File
@@ -2918,6 +2918,7 @@ export const dict = {
'mobile.sessions.section.worktrees': 'Worktrees', 'mobile.sessions.section.worktrees': 'Worktrees',
'mobile.sessions.section.otherProjects': 'Changer de projet', 'mobile.sessions.section.otherProjects': 'Changer de projet',
'mobile.sessions.section.projects': 'Projets', 'mobile.sessions.section.projects': 'Projets',
'mobile.sessions.section.chats': 'Discussions',
'mobile.sessions.empty.noProjectsTitle': 'Aucun projet pour le moment', 'mobile.sessions.empty.noProjectsTitle': 'Aucun projet pour le moment',
'mobile.sessions.empty.noProjectsDescription': 'Ajoutez un projet pour commencer à discuter avec votre code.', 'mobile.sessions.empty.noProjectsDescription': 'Ajoutez un projet pour commencer à discuter avec votre code.',
'mobile.sessions.empty.noSessionsTitle': 'Aucune session pour le moment', 'mobile.sessions.empty.noSessionsTitle': 'Aucune session pour le moment',
+1
View File
@@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': 'ワークツリー', 'mobile.sessions.section.worktrees': 'ワークツリー',
'mobile.sessions.section.otherProjects': 'プロジェクトを切り替え', 'mobile.sessions.section.otherProjects': 'プロジェクトを切り替え',
'mobile.sessions.section.projects': 'プロジェクト', 'mobile.sessions.section.projects': 'プロジェクト',
'mobile.sessions.section.chats': 'チャット',
'mobile.sessions.empty.noProjectsTitle': 'まだプロジェクトがありません', 'mobile.sessions.empty.noProjectsTitle': 'まだプロジェクトがありません',
'mobile.sessions.empty.noProjectsDescription': 'プロジェクトを追加してコードとチャットを始めましょう。', 'mobile.sessions.empty.noProjectsDescription': 'プロジェクトを追加してコードとチャットを始めましょう。',
'mobile.sessions.empty.noSessionsTitle': 'まだセッションがありません', 'mobile.sessions.empty.noSessionsTitle': 'まだセッションがありません',
+1
View File
@@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': '워크트리', 'mobile.sessions.section.worktrees': '워크트리',
'mobile.sessions.section.otherProjects': '프로젝트 전환', 'mobile.sessions.section.otherProjects': '프로젝트 전환',
'mobile.sessions.section.projects': '프로젝트', 'mobile.sessions.section.projects': '프로젝트',
'mobile.sessions.section.chats': '채팅',
'mobile.sessions.empty.noProjectsTitle': '프로젝트 없음', 'mobile.sessions.empty.noProjectsTitle': '프로젝트 없음',
'mobile.sessions.empty.noProjectsDescription': '코드와 채팅을 시작하려면 프로젝트를 추가하세요.', 'mobile.sessions.empty.noProjectsDescription': '코드와 채팅을 시작하려면 프로젝트를 추가하세요.',
'mobile.sessions.empty.noSessionsTitle': '세션 없음', 'mobile.sessions.empty.noSessionsTitle': '세션 없음',
+1
View File
@@ -132,6 +132,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': 'Worktrees', 'mobile.sessions.section.worktrees': 'Worktrees',
'mobile.sessions.section.otherProjects': 'Zmień projekt', 'mobile.sessions.section.otherProjects': 'Zmień projekt',
'mobile.sessions.section.projects': 'Projekty', 'mobile.sessions.section.projects': 'Projekty',
'mobile.sessions.section.chats': 'Czaty',
'mobile.sessions.empty.noProjectsTitle': 'Brak projektów', 'mobile.sessions.empty.noProjectsTitle': 'Brak projektów',
'mobile.sessions.empty.noProjectsDescription': 'Dodaj projekt, aby zacząć rozmawiać ze swoim kodem.', 'mobile.sessions.empty.noProjectsDescription': 'Dodaj projekt, aby zacząć rozmawiać ze swoim kodem.',
'mobile.sessions.empty.noSessionsTitle': 'Brak sesji', 'mobile.sessions.empty.noSessionsTitle': 'Brak sesji',
@@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.sessions.section.worktrees": "Worktrees", "mobile.sessions.section.worktrees": "Worktrees",
"mobile.sessions.section.otherProjects": "Trocar de projeto", "mobile.sessions.section.otherProjects": "Trocar de projeto",
"mobile.sessions.section.projects": "Projetos", "mobile.sessions.section.projects": "Projetos",
"mobile.sessions.section.chats": "Conversas",
"mobile.sessions.empty.noProjectsTitle": "Sem projetos", "mobile.sessions.empty.noProjectsTitle": "Sem projetos",
"mobile.sessions.empty.noProjectsDescription": "Adicione um projeto para começar a conversar com seu código.", "mobile.sessions.empty.noProjectsDescription": "Adicione um projeto para começar a conversar com seu código.",
"mobile.sessions.empty.noSessionsTitle": "Sem sessões", "mobile.sessions.empty.noSessionsTitle": "Sem sessões",
+1
View File
@@ -117,6 +117,7 @@ export const dict = {
'mobile.sessions.section.worktrees': 'Worktree\'ler', 'mobile.sessions.section.worktrees': 'Worktree\'ler',
'mobile.sessions.section.otherProjects': 'Proje değiştir', 'mobile.sessions.section.otherProjects': 'Proje değiştir',
'mobile.sessions.section.projects': 'Projeler', 'mobile.sessions.section.projects': 'Projeler',
'mobile.sessions.section.chats': 'Sohbetler',
'mobile.sessions.empty.noProjectsTitle': 'Henüz proje yok', 'mobile.sessions.empty.noProjectsTitle': 'Henüz proje yok',
'mobile.sessions.empty.noProjectsDescription': 'Kodunuzla sohbet etmeye başlamak için bir proje ekleyin.', 'mobile.sessions.empty.noProjectsDescription': 'Kodunuzla sohbet etmeye başlamak için bir proje ekleyin.',
'mobile.sessions.empty.noSessionsTitle': 'Henüz session yok', 'mobile.sessions.empty.noSessionsTitle': 'Henüz session yok',
+1
View File
@@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.sessions.section.worktrees": "Worktrees", "mobile.sessions.section.worktrees": "Worktrees",
"mobile.sessions.section.otherProjects": "Інші проєкти", "mobile.sessions.section.otherProjects": "Інші проєкти",
"mobile.sessions.section.projects": "Проєкти", "mobile.sessions.section.projects": "Проєкти",
"mobile.sessions.section.chats": "Чати",
"mobile.sessions.empty.noProjectsTitle": "Ще немає проєктів", "mobile.sessions.empty.noProjectsTitle": "Ще немає проєктів",
"mobile.sessions.empty.noProjectsDescription": "Додай проєкт, щоб почати спілкування з кодом.", "mobile.sessions.empty.noProjectsDescription": "Додай проєкт, щоб почати спілкування з кодом.",
"mobile.sessions.empty.noSessionsTitle": "Ще немає сесій", "mobile.sessions.empty.noSessionsTitle": "Ще немає сесій",
@@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': '工作树', 'mobile.sessions.section.worktrees': '工作树',
'mobile.sessions.section.otherProjects': '切换项目', 'mobile.sessions.section.otherProjects': '切换项目',
'mobile.sessions.section.projects': '项目', 'mobile.sessions.section.projects': '项目',
'mobile.sessions.section.chats': '聊天',
'mobile.sessions.empty.noProjectsTitle': '暂无项目', 'mobile.sessions.empty.noProjectsTitle': '暂无项目',
'mobile.sessions.empty.noProjectsDescription': '添加项目以开始与代码对话。', 'mobile.sessions.empty.noProjectsDescription': '添加项目以开始与代码对话。',
'mobile.sessions.empty.noSessionsTitle': '暂无会话', 'mobile.sessions.empty.noSessionsTitle': '暂无会话',
@@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': '工作樹', 'mobile.sessions.section.worktrees': '工作樹',
'mobile.sessions.section.otherProjects': '切換專案', 'mobile.sessions.section.otherProjects': '切換專案',
'mobile.sessions.section.projects': '專案', 'mobile.sessions.section.projects': '專案',
'mobile.sessions.section.chats': '聊天',
'mobile.sessions.empty.noProjectsTitle': '尚無專案', 'mobile.sessions.empty.noProjectsTitle': '尚無專案',
'mobile.sessions.empty.noProjectsDescription': '新增專案即可開始與程式碼聊天。', 'mobile.sessions.empty.noProjectsDescription': '新增專案即可開始與程式碼聊天。',
'mobile.sessions.empty.noSessionsTitle': '尚無會話', 'mobile.sessions.empty.noSessionsTitle': '尚無會話',