Show messaging source folders in desktop sessions

This commit is contained in:
D'Angelo Rodriguez 2026-06-06 02:07:17 -04:00 committed by Teknium
parent 9d6992ee8a
commit ede4f5a4a3
3 changed files with 110 additions and 39 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

View file

@ -19,6 +19,7 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
@ -38,6 +39,7 @@ import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { profileColor } from '@/lib/profile-color'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
import { sessionMatchesSearch } from '@/lib/session-search'
import { cn } from '@/lib/utils'
import { $cronJobs } from '@/store/cron'
@ -118,6 +120,7 @@ const WORKSPACE_PAGE = 5
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const WS_ID_PREFIX = 'workspace:'
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null)
@ -208,6 +211,46 @@ function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string):
return [...groups.values()]
}
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
localSessions: SessionInfo[]
sourceGroups: SidebarSessionGroup[]
} {
const groups = new Map<string, SidebarSessionGroup>()
const localSessions: SessionInfo[] = []
for (const session of sessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
localSessions.push(session)
continue
}
const label = sessionSourceLabel(sourceId) ?? sourceId
const group = groups.get(sourceId) ?? {
id: `source:${sourceId}`,
label,
mode: 'source',
path: null,
sessions: [],
sourceId
}
group.sessions.push(session)
groups.set(sourceId, group)
}
for (const group of groups.values()) {
group.sessions.sort((a, b) => sessionTime(b) - sessionTime(a))
}
return {
localSessions,
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@ -434,9 +477,14 @@ export function ChatSidebar({
[unpinnedAgentSessions, agentOrderIds]
)
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
() => sourceSessionGroupsFor(agentSessions),
[agentSessions]
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
() => orderByIds(workspaceGroupsFor(localAgentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[localAgentSessions, s.noWorkspace, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@ -449,9 +497,7 @@ export function ChatSidebar({
void Promise.resolve(onLoadMoreProfileSessions(profile))
.catch(() => undefined)
.finally(() =>
setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)
)
.finally(() => setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest))
},
[onLoadMoreProfileSessions]
)
@ -482,15 +528,17 @@ export function ChatSidebar({
groups.set(key, group)
}
return [...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
return (
[...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
)
}, [
showAllProfiles,
agentSessions,
@ -500,6 +548,30 @@ export function ChatSidebar({
sessionProfileTotals
])
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
const displayAgentGroups = useMemo(() => {
if (sourceGroups.length) {
const localGroups = agentsGrouped
? agentGroups
: localAgentSessions.length
? [
{
id: 'local-sessions',
label: 'Local',
mode: 'workspace' as const,
path: null,
sessions: localAgentSessions
}
]
: []
return [...sourceGroups, ...localGroups]
}
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
}, [agentGroups, agentsGrouped, localAgentSessions, profileGroups, showAllProfiles, sourceGroups])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
@ -735,7 +807,7 @@ export function ChatSidebar({
) : null
}
forceEmptyState={showSessionSkeletons}
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
groups={displayAgentGroups}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
@ -744,7 +816,7 @@ export function ChatSidebar({
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
{!showAllProfiles && localAgentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
@ -778,8 +850,8 @@ export function ChatSidebar({
open={agentsOpen}
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={agentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
sessions={displayAgentSessions}
sortable={!showAllProfiles && sourceGroups.length === 0 && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
@ -880,8 +952,9 @@ interface SidebarSessionGroup {
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
loadingMore?: boolean
mode?: 'profile' | 'workspace'
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
}
@ -1069,6 +1142,7 @@ function SidebarWorkspaceGroup({
const { t } = useI18n()
const s = t.sidebar
const isProfileGroup = group.mode === 'profile'
const isSourceGroup = group.mode === 'source'
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
const [open, setOpen] = useState(true)
const [visibleCount, setVisibleCount] = useState(pageStep)
@ -1102,7 +1176,18 @@ function SidebarWorkspaceGroup({
type="button"
>
{group.color ? (
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: group.color }}
/>
) : null}
{isSourceGroup && group.sourceId ? (
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
platformId={group.sourceId}
platformName={group.label}
/>
) : null}
<span className="truncate">{group.label}</span>
<SidebarCount>
@ -1151,7 +1236,11 @@ function SidebarWorkspaceGroup({
{renderRows(visibleSessions)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
<SidebarLoadMoreRow
loading={Boolean(group.loadingMore)}
onClick={handleProfileLoadMore}
step={nextCount}
/>
) : (
<Tip label={s.showMoreIn(nextCount, group.label)}>
<button

View file

@ -2,14 +2,12 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
@ -68,9 +66,6 @@ export function SidebarSessionRow({
const r = t.sidebar.row
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at, r)
const sourceId = normalizeSessionSource(session.source)
const sourceLabel = sessionSourceLabel(sourceId)
const showSource = Boolean(sourceId && sourceLabel && !['desktop', 'local', 'tui'].includes(sourceId))
const handleLabel = `Reorder ${title}`
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
@ -187,19 +182,6 @@ export function SidebarSessionRow({
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
{showSource && sourceId && sourceLabel && (
<span
className="hidden shrink-0 items-center gap-1 rounded-[4px] bg-(--ui-bg-tertiary) px-1.5 py-0.5 text-[0.625rem] leading-none text-(--ui-text-tertiary) sm:inline-flex"
title={`${sourceLabel} session`}
>
<PlatformAvatar
className="size-3.5 rounded-[3px] text-[0.5rem] [&_svg]:size-2.5"
platformId={sourceId}
platformName={sourceLabel}
/>
<span className="max-w-16 truncate">{sourceLabel}</span>
</span>
)}
</button>
<div className="relative z-2 grid w-[1.375rem] place-items-center">
{!isWorking && (