feat(ui): add dynamic window title and sprite-based project/file icons (#529)
* feat(ui): add dynamic titles and sprite-based project/file icons * feat(files): add viewer syntax fallback and tab file icons * fix(files): restore file viewer highlighting and add diff file icons * feat(git): add file icons and async file-viewer syntax fallback * fix(files): force codemirror token colors in file viewer * feat(files): add shiki view mode for file viewer * fix(files): force codemirror parse after programmatic content updates * feat(files): support markdown frontmatter preview * feat(chat): use pierre diffs for tool previews * feat(chat): add configurable beautiful-mermaid rendering * feat(perf): virtualize chat rendering and add react-scan toggle * feat(build): enable React Compiler in Vite React apps * fix(chat): reduce rerenders from tooltips and streamed activity * fix(ui): make MessageList React Compiler safe * chore(ui): batch commit remaining pending ui updates * fix: polish chat and diff preview rendering - Keep Mermaid action buttons fixed while diagram content scrolls - Align Diff All Files headers and match Git-style path truncation - Default chat tool diffs to unified view with lightweight indicators disabled * fix: preserve file tree expansion and delay git action label collapse * fix: refine project icon controls in settings --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d6b8f28e6f
commit
1d8ff97c95
@@ -9,10 +9,10 @@ import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import { getAgentColor } from '@/lib/agentColors';
|
||||
import { RiLoader4Line, RiAddLine } from '@remixicon/react';
|
||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
|
||||
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { isTauriShell, isDesktopLocalOriginActive } from '@/lib/desktop';
|
||||
import { isTauriShell, isDesktopLocalOriginActive, requestDirectoryAccess } from '@/lib/desktop';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -184,72 +184,61 @@ function useProjectStatus(
|
||||
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
|
||||
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
|
||||
|
||||
const projectStatusMap = React.useMemo(() => {
|
||||
const result = new Map<string, { hasRunning: boolean; hasUnread: boolean }>();
|
||||
|
||||
const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
|
||||
const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
|
||||
const status = sessionStatus?.get(sessionId);
|
||||
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
|
||||
return 'idle';
|
||||
};
|
||||
|
||||
return (projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
|
||||
const cached = result.get(projectPath);
|
||||
if (cached) return cached;
|
||||
const projectRoot = normalize(projectPath);
|
||||
if (!projectRoot) {
|
||||
return { hasRunning: false, hasUnread: false };
|
||||
}
|
||||
|
||||
const projectRoot = normalize(projectPath);
|
||||
if (!projectRoot) {
|
||||
const empty = { hasRunning: false, hasUnread: false };
|
||||
result.set(projectPath, empty);
|
||||
return empty;
|
||||
}
|
||||
|
||||
const dirs: string[] = [projectRoot];
|
||||
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
|
||||
for (const meta of worktrees) {
|
||||
const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null;
|
||||
if (typeof p === 'string' && p.trim()) {
|
||||
const normalized = normalize(p);
|
||||
if (normalized && normalized !== projectRoot) {
|
||||
dirs.push(normalized);
|
||||
}
|
||||
const dirs: string[] = [projectRoot];
|
||||
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
|
||||
for (const meta of worktrees) {
|
||||
const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null;
|
||||
if (typeof p === 'string' && p.trim()) {
|
||||
const normalized = normalize(p);
|
||||
if (normalized && normalized !== projectRoot) {
|
||||
dirs.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
let hasRunning = false;
|
||||
let hasUnread = false;
|
||||
const seen = new Set<string>();
|
||||
let hasRunning = false;
|
||||
let hasUnread = false;
|
||||
|
||||
for (const dir of dirs) {
|
||||
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
|
||||
for (const session of list) {
|
||||
if (!session?.id || seen.has(session.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(session.id);
|
||||
|
||||
const statusType = getStatusType(session.id);
|
||||
if (statusType === 'busy' || statusType === 'retry') {
|
||||
hasRunning = true;
|
||||
}
|
||||
|
||||
if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) {
|
||||
hasUnread = true;
|
||||
}
|
||||
|
||||
if (hasRunning && hasUnread) {
|
||||
break;
|
||||
}
|
||||
for (const dir of dirs) {
|
||||
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
|
||||
for (const session of list) {
|
||||
if (!session?.id || seen.has(session.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(session.id);
|
||||
|
||||
const statusType = getStatusType(session.id);
|
||||
if (statusType === 'busy' || statusType === 'retry') {
|
||||
hasRunning = true;
|
||||
}
|
||||
|
||||
if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) {
|
||||
hasUnread = true;
|
||||
}
|
||||
|
||||
if (hasRunning && hasUnread) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasRunning && hasUnread) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const status = { hasRunning, hasUnread };
|
||||
result.set(projectPath, status);
|
||||
return status;
|
||||
};
|
||||
return { hasRunning, hasUnread };
|
||||
}, [sessionsByDirectory, getSessionsByDirectory, availableWorktreesByProject, sessionStatus, sessionAttentionStates, currentSessionId]);
|
||||
|
||||
return projectStatusMap;
|
||||
@@ -390,6 +379,8 @@ interface SessionStatusHeaderProps {
|
||||
currentSessionTitle: string;
|
||||
currentProjectLabel?: string;
|
||||
currentProjectIcon?: string | null;
|
||||
currentProjectIconImageUrl?: string | null;
|
||||
currentProjectIconBackground?: string | null;
|
||||
currentProjectColor?: string | null;
|
||||
onToggle: () => void;
|
||||
isExpanded?: boolean;
|
||||
@@ -400,15 +391,23 @@ function SessionStatusHeader({
|
||||
currentSessionTitle,
|
||||
currentProjectLabel,
|
||||
currentProjectIcon,
|
||||
currentProjectIconImageUrl,
|
||||
currentProjectIconBackground,
|
||||
currentProjectColor,
|
||||
onToggle,
|
||||
isExpanded = false,
|
||||
childIndicators = []
|
||||
}: SessionStatusHeaderProps) {
|
||||
const [imageFailed, setImageFailed] = React.useState(false);
|
||||
const ProjectIcon = currentProjectIcon ? PROJECT_ICON_MAP[currentProjectIcon] : null;
|
||||
const imageUrl = !imageFailed ? currentProjectIconImageUrl : null;
|
||||
const projectColorVar = currentProjectColor ? (PROJECT_COLOR_MAP[currentProjectColor] ?? null) : null;
|
||||
const extraCount = childIndicators.length > 3 ? childIndicators.length - 3 : 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
setImageFailed(false);
|
||||
}, [currentProjectIconImageUrl]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -418,7 +417,20 @@ function SessionStatusHeader({
|
||||
{!isExpanded && currentProjectLabel && (
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex items-center gap-1 leading-none">
|
||||
{ProjectIcon && (
|
||||
{imageUrl ? (
|
||||
<span
|
||||
className="inline-flex h-2.5 w-2.5 items-center justify-center overflow-hidden rounded-[1px]"
|
||||
style={currentProjectIconBackground ? { backgroundColor: currentProjectIconBackground } : undefined}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
draggable={false}
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
) : ProjectIcon && (
|
||||
<ProjectIcon
|
||||
className="h-2.5 w-2.5"
|
||||
style={projectColorVar ? { color: projectColorVar } : undefined}
|
||||
@@ -534,7 +546,13 @@ function ProjectButton({
|
||||
onRemoveProject,
|
||||
formatProjectLabel,
|
||||
}: ProjectButtonProps) {
|
||||
const [imageFailed, setImageFailed] = React.useState(false);
|
||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const projectIconImageUrl = !imageFailed ? getProjectIconImageUrl(project) : null;
|
||||
|
||||
React.useEffect(() => {
|
||||
setImageFailed(false);
|
||||
}, [project.id, project.iconImage?.updatedAt]);
|
||||
|
||||
const longPressHandlers = useLongPress(
|
||||
() => {
|
||||
@@ -569,7 +587,20 @@ function ProjectButton({
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
{ProjectIcon && (
|
||||
{projectIconImageUrl ? (
|
||||
<span
|
||||
className="inline-flex h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[2px]"
|
||||
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
||||
>
|
||||
<img
|
||||
src={projectIconImageUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
draggable={false}
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
) : ProjectIcon && (
|
||||
<ProjectIcon
|
||||
className="h-3.5 w-3.5"
|
||||
style={projectColorVar ? { color: projectColorVar } : undefined}
|
||||
@@ -752,6 +783,8 @@ function CollapsedView({
|
||||
currentSessionTitle,
|
||||
currentProjectLabel,
|
||||
currentProjectIcon,
|
||||
currentProjectIconImageUrl,
|
||||
currentProjectIconBackground,
|
||||
currentProjectColor,
|
||||
onToggle,
|
||||
onNewSession,
|
||||
@@ -764,6 +797,8 @@ function CollapsedView({
|
||||
currentSessionTitle: string;
|
||||
currentProjectLabel?: string;
|
||||
currentProjectIcon?: string | null;
|
||||
currentProjectIconImageUrl?: string | null;
|
||||
currentProjectIconBackground?: string | null;
|
||||
currentProjectColor?: string | null;
|
||||
onToggle: () => void;
|
||||
onNewSession: () => void;
|
||||
@@ -789,6 +824,8 @@ function CollapsedView({
|
||||
currentSessionTitle={currentSessionTitle}
|
||||
currentProjectLabel={currentProjectLabel}
|
||||
currentProjectIcon={currentProjectIcon}
|
||||
currentProjectIconImageUrl={currentProjectIconImageUrl}
|
||||
currentProjectIconBackground={currentProjectIconBackground}
|
||||
currentProjectColor={currentProjectColor}
|
||||
onToggle={onToggle}
|
||||
childIndicators={childIndicators}
|
||||
@@ -827,6 +864,8 @@ function ExpandedView({
|
||||
currentSessionTitle,
|
||||
currentProjectLabel,
|
||||
currentProjectIcon,
|
||||
currentProjectIconImageUrl,
|
||||
currentProjectIconBackground,
|
||||
currentProjectColor,
|
||||
isExpanded,
|
||||
onToggleCollapse,
|
||||
@@ -854,6 +893,8 @@ function ExpandedView({
|
||||
currentSessionTitle: string;
|
||||
currentProjectLabel?: string;
|
||||
currentProjectIcon?: string | null;
|
||||
currentProjectIconImageUrl?: string | null;
|
||||
currentProjectIconBackground?: string | null;
|
||||
currentProjectColor?: string | null;
|
||||
isExpanded: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
@@ -936,6 +977,8 @@ function ExpandedView({
|
||||
currentSessionTitle={currentSessionTitle}
|
||||
currentProjectLabel={currentProjectLabel}
|
||||
currentProjectIcon={currentProjectIcon}
|
||||
currentProjectIconImageUrl={currentProjectIconImageUrl}
|
||||
currentProjectIconBackground={currentProjectIconBackground}
|
||||
currentProjectColor={currentProjectColor}
|
||||
onToggle={onToggleCollapse}
|
||||
isExpanded={true}
|
||||
@@ -1053,6 +1096,8 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
const activeProject = getActiveProject();
|
||||
const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory);
|
||||
const currentProjectIcon = activeProject?.icon;
|
||||
const currentProjectIconImageUrl = activeProject ? getProjectIconImageUrl(activeProject) : null;
|
||||
const currentProjectIconBackground = activeProject?.iconBackground ?? null;
|
||||
const currentProjectColor = activeProject?.color;
|
||||
|
||||
// Calculate token usage for current session
|
||||
@@ -1097,8 +1142,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
sessionEvents.requestDirectoryDialog();
|
||||
return;
|
||||
}
|
||||
import('@/lib/desktop')
|
||||
.then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
|
||||
requestDirectoryAccess('')
|
||||
.then((result) => {
|
||||
if (result.success && result.path) {
|
||||
const added = addProject(result.path, { id: result.projectId });
|
||||
@@ -1127,6 +1171,8 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
currentSessionTitle={currentSessionTitle}
|
||||
currentProjectLabel={currentProjectLabel}
|
||||
currentProjectIcon={currentProjectIcon}
|
||||
currentProjectIconImageUrl={currentProjectIconImageUrl}
|
||||
currentProjectIconBackground={currentProjectIconBackground}
|
||||
currentProjectColor={currentProjectColor}
|
||||
onToggle={() => setIsMobileSessionStatusBarCollapsed(false)}
|
||||
onNewSession={handleCreateSession}
|
||||
@@ -1146,6 +1192,8 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
currentSessionTitle={currentSessionTitle}
|
||||
currentProjectLabel={currentProjectLabel}
|
||||
currentProjectIcon={currentProjectIcon}
|
||||
currentProjectIconImageUrl={currentProjectIconImageUrl}
|
||||
currentProjectIconBackground={currentProjectIconBackground}
|
||||
currentProjectColor={currentProjectColor}
|
||||
isExpanded={isExpanded}
|
||||
onToggleCollapse={() => {
|
||||
|
||||
Reference in New Issue
Block a user