feat: refine mobile session status bar header
Remove the per-row agent color dot (and its now-unused helpers), keeping only the status indicator. Add a "+" button to the panel header that opens a new session draft targeting the filtered project (or the most recently active session's project under "All", falling back to the default target), and restyle the close button to match it. Show the context token usage next to the percentage (e.g. 5.1% 20.4K/400.0K) at a larger, consistent size, and compute the percentage with a fraction instead of the store's integer-rounded value.
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useSelectionStore } from '@/sync/selection-store';
|
|
||||||
import { useAllSessionStatuses, useAllLiveSessions } from '@/sync/sync-context';
|
import { useAllSessionStatuses, useAllLiveSessions } from '@/sync/sync-context';
|
||||||
import { useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
import { useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
@@ -9,7 +8,6 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
|||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import type { ProjectEntry } from '@/lib/api/types';
|
import type { ProjectEntry } from '@/lib/api/types';
|
||||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||||
import { getAgentColor } from '@/lib/agentColors';
|
|
||||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||||
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
@@ -172,17 +170,7 @@ function useSessionGrouping(
|
|||||||
return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length };
|
return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSessionHelpers(agents: Array<{ name: string }>) {
|
function useSessionHelpers() {
|
||||||
const getSessionAgentName = React.useCallback((session: Session): string => {
|
|
||||||
const agent = (session as { agent?: string }).agent;
|
|
||||||
if (agent) return agent;
|
|
||||||
|
|
||||||
const sessionAgentSelection = useSelectionStore.getState().getSessionAgentSelection(session.id);
|
|
||||||
if (sessionAgentSelection) return sessionAgentSelection;
|
|
||||||
|
|
||||||
return agents[0]?.name ?? 'agent';
|
|
||||||
}, [agents]);
|
|
||||||
|
|
||||||
const getSessionTitle = React.useCallback((session: Session): string => {
|
const getSessionTitle = React.useCallback((session: Session): string => {
|
||||||
const title = session.title;
|
const title = session.title;
|
||||||
if (title && title.trim()) return title;
|
if (title && title.trim()) return title;
|
||||||
@@ -194,7 +182,7 @@ function useSessionHelpers(agents: Array<{ name: string }>) {
|
|||||||
return (unseenCounts[sessionId] ?? 0) > 0;
|
return (unseenCounts[sessionId] ?? 0) > 0;
|
||||||
}, [unseenCounts]);
|
}, [unseenCounts]);
|
||||||
|
|
||||||
return { getSessionAgentName, getSessionTitle, needsAttention };
|
return { getSessionTitle, needsAttention };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-project status indicators (running / unread) for the filter chips.
|
// Per-project status indicators (running / unread) for the filter chips.
|
||||||
@@ -300,14 +288,29 @@ function UnreadIndicator({ count }: { count: number }) {
|
|||||||
function TokenUsageIndicator({ contextUsage }: { contextUsage: SessionContextUsage | null }) {
|
function TokenUsageIndicator({ contextUsage }: { contextUsage: SessionContextUsage | null }) {
|
||||||
if (!contextUsage || contextUsage.totalTokens === 0) return null;
|
if (!contextUsage || contextUsage.totalTokens === 0) return null;
|
||||||
|
|
||||||
const percentage = Math.min(contextUsage.percentage, 999);
|
// Recompute with a fraction: contextUsage.percentage is rounded to an integer
|
||||||
|
// in the store, which would always render as "X.0%".
|
||||||
|
const percentage = contextUsage.contextLimit > 0
|
||||||
|
? Math.min((contextUsage.totalTokens / contextUsage.contextLimit) * 100, 999)
|
||||||
|
: 0;
|
||||||
const colorClass =
|
const colorClass =
|
||||||
percentage >= 90 ? 'text-[var(--status-error)]' :
|
percentage >= 90 ? 'text-[var(--status-error)]' :
|
||||||
percentage >= 75 ? 'text-[var(--status-warning)]' : 'text-[var(--status-success)]';
|
percentage >= 75 ? 'text-[var(--status-warning)]' : 'text-[var(--status-success)]';
|
||||||
|
|
||||||
|
const formatTokens = (value: number): string => {
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokens = contextUsage.contextLimit > 0
|
||||||
|
? `${formatTokens(contextUsage.totalTokens)}/${formatTokens(contextUsage.contextLimit)}`
|
||||||
|
: formatTokens(contextUsage.totalTokens);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={cn("text-[12px] tabular-nums font-medium", colorClass)}>
|
<span className="flex items-baseline gap-1.5 text-[15px] tabular-nums">
|
||||||
{percentage.toFixed(1)}%
|
<span className={cn("font-medium", colorClass)}>{percentage.toFixed(1)}%</span>
|
||||||
|
<span className="text-[var(--surface-mutedForeground)]">{tokens}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -316,20 +319,16 @@ function TokenUsageIndicator({ contextUsage }: { contextUsage: SessionContextUsa
|
|||||||
function SessionItem({
|
function SessionItem({
|
||||||
session,
|
session,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
getSessionAgentName,
|
|
||||||
getSessionTitle,
|
getSessionTitle,
|
||||||
onClick,
|
onClick,
|
||||||
needsAttention,
|
needsAttention,
|
||||||
}: {
|
}: {
|
||||||
session: SessionWithStatus;
|
session: SessionWithStatus;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
getSessionAgentName: (s: Session) => string;
|
|
||||||
getSessionTitle: (s: Session) => string;
|
getSessionTitle: (s: Session) => string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
needsAttention: (sessionId: string) => boolean;
|
needsAttention: (sessionId: string) => boolean;
|
||||||
}) {
|
}) {
|
||||||
const agentName = getSessionAgentName(session);
|
|
||||||
const agentColor = getAgentColor(agentName);
|
|
||||||
const attention = needsAttention(session.id);
|
const attention = needsAttention(session.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -346,11 +345,6 @@ function SessionItem({
|
|||||||
<StatusIndicator isRunning={session._statusType !== 'idle'} needsAttention={attention} />
|
<StatusIndicator isRunning={session._statusType !== 'idle'} needsAttention={attention} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
|
||||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: `var(${agentColor.var})` }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"flex-1 truncate text-[15px] leading-tight",
|
"flex-1 truncate text-[15px] leading-tight",
|
||||||
isCurrent ? "font-semibold text-[var(--surface-foreground)]" : "text-[var(--surface-foreground)]"
|
isCurrent ? "font-semibold text-[var(--surface-foreground)]" : "text-[var(--surface-foreground)]"
|
||||||
@@ -500,8 +494,8 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessionStatus = useAllSessionStatuses();
|
const sessionStatus = useAllSessionStatuses();
|
||||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||||
const agents = useConfigStore((state) => state.agents);
|
|
||||||
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
|
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
|
||||||
@@ -512,7 +506,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||||
|
|
||||||
const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus);
|
const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus);
|
||||||
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents);
|
const { getSessionTitle, needsAttention } = useSessionHelpers();
|
||||||
const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId);
|
const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId);
|
||||||
const resolveProjectRoots = useProjectRootsResolver();
|
const resolveProjectRoots = useProjectRootsResolver();
|
||||||
|
|
||||||
@@ -572,7 +566,28 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderHeader = React.useCallback((closeButton: React.ReactNode) => (
|
// "+" — start a new session draft. Target the project selected in the filter;
|
||||||
|
// for "All", use the most recently active session's directory, falling back to
|
||||||
|
// the store's own default target when there are no sessions.
|
||||||
|
const handleNewChat = React.useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
if (filterProjectId) {
|
||||||
|
const project = projects.find((p) => p.id === filterProjectId);
|
||||||
|
if (project) {
|
||||||
|
openNewSessionDraft({ selectedProjectId: project.id, directoryOverride: project.path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mostRecent = [...sessions].sort((a, b) => {
|
||||||
|
const aTime = (a as { time?: { updated?: number } }).time?.updated ?? 0;
|
||||||
|
const bTime = (b as { time?: { updated?: number } }).time?.updated ?? 0;
|
||||||
|
return bTime - aTime;
|
||||||
|
})[0];
|
||||||
|
const directory = mostRecent ? sessionDirectory(mostRecent) : '';
|
||||||
|
openNewSessionDraft(directory ? { directoryOverride: directory } : undefined);
|
||||||
|
}, [filterProjectId, projects, sessions, openNewSessionDraft, setOpen]);
|
||||||
|
|
||||||
|
const renderHeader = React.useCallback(() => (
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<div className="flex justify-center pt-2.5 pb-1">
|
<div className="flex justify-center pt-2.5 pb-1">
|
||||||
<div className="h-1 w-9 rounded-full bg-[color-mix(in_srgb,var(--surface-mutedForeground)_40%,transparent)]" />
|
<div className="h-1 w-9 rounded-full bg-[color-mix(in_srgb,var(--surface-mutedForeground)_40%,transparent)]" />
|
||||||
@@ -586,7 +601,23 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
<RunningIndicator count={totalRunning} />
|
<RunningIndicator count={totalRunning} />
|
||||||
<UnreadIndicator count={totalUnread} />
|
<UnreadIndicator count={totalUnread} />
|
||||||
<TokenUsageIndicator contextUsage={contextUsage} />
|
<TokenUsageIndicator contextUsage={contextUsage} />
|
||||||
{closeButton}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNewChat}
|
||||||
|
aria-label={t('mobile.sessions.newChat')}
|
||||||
|
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
||||||
|
style={{ touchAction: 'manipulation' }}
|
||||||
|
>
|
||||||
|
<Icon name="add" className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
||||||
|
style={{ touchAction: 'manipulation' }}
|
||||||
|
>
|
||||||
|
<Icon name="close" className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -620,7 +651,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
), [t, totalRunning, totalUnread, contextUsage, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus]);
|
), [t, totalRunning, totalUnread, contextUsage, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]);
|
||||||
|
|
||||||
if (!isMobile || !showMobileSessionStatusBar) {
|
if (!isMobile || !showMobileSessionStatusBar) {
|
||||||
return null;
|
return null;
|
||||||
@@ -646,7 +677,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
key={session.id}
|
key={session.id}
|
||||||
session={session}
|
session={session}
|
||||||
isCurrent={session.id === currentSessionId}
|
isCurrent={session.id === currentSessionId}
|
||||||
getSessionAgentName={getSessionAgentName}
|
|
||||||
getSessionTitle={getSessionTitle}
|
getSessionTitle={getSessionTitle}
|
||||||
onClick={() => handleSessionClick(session)}
|
onClick={() => handleSessionClick(session)}
|
||||||
needsAttention={needsAttention}
|
needsAttention={needsAttention}
|
||||||
|
|||||||
Reference in New Issue
Block a user