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 { 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>
);
@@ -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
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
and managed Chats, while retaining its workspace-scoped grouped list and inline
archived buckets.
display can be all projects or one selected project. The mobile sessions sheet
(`apps/MobileSessionsSheet.tsx`) partitions the same way through
`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
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.otherProjects': 'Projekt wechseln',
'mobile.sessions.section.projects': 'Projekte',
'mobile.sessions.section.chats': 'Chats',
'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.noSessionsTitle': 'Noch keine Sitzungen',
+1
View File
@@ -130,6 +130,7 @@ export const dict = {
'mobile.sessions.section.worktrees': 'Worktrees',
'mobile.sessions.section.otherProjects': 'Switch project',
'mobile.sessions.section.projects': 'Projects',
'mobile.sessions.section.chats': 'Chats',
'mobile.sessions.empty.noProjectsTitle': 'No projects yet',
'mobile.sessions.empty.noProjectsDescription': 'Add a project to start chatting with your code.',
'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.otherProjects": "Cambiar de proyecto",
"mobile.sessions.section.projects": "Proyectos",
"mobile.sessions.section.chats": "Chats",
"mobile.sessions.empty.noProjectsTitle": "Sin proyectos",
"mobile.sessions.empty.noProjectsDescription": "Agrega un proyecto para empezar a chatear con tu código.",
"mobile.sessions.empty.noSessionsTitle": "Sin sesiones",
+1
View File
@@ -2918,6 +2918,7 @@ export const dict = {
'mobile.sessions.section.worktrees': 'Worktrees',
'mobile.sessions.section.otherProjects': 'Changer de projet',
'mobile.sessions.section.projects': 'Projets',
'mobile.sessions.section.chats': 'Discussions',
'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.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.otherProjects': 'プロジェクトを切り替え',
'mobile.sessions.section.projects': 'プロジェクト',
'mobile.sessions.section.chats': 'チャット',
'mobile.sessions.empty.noProjectsTitle': 'まだプロジェクトがありません',
'mobile.sessions.empty.noProjectsDescription': 'プロジェクトを追加してコードとチャットを始めましょう。',
'mobile.sessions.empty.noSessionsTitle': 'まだセッションがありません',
+1
View File
@@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': '워크트리',
'mobile.sessions.section.otherProjects': '프로젝트 전환',
'mobile.sessions.section.projects': '프로젝트',
'mobile.sessions.section.chats': '채팅',
'mobile.sessions.empty.noProjectsTitle': '프로젝트 없음',
'mobile.sessions.empty.noProjectsDescription': '코드와 채팅을 시작하려면 프로젝트를 추가하세요.',
'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.otherProjects': 'Zmień projekt',
'mobile.sessions.section.projects': 'Projekty',
'mobile.sessions.section.chats': 'Czaty',
'mobile.sessions.empty.noProjectsTitle': 'Brak projektów',
'mobile.sessions.empty.noProjectsDescription': 'Dodaj projekt, aby zacząć rozmawiać ze swoim kodem.',
'mobile.sessions.empty.noSessionsTitle': 'Brak sesji',
@@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.sessions.section.worktrees": "Worktrees",
"mobile.sessions.section.otherProjects": "Trocar de projeto",
"mobile.sessions.section.projects": "Projetos",
"mobile.sessions.section.chats": "Conversas",
"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.noSessionsTitle": "Sem sessões",
+1
View File
@@ -117,6 +117,7 @@ export const dict = {
'mobile.sessions.section.worktrees': 'Worktree\'ler',
'mobile.sessions.section.otherProjects': 'Proje değiştir',
'mobile.sessions.section.projects': 'Projeler',
'mobile.sessions.section.chats': 'Sohbetler',
'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.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.otherProjects": "Інші проєкти",
"mobile.sessions.section.projects": "Проєкти",
"mobile.sessions.section.chats": "Чати",
"mobile.sessions.empty.noProjectsTitle": "Ще немає проєктів",
"mobile.sessions.empty.noProjectsDescription": "Додай проєкт, щоб почати спілкування з кодом.",
"mobile.sessions.empty.noSessionsTitle": "Ще немає сесій",
@@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': '工作树',
'mobile.sessions.section.otherProjects': '切换项目',
'mobile.sessions.section.projects': '项目',
'mobile.sessions.section.chats': '聊天',
'mobile.sessions.empty.noProjectsTitle': '暂无项目',
'mobile.sessions.empty.noProjectsDescription': '添加项目以开始与代码对话。',
'mobile.sessions.empty.noSessionsTitle': '暂无会话',
@@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.section.worktrees': '工作樹',
'mobile.sessions.section.otherProjects': '切換專案',
'mobile.sessions.section.projects': '專案',
'mobile.sessions.section.chats': '聊天',
'mobile.sessions.empty.noProjectsTitle': '尚無專案',
'mobile.sessions.empty.noProjectsDescription': '新增專案即可開始與程式碼聊天。',
'mobile.sessions.empty.noSessionsTitle': '尚無會話',