Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 21 additions & 18 deletions apps/desktop/src/components/main/body/sessions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useTitleGeneration } from "../../../../hooks/useTitleGeneration";
import * as main from "../../../../store/tinybase/store/main";
import { useSessionTitle } from "../../../../store/zustand/live-title";
import { type Tab, useTabs } from "../../../../store/zustand/tabs";
import { SessionPreviewCard } from "../../../session-preview-card";
import { StandardTabWrapper } from "../index";
import { type TabItem, TabItemBase } from "../shared";
import { CaretPositionProvider } from "./caret-position-context";
Expand Down Expand Up @@ -78,24 +79,26 @@ export const TabItemNote: TabItem<Extract<Tab, { type: "sessions" }>> = ({
}, [isActive, stop, tab, handleCloseThis]);

return (
<TabItemBase
icon={<StickyNoteIcon className="w-4 h-4" />}
title={title || "Untitled"}
selected={tab.active}
active={isActive}
accent={isActive ? "red" : "neutral"}
finalizing={showSpinner}
pinned={tab.pinned}
tabIndex={tabIndex}
showCloseConfirmation={showCloseConfirmation}
onCloseConfirmationChange={handleCloseConfirmationChange}
handleCloseThis={handleCloseWithStop}
handleSelectThis={() => handleSelectThis(tab)}
handleCloseOthers={handleCloseOthers}
handleCloseAll={handleCloseAll}
handlePinThis={() => handlePinThis(tab)}
handleUnpinThis={() => handleUnpinThis(tab)}
/>
<SessionPreviewCard sessionId={tab.id} side="bottom" enabled={!tab.active}>
<TabItemBase
icon={<StickyNoteIcon className="w-4 h-4" />}
title={title || "Untitled"}
selected={tab.active}
active={isActive}
accent={isActive ? "red" : "neutral"}
finalizing={showSpinner}
pinned={tab.pinned}
tabIndex={tabIndex}
showCloseConfirmation={showCloseConfirmation}
onCloseConfirmationChange={handleCloseConfirmationChange}
handleCloseThis={handleCloseWithStop}
handleSelectThis={() => handleSelectThis(tab)}
handleCloseOthers={handleCloseOthers}
handleCloseAll={handleCloseAll}
handlePinThis={() => handlePinThis(tab)}
handleUnpinThis={() => handleUnpinThis(tab)}
/>
</SessionPreviewCard>
);
};

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/main/body/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export function TabItemBase({
</div>
)}
</div>
<span className="truncate">{title}</span>
<span className="truncate pointer-events-none">{title}</span>
</div>
{showShortcut && (
<div className="absolute top-0.75 right-2 pointer-events-none">
Expand Down
33 changes: 20 additions & 13 deletions apps/desktop/src/components/main/sidebar/timeline/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
TimelinePrecision,
} from "../../../../utils/timeline";
import { InteractiveButton } from "../../../interactive-button";
import { SessionPreviewCard } from "../../../session-preview-card";

export const TimelineItemComponent = memo(
({
Expand Down Expand Up @@ -124,7 +125,7 @@ function ItemBase({
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<div
className={cn(
"text-sm font-normal truncate",
"text-sm font-normal truncate pointer-events-none",
ignored && "line-through",
)}
>
Expand Down Expand Up @@ -425,18 +426,24 @@ const SessionItem = memo(
);

return (
<ItemBase
title={title}
displayTime={displayTime}
calendarId={calendarId}
showSpinner={showSpinner}
selected={selected}
multiSelected={multiSelected}
onClick={handleClick}
onCmdClick={handleCmdClick}
onShiftClick={handleShiftClick}
contextMenu={contextMenu}
/>
<SessionPreviewCard
sessionId={sessionId}
side="right"
enabled={!selected}
>
<ItemBase
title={title}
displayTime={displayTime}
calendarId={calendarId}
showSpinner={showSpinner}
selected={selected}
multiSelected={multiSelected}
onClick={handleClick}
onCmdClick={handleCmdClick}
onShiftClick={handleShiftClick}
contextMenu={contextMenu}
/>
</SessionPreviewCard>
);
},
);
Expand Down
236 changes: 236 additions & 0 deletions apps/desktop/src/components/session-preview-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { useMotionValue, useSpring, useTransform } from "motion/react";
import { useCallback, useMemo, useRef, useState } from "react";

import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@hypr/ui/components/ui/hover-card";
import { cn, format, safeParseDate } from "@hypr/utils";

import { extractPlainText } from "../contexts/search/engine/utils";
import * as main from "../store/tinybase/store/main";

const MAX_PREVIEW_LENGTH = 200;
const FOLLOW_RANGE = 16;
const SPRING_CONFIG = { stiffness: 300, damping: 30, mass: 0.5 };

const OPEN_DELAY_COLD = 400;
const OPEN_DELAY_WARM = 0;
const WARMUP_COOLDOWN_MS = 600;

let lastPreviewClosedAt = 0;

function isWarmedUp() {
return Date.now() - lastPreviewClosedAt < WARMUP_COOLDOWN_MS;
}

function markPreviewClosed() {
lastPreviewClosedAt = Date.now();
}

function useSessionPreviewData(sessionId: string) {
const title =
(main.UI.useCell("sessions", sessionId, "title", main.STORE_ID) as
| string
| undefined) || "";
const rawMd = main.UI.useCell(
"sessions",
sessionId,
"raw_md",
main.STORE_ID,
) as string | undefined;
const createdAt = main.UI.useCell(
"sessions",
sessionId,
"created_at",
main.STORE_ID,
) as string | undefined;
const eventJson = main.UI.useCell(
"sessions",
sessionId,
"event_json",
main.STORE_ID,
) as string | undefined;

const participantMappingIds = main.UI.useSliceRowIds(
main.INDEXES.sessionParticipantsBySession,
sessionId,
main.STORE_ID,
);

const previewText = useMemo(() => {
const text = extractPlainText(rawMd);
if (!text) return "";
return text.length > MAX_PREVIEW_LENGTH
? text.slice(0, MAX_PREVIEW_LENGTH) + "…"
: text;
}, [rawMd]);

const dateDisplay = useMemo(() => {
let timestamp = createdAt;
if (eventJson) {
try {
const event = JSON.parse(eventJson);
if (event?.started_at) timestamp = event.started_at;
} catch {}
}
const parsed = safeParseDate(timestamp);
if (!parsed) return "";
return format(parsed, "MMM d, yyyy · h:mm a");
}, [createdAt, eventJson]);

return { title, previewText, dateDisplay, participantMappingIds };
}

function useCursorFollow(axis: "x" | "y") {
const triggerRef = useRef<HTMLDivElement>(null);
const normalized = useMotionValue(0.5);

const offset = useSpring(
useTransform(normalized, [0, 1], [-FOLLOW_RANGE, FOLLOW_RANGE]),
SPRING_CONFIG,
);

const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
const el = triggerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const ratio =
axis === "y"
? (e.clientY - rect.top) / rect.height
: (e.clientX - rect.left) / rect.width;
normalized.set(Math.max(0, Math.min(1, ratio)));
},
[axis, normalized],
);

const handleMouseLeave = useCallback(() => {
normalized.set(0.5);
}, [normalized]);

const style = axis === "y" ? { translateY: offset } : { translateX: offset };

return { triggerRef, handleMouseMove, handleMouseLeave, style };
}

function useParticipantNames(mappingIds: string[]) {
const allResults = main.UI.useResultTable(
main.QUERIES.sessionParticipantsWithDetails,
main.STORE_ID,
);

return useMemo(() => {
const names: string[] = [];
for (const id of mappingIds) {
const row = allResults[id];
if (!row) continue;
const name = (row.human_name as string) || "Unknown";
names.push(name);
}
return names;
}, [mappingIds, allResults]);
}

const MAX_VISIBLE_PARTICIPANTS = 3;

function ParticipantsList({ mappingIds }: { mappingIds: string[] }) {
const names = useParticipantNames(mappingIds);

if (names.length === 0) return null;

const visible = names.slice(0, MAX_VISIBLE_PARTICIPANTS);
const remaining = names.length - visible.length;

return (
<div className="text-xs text-neutral-500 line-clamp-2">
{visible.join(", ")}
{remaining > 0 && (
<span className="text-neutral-500"> and {remaining} more</span>
)}
</div>
);
}

export function SessionPreviewCard({
sessionId,
side,
children,
enabled = true,
}: {
sessionId: string;
side: "right" | "bottom";
children: React.ReactNode;
enabled?: boolean;
}) {
const { title, previewText, dateDisplay, participantMappingIds } =
useSessionPreviewData(sessionId);

const followAxis = side === "right" ? "y" : "x";
const { triggerRef, handleMouseMove, handleMouseLeave, style } =
useCursorFollow(followAxis);

const [openDelay, setOpenDelay] = useState(
isWarmedUp() ? OPEN_DELAY_WARM : OPEN_DELAY_COLD,
);

const handleOpenChange = useCallback((open: boolean) => {
if (open) {
markPreviewClosed();
} else {
markPreviewClosed();
setOpenDelay(OPEN_DELAY_WARM);
}
}, []);

const handleMouseEnter = useCallback(() => {
setOpenDelay(isWarmedUp() ? OPEN_DELAY_WARM : OPEN_DELAY_COLD);
}, []);

if (!enabled) {
return <>{children}</>;
}

return (
<HoverCard
openDelay={openDelay}
closeDelay={0}
onOpenChange={handleOpenChange}
>
<HoverCardTrigger asChild>
<div
ref={triggerRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnter}
className={side === "bottom" ? "h-full" : ""}
>
{children}
</div>
</HoverCardTrigger>
<HoverCardContent
side={side}
align="start"
sideOffset={8}
followStyle={style}
className={cn(["w-72 p-4", "pointer-events-none"])}
>
<div className="flex flex-col gap-1">
{dateDisplay && (
<div className="text-xs text-neutral-500">{dateDisplay}</div>
)}

<div className="font-medium text-sm">{title || "Untitled"}</div>
<ParticipantsList mappingIds={participantMappingIds} />

{previewText && (
<div className="text-xs leading-relaxed line-clamp-4 text-gradient-to-b from-neutral-700 to-transparent">
{previewText}
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
);
}
Loading
Loading