2026-03-06 17:22:59 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import type { Session } from '@opencode-ai/sdk/v2';
|
|
|
|
|
import type { SessionSummaryMeta } from './types';
|
|
|
|
|
|
|
|
|
|
const formatDateLabel = (value: string | number) => {
|
|
|
|
|
const targetDate = new Date(value);
|
|
|
|
|
const today = new Date();
|
|
|
|
|
const isSameDay = (a: Date, b: Date) =>
|
|
|
|
|
a.getFullYear() === b.getFullYear() &&
|
|
|
|
|
a.getMonth() === b.getMonth() &&
|
|
|
|
|
a.getDate() === b.getDate();
|
|
|
|
|
|
|
|
|
|
const yesterday = new Date(today);
|
|
|
|
|
yesterday.setDate(today.getDate() - 1);
|
|
|
|
|
|
|
|
|
|
if (isSameDay(targetDate, today)) {
|
|
|
|
|
return 'Today';
|
|
|
|
|
}
|
|
|
|
|
if (isSameDay(targetDate, yesterday)) {
|
|
|
|
|
return 'Yesterday';
|
|
|
|
|
}
|
|
|
|
|
const formatted = targetDate.toLocaleDateString('en-US', {
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
});
|
|
|
|
|
return formatted.replace(',', '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const formatSessionDateLabel = (updatedMs: number): string => {
|
|
|
|
|
const today = new Date();
|
|
|
|
|
const updatedDate = new Date(updatedMs);
|
|
|
|
|
const isSameDay = (a: Date, b: Date) =>
|
|
|
|
|
a.getFullYear() === b.getFullYear() &&
|
|
|
|
|
a.getMonth() === b.getMonth() &&
|
|
|
|
|
a.getDate() === b.getDate();
|
|
|
|
|
|
|
|
|
|
if (isSameDay(updatedDate, today)) {
|
|
|
|
|
const diff = Date.now() - updatedMs;
|
|
|
|
|
if (diff < 60_000) return 'Just now';
|
|
|
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}min ago`;
|
|
|
|
|
return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return formatDateLabel(updatedMs);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
export const formatSessionCompactDateLabel = (updatedMs: number): string => {
|
|
|
|
|
const diff = Math.max(0, Date.now() - updatedMs);
|
|
|
|
|
|
|
|
|
|
const minute = 60_000;
|
|
|
|
|
const hour = 60 * minute;
|
|
|
|
|
const day = 24 * hour;
|
|
|
|
|
const week = 7 * day;
|
|
|
|
|
const month = 30 * day;
|
|
|
|
|
const year = 365 * day;
|
|
|
|
|
|
|
|
|
|
if (diff < hour) {
|
|
|
|
|
return `${Math.max(1, Math.floor(diff / minute))}m`;
|
|
|
|
|
}
|
|
|
|
|
if (diff < day) {
|
|
|
|
|
return `${Math.floor(diff / hour)}h`;
|
|
|
|
|
}
|
|
|
|
|
if (diff < week) {
|
|
|
|
|
return `${Math.floor(diff / day)}d`;
|
|
|
|
|
}
|
|
|
|
|
if (diff < 5 * week) {
|
|
|
|
|
return `${Math.floor(diff / week)}w`;
|
|
|
|
|
}
|
|
|
|
|
if (diff < year) {
|
|
|
|
|
return `${Math.floor(diff / month)}mo`;
|
|
|
|
|
}
|
|
|
|
|
return `${Math.floor(diff / year)}y`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-06 17:22:59 +02:00
|
|
|
export const normalizePath = (value?: string | null) => {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
|
|
|
return normalized.length === 0 ? '/' : normalized;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-16 09:46:05 -04:00
|
|
|
export const isPathWithinProject = (directory?: string | null, projectPath?: string | null): boolean => {
|
|
|
|
|
const normalizedDirectory = normalizePath(directory);
|
|
|
|
|
const normalizedProjectPath = normalizePath(projectPath);
|
|
|
|
|
if (!normalizedDirectory || !normalizedProjectPath) return false;
|
|
|
|
|
if (normalizedDirectory === normalizedProjectPath) return true;
|
|
|
|
|
if (normalizedProjectPath === '/') return normalizedDirectory.startsWith('/');
|
|
|
|
|
return normalizedDirectory.startsWith(`${normalizedProjectPath}/`);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-06 17:22:59 +02:00
|
|
|
export const normalizeForBranchComparison = (value: string): string => {
|
|
|
|
|
return value
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/^opencode[/-]?/i, '')
|
|
|
|
|
.replace(/[-_]/g, '')
|
|
|
|
|
.trim();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const isBranchDifferentFromLabel = (branch: string | null, label: string): boolean => {
|
|
|
|
|
if (!branch) return false;
|
|
|
|
|
return normalizeForBranchComparison(branch) !== normalizeForBranchComparison(label);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toFiniteNumber = (value: unknown): number | undefined => {
|
|
|
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
if (Number.isFinite(parsed)) {
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getSessionCreatedAt = (session: Session): number => {
|
|
|
|
|
return toFiniteNumber(session.time?.created) ?? 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getSessionUpdatedAt = (session: Session): number => {
|
|
|
|
|
return toFiniteNumber(session.time?.updated) ?? toFiniteNumber(session.time?.created) ?? 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const compareSessionsByPinnedAndTime = (
|
|
|
|
|
a: Session,
|
|
|
|
|
b: Session,
|
|
|
|
|
pinnedSessionIds: Set<string>,
|
|
|
|
|
): number => {
|
|
|
|
|
const aPinned = pinnedSessionIds.has(a.id);
|
|
|
|
|
const bPinned = pinnedSessionIds.has(b.id);
|
|
|
|
|
if (aPinned !== bPinned) {
|
|
|
|
|
return aPinned ? -1 : 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (aPinned && bPinned) {
|
|
|
|
|
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-04 02:19:55 +03:00
|
|
|
export const compareSessionsByPinnedAndCreated = (
|
|
|
|
|
a: Session,
|
|
|
|
|
b: Session,
|
|
|
|
|
pinnedSessionIds: Set<string>,
|
|
|
|
|
): number => {
|
|
|
|
|
const aPinned = pinnedSessionIds.has(a.id);
|
|
|
|
|
const bPinned = pinnedSessionIds.has(b.id);
|
|
|
|
|
if (aPinned !== bPinned) {
|
|
|
|
|
return aPinned ? -1 : 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-06 17:22:59 +02:00
|
|
|
export const dedupeSessionsById = (sessions: Session[]): Session[] => {
|
|
|
|
|
const byId = new Map<string, Session>();
|
|
|
|
|
sessions.forEach((session) => {
|
|
|
|
|
byId.set(session.id, session);
|
|
|
|
|
});
|
|
|
|
|
return Array.from(byId.values());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const getArchivedScopeKey = (projectRoot: string): string => `__archived__:${projectRoot}`;
|
|
|
|
|
|
|
|
|
|
export const resolveArchivedFolderName = (session: Session, projectRoot: string | null): string => {
|
|
|
|
|
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
|
|
|
|
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
|
|
|
|
const resolved = sessionDirectory ?? projectWorktree;
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
return 'unassigned';
|
|
|
|
|
}
|
|
|
|
|
if (projectRoot && resolved === projectRoot) {
|
|
|
|
|
return 'project root';
|
|
|
|
|
}
|
|
|
|
|
const source = projectRoot && resolved.startsWith(`${projectRoot}/`)
|
|
|
|
|
? resolved.slice(projectRoot.length + 1)
|
|
|
|
|
: resolved;
|
|
|
|
|
const segments = source.split('/').filter(Boolean);
|
|
|
|
|
return segments[segments.length - 1] ?? 'unassigned';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const isSessionRelatedToProject = (
|
|
|
|
|
session: Session,
|
|
|
|
|
projectRoot: string,
|
|
|
|
|
validDirectories?: Set<string>,
|
|
|
|
|
): boolean => {
|
|
|
|
|
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
|
|
|
|
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
|
|
|
|
|
|
|
|
|
if (projectWorktree && (projectWorktree === projectRoot || projectWorktree.startsWith(`${projectRoot}/`))) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!sessionDirectory) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (validDirectories && validDirectories.has(sessionDirectory)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return sessionDirectory === projectRoot || sessionDirectory.startsWith(`${projectRoot}/`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseSummaryCount = (value: number | string | null | undefined): number | null => {
|
|
|
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
if (typeof value === 'string') {
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
if (Number.isFinite(parsed)) {
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const resolveSessionDiffStats = (summary?: SessionSummaryMeta): { additions: number; deletions: number } | null => {
|
|
|
|
|
if (!summary) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const directAdditions = parseSummaryCount(summary.additions);
|
|
|
|
|
const directDeletions = parseSummaryCount(summary.deletions);
|
|
|
|
|
if (directAdditions !== null || directDeletions !== null) {
|
|
|
|
|
const stats = {
|
|
|
|
|
additions: Math.max(0, directAdditions ?? 0),
|
|
|
|
|
deletions: Math.max(0, directDeletions ?? 0),
|
|
|
|
|
};
|
|
|
|
|
return stats.additions === 0 && stats.deletions === 0 ? null : stats;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const diffs = Array.isArray(summary.diffs) ? summary.diffs : [];
|
|
|
|
|
if (diffs.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let additions = 0;
|
|
|
|
|
let deletions = 0;
|
|
|
|
|
diffs.forEach((diff) => {
|
|
|
|
|
additions += Math.max(0, parseSummaryCount(diff.additions) ?? 0);
|
|
|
|
|
deletions += Math.max(0, parseSummaryCount(diff.deletions) ?? 0);
|
|
|
|
|
});
|
|
|
|
|
return additions === 0 && deletions === 0 ? null : { additions, deletions };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const formatProjectLabel = (label: string): string => {
|
|
|
|
|
return label
|
|
|
|
|
.replace(/[-_]/g, ' ')
|
|
|
|
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const renderHighlightedText = (text: string, query: string): React.ReactNode => {
|
|
|
|
|
if (!query) {
|
|
|
|
|
return text;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const loweredText = text.toLowerCase();
|
|
|
|
|
const loweredQuery = query.toLowerCase();
|
|
|
|
|
const queryLength = loweredQuery.length;
|
|
|
|
|
if (queryLength === 0) {
|
|
|
|
|
return text;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parts: React.ReactNode[] = [];
|
|
|
|
|
let cursor = 0;
|
|
|
|
|
let matchIndex = loweredText.indexOf(loweredQuery, cursor);
|
|
|
|
|
|
|
|
|
|
while (matchIndex !== -1) {
|
|
|
|
|
if (matchIndex > cursor) {
|
|
|
|
|
parts.push(text.slice(cursor, matchIndex));
|
|
|
|
|
}
|
|
|
|
|
const matchText = text.slice(matchIndex, matchIndex + queryLength);
|
|
|
|
|
parts.push(
|
|
|
|
|
<mark
|
|
|
|
|
key={`${matchIndex}-${matchText}`}
|
|
|
|
|
className="bg-primary text-primary-foreground ring-1 ring-primary/90"
|
|
|
|
|
>
|
|
|
|
|
{matchText}
|
|
|
|
|
</mark>,
|
|
|
|
|
);
|
|
|
|
|
cursor = matchIndex + queryLength;
|
|
|
|
|
matchIndex = loweredText.indexOf(loweredQuery, cursor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cursor < text.length) {
|
|
|
|
|
parts.push(text.slice(cursor));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return parts.length > 0 ? parts : text;
|
|
|
|
|
};
|