From e869a55d31a871829bfd6067a5effef14d57033f Mon Sep 17 00:00:00 2001
From: ComputelessComputer
<63365510+ComputelessComputer@users.noreply.github.com>
Date: Mon, 1 Jun 2026 19:43:25 +0900
Subject: [PATCH] fix(desktop): add now note timeline card
Show a Create new note card next to the top timeline now indicator and wire it to create a normal note.
---
.../src/main/top-meeting-timeline.test.tsx | 154 ++++++++++++++++--
.../desktop/src/main/top-meeting-timeline.tsx | 120 +++++++++-----
2 files changed, 217 insertions(+), 57 deletions(-)
diff --git a/apps/desktop/src/main/top-meeting-timeline.test.tsx b/apps/desktop/src/main/top-meeting-timeline.test.tsx
index 4d9f727669..789fd71066 100644
--- a/apps/desktop/src/main/top-meeting-timeline.test.tsx
+++ b/apps/desktop/src/main/top-meeting-timeline.test.tsx
@@ -1,4 +1,5 @@
import {
+ act,
cleanup,
fireEvent,
render,
@@ -8,7 +9,7 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
- createNewMeeting: vi.fn(),
+ createNewNote: vi.fn(),
liveSessionId: null as string | null,
openNew: vi.fn(),
startDragging: vi.fn().mockResolvedValue(undefined),
@@ -60,7 +61,7 @@ vi.mock("~/shared/hooks/useNativeContextMenu", () => ({
}));
vi.mock("~/shared/useNewNote", () => ({
- useNewNoteAndListen: () => mocks.createNewMeeting,
+ useNewNote: () => mocks.createNewNote,
}));
vi.mock("~/store/tinybase/hooks", () => ({
@@ -150,7 +151,7 @@ import {
describe("TopMeetingTimeline", () => {
beforeEach(() => {
- mocks.createNewMeeting.mockClear();
+ mocks.createNewNote.mockClear();
mocks.openNew.mockClear();
mocks.startDragging.mockClear();
mocks.stopListening.mockClear();
@@ -170,7 +171,7 @@ describe("TopMeetingTimeline", () => {
render();
const createButton = screen.getByRole("button", {
- name: /Create new meeting/,
+ name: /Create new note/,
});
fireEvent.pointerDown(createButton, {
@@ -182,14 +183,14 @@ describe("TopMeetingTimeline", () => {
fireEvent.click(createButton);
expect(mocks.startDragging).not.toHaveBeenCalled();
- expect(mocks.createNewMeeting).toHaveBeenCalledTimes(1);
+ expect(mocks.createNewNote).toHaveBeenCalledTimes(1);
});
it("starts window drag and ignores the release click after dragging", () => {
render();
const createButton = screen.getByRole("button", {
- name: /Create new meeting/,
+ name: /Create new note/,
});
fireEvent.pointerDown(createButton, {
@@ -206,7 +207,7 @@ describe("TopMeetingTimeline", () => {
fireEvent.click(createButton);
expect(mocks.startDragging).toHaveBeenCalledTimes(1);
- expect(mocks.createNewMeeting).not.toHaveBeenCalled();
+ expect(mocks.createNewNote).not.toHaveBeenCalled();
});
it("shows timeline item titles above start time metadata", () => {
@@ -224,10 +225,11 @@ describe("TopMeetingTimeline", () => {
render();
const title = screen.getByText("Design Review");
- const startMetadata = screen.getByText(startLabel);
- const buttonText = title.closest("button")?.textContent ?? "";
+ const cardButton = title.closest("button");
+ const startMetadata = within(cardButton!).getByText(startLabel);
+ const buttonText = cardButton?.textContent ?? "";
- expect(title.closest("button")).toBe(startMetadata.closest("button"));
+ expect(cardButton).toBe(startMetadata.closest("button"));
expect(buttonText.indexOf("Design Review")).toBeLessThan(
buttonText.indexOf(startLabel),
);
@@ -369,7 +371,7 @@ describe("TopMeetingTimeline", () => {
expect(indicatorX).toBe(cardWidth / 2);
});
- it("places the current time marker between open-ended notes and future meetings", () => {
+ it("places the create note card next to the latest note instead of a future event", () => {
const now = new Date("2026-05-29T15:41:00.000Z");
vi.useFakeTimers();
vi.setSystemTime(now);
@@ -393,12 +395,123 @@ describe("TopMeetingTimeline", () => {
render();
- expect(screen.getByTestId("top-timeline-now-indicator").style.left).toBe(
- "162px",
+ const createButton = screen.getByRole("button", {
+ name: /Create new note/,
+ });
+ const createCard = createButton.closest(
+ "[data-timeline-start-ms]",
+ ) as HTMLDivElement | null;
+ const cardWidth = Number.parseFloat(createCard?.style.width ?? "");
+ const carousel = createCard?.parentElement as HTMLDivElement | null;
+ const timelineCards = Array.from(
+ document.querySelectorAll("[data-timeline-start-ms]"),
+ );
+ const indicatorX = Number.parseFloat(
+ screen.getByTestId("top-timeline-now-indicator").style.left,
);
+
+ expect(timelineCards[0]?.textContent).toContain("Untitled");
+ expect(timelineCards[1]?.textContent).toContain("Create new note");
+ expect(timelineCards[2]?.textContent).toContain("Design sync");
+ expect(createCard?.textContent).not.toContain("Today");
+ expect(createCard?.textContent).not.toContain("3:41 PM");
+ expect(cardWidth).toBe(160);
+ expect(carousel?.style.width).toBe("512px");
+ expect(indicatorX).toBe(164);
});
- it("hides the current time marker during active ad-hoc meetings", () => {
+ it("keeps same-day events before the current-time create note card", () => {
+ const now = new Date("2026-05-29T15:41:00.000Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(now);
+
+ mocks.timelineSessionsTable = {
+ "session-1": {
+ created_at: new Date("2026-05-29T15:28:00.000Z").toISOString(),
+ event_json: "",
+ title: "Untitled",
+ },
+ };
+ mocks.timelineEventsTable = {
+ "event-1": {
+ calendar_id: null,
+ ended_at: new Date("2026-05-29T16:00:00.000Z").toISOString(),
+ has_recurrence_rules: false,
+ started_at: new Date("2026-05-29T15:33:00.000Z").toISOString(),
+ title: "Post-note event",
+ },
+ };
+
+ render();
+
+ screen.getByRole("button", { name: /Create new note/ });
+ const timelineCards = Array.from(
+ document.querySelectorAll("[data-timeline-start-ms]"),
+ );
+
+ expect(timelineCards[0]?.textContent).toContain("Untitled");
+ expect(timelineCards[1]?.textContent).toContain("Post-note event");
+ expect(timelineCards[2]?.textContent).toContain("Create new note");
+ });
+
+ it("places the create note card before future notes", () => {
+ const now = new Date("2026-05-29T15:41:00.000Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(now);
+
+ mocks.timelineSessionsTable = {
+ "session-1": {
+ created_at: new Date("2026-05-29T15:28:00.000Z").toISOString(),
+ event_json: "",
+ title: "Current note",
+ },
+ "session-2": {
+ created_at: new Date("2026-05-29T16:15:00.000Z").toISOString(),
+ event_json: "",
+ title: "Future note",
+ },
+ };
+
+ render();
+
+ const timelineCards = Array.from(
+ document.querySelectorAll("[data-timeline-start-ms]"),
+ );
+
+ expect(timelineCards[0]?.textContent).toContain("Current note");
+ expect(timelineCards[1]?.textContent).toContain("Create new note");
+ expect(timelineCards[2]?.textContent).toContain("Future note");
+ });
+
+ it("keeps manual scroll when the trailing create note clock ticks", () => {
+ const now = new Date("2026-05-29T15:41:00.000Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(now);
+
+ render();
+
+ const createCard = screen
+ .getByRole("button", { name: /Create new note/ })
+ .closest("[data-timeline-start-ms]") as HTMLDivElement | null;
+ const scrollContainer = createCard?.parentElement?.parentElement;
+
+ expect(scrollContainer).toBeTruthy();
+
+ Object.defineProperty(scrollContainer, "clientWidth", {
+ configurable: true,
+ value: 100,
+ });
+ scrollContainer!.scrollLeft = 42;
+
+ act(() => {
+ vi.setSystemTime(new Date(now.getTime() + 60_100));
+ vi.advanceTimersByTime(60_100);
+ });
+
+ expect(scrollContainer!.scrollLeft).toBe(42);
+ });
+
+ it("keeps create note visible next to active ad-hoc meetings", () => {
const now = new Date("2026-05-29T15:41:00.000Z");
vi.useFakeTimers();
vi.setSystemTime(now);
@@ -415,6 +528,15 @@ describe("TopMeetingTimeline", () => {
render();
+ const timelineCards = Array.from(
+ document.querySelectorAll("[data-timeline-start-ms]"),
+ );
+
+ expect(timelineCards[0]?.textContent).toContain("Live Ad-hoc");
+ expect(timelineCards[1]?.textContent).toContain("Create new note");
+ expect(
+ screen.getByRole("button", { name: /Create new note/ }),
+ ).toBeTruthy();
expect(screen.queryByTestId("top-timeline-now-indicator")).toBeNull();
});
@@ -460,11 +582,11 @@ describe("TopMeetingTimeline", () => {
configurable: true,
value: 100,
});
- scrollContainer!.scrollLeft = 250;
+ scrollContainer!.scrollLeft = 380;
fireEvent.scroll(scrollContainer!);
fireEvent.click(screen.getByRole("button", { name: "Now" }));
- expect(scrollContainer!.scrollLeft).toBe(112);
+ expect(scrollContainer!.scrollLeft).toBe(114);
});
});
diff --git a/apps/desktop/src/main/top-meeting-timeline.tsx b/apps/desktop/src/main/top-meeting-timeline.tsx
index c5e7a2139b..32d1d5556f 100644
--- a/apps/desktop/src/main/top-meeting-timeline.tsx
+++ b/apps/desktop/src/main/top-meeting-timeline.tsx
@@ -56,7 +56,7 @@ import {
type MenuItemDef,
useNativeContextMenu,
} from "~/shared/hooks/useNativeContextMenu";
-import { useNewNoteAndListen } from "~/shared/useNewNote";
+import { useNewNote } from "~/shared/useNewNote";
import { useCurrentTimeMs } from "~/sidebar/timeline/realtime";
import type {
TimelineEventRow,
@@ -74,6 +74,7 @@ import { useListener } from "~/stt/contexts";
const TIMELINE_HEIGHT = 44;
const TIMELINE_CAROUSEL_CARD_WIDTH = 160;
const TIMELINE_CAROUSEL_PADDING = 0;
+const TIMELINE_CAROUSEL_END_PADDING = 24;
const TIMELINE_CAROUSEL_GAP = 4;
const TIMELINE_PAST_DAYS = 6;
const TIMELINE_FUTURE_DAYS = 1;
@@ -165,7 +166,7 @@ export function TopMeetingTimeline({ currentTab }: { currentTab: Tab | null }) {
const [todayChipDirection, setTodayChipDirection] =
useState(null);
- const createNewMeeting = useNewNoteAndListen({ behavior: "current" });
+ const createNewNote = useNewNote({ behavior: "current" });
const openNew = useTabs((state) => state.openNew);
const renderItems = useMemo(
@@ -193,7 +194,8 @@ export function TopMeetingTimeline({ currentTab }: { currentTab: Tab | null }) {
endExclusive: timelineEnd,
hasHiddenPastItems,
hasHiddenFutureItems,
- timezone,
+ currentTime: new Date(currentTimeMs),
+ includeCreateNote: true,
}),
[
renderItems,
@@ -202,7 +204,7 @@ export function TopMeetingTimeline({ currentTab }: { currentTab: Tab | null }) {
timelineEnd,
hasHiddenPastItems,
hasHiddenFutureItems,
- timezone,
+ currentTimeMs,
],
);
const carouselWidth = getTimelineCarouselWidth(carouselItems);
@@ -440,12 +442,11 @@ export function TopMeetingTimeline({ currentTab }: { currentTab: Tab | null }) {
/>
) : null}
{carouselItems.map((renderItem) =>
- renderItem.kind === "create-meeting" ? (
-
) : renderItem.kind === "open-calendar" ? (
void;
}) {
return (
@@ -761,12 +760,9 @@ function TimelineCreateMeetingCard({
])}
onClick={onClick}
>
-
- {formatRelativeTimelineDay(item.start, timezone)}
-
- Create new meeting
+ Create new note
@@ -1003,7 +999,8 @@ function buildTimelineCarouselItems({
endExclusive,
hasHiddenPastItems,
hasHiddenFutureItems,
- timezone,
+ currentTime,
+ includeCreateNote,
}: {
renderItems: TimelineRenderItem[];
currentDate: Date;
@@ -1011,30 +1008,22 @@ function buildTimelineCarouselItems({
endExclusive: Date;
hasHiddenPastItems: boolean;
hasHiddenFutureItems: boolean;
- timezone?: string;
+ currentTime: Date;
+ includeCreateNote: boolean;
}): TimelineCarouselItem[] {
- const items: TimelineCarouselItem[] = [...renderItems];
- const hasToday = items.some((item) =>
- isSameTimelineDay(
- getTimelineCarouselItemStart(item),
- currentDate,
- timezone,
- ),
- );
-
- if (!hasToday) {
- items.push({
- kind: "create-meeting",
- id: `create-meeting-${currentDate.getTime()}`,
- start: currentDate,
- });
- }
+ const items: TimelineRenderItem[] = [...renderItems];
const sortedItems = items.sort(
(a, b) =>
getTimelineCarouselItemStart(a).getTime() -
getTimelineCarouselItemStart(b).getTime(),
);
+ const visibleItems = includeCreateNote
+ ? insertCreateNoteAtCurrentTime(sortedItems, {
+ currentDate,
+ currentTime,
+ })
+ : sortedItems;
const result: TimelineCarouselItem[] = hasHiddenPastItems
? [
@@ -1043,9 +1032,9 @@ function buildTimelineCarouselItems({
id: `open-calendar-${startInclusive.getTime()}`,
start: startInclusive,
},
- ...sortedItems,
+ ...visibleItems,
]
- : sortedItems;
+ : visibleItems;
if (hasHiddenFutureItems) {
result.push({
@@ -1058,6 +1047,36 @@ function buildTimelineCarouselItems({
return result;
}
+function insertCreateNoteAtCurrentTime(
+ items: TimelineRenderItem[],
+ {
+ currentDate,
+ currentTime,
+ }: {
+ currentDate: Date;
+ currentTime: Date;
+ },
+): TimelineCarouselItem[] {
+ const createNote: TimelineCreateNoteItem = {
+ kind: "create-note",
+ id: `create-note-${currentDate.getTime()}`,
+ start: currentTime,
+ };
+ const chronologicalIndex = items.findIndex(
+ (item) => item.start.getTime() > currentTime.getTime(),
+ );
+
+ if (chronologicalIndex === -1) {
+ return [...items, createNote];
+ }
+
+ return [
+ ...items.slice(0, chronologicalIndex),
+ createNote,
+ ...items.slice(chronologicalIndex),
+ ];
+}
+
function hasTimelineEntriesBefore(
entries: MeetingTimelineEntry[],
startInclusive: Date,
@@ -1094,6 +1113,7 @@ function getTimelineCarouselWidth(items: TimelineCarouselItem[]): number {
return (
TIMELINE_CAROUSEL_PADDING * 2 +
+ TIMELINE_CAROUSEL_END_PADDING +
contentWidth +
TIMELINE_CAROUSEL_GAP * Math.max(0, items.length - 1)
);
@@ -1207,6 +1227,14 @@ function getTimelineCarouselNowX(
}
if (previousItem && nextItem) {
+ if (previousItem.item.kind === "create-note") {
+ return previousItem.left;
+ }
+
+ if (nextItem.item.kind === "create-note") {
+ return nextItem.left;
+ }
+
return previousItem.right + (nextItem.left - previousItem.right) / 2;
}
@@ -1218,6 +1246,10 @@ function getTimelineCarouselNowX(
return null;
}
+ if (previousItem.item.kind === "create-note") {
+ return previousItem.left;
+ }
+
if (previousItem.item.kind !== "item") {
return previousItem.left + previousItem.width / 2;
}
@@ -1340,11 +1372,17 @@ function getTimelineCarouselAnchorKey(
return [
selectedSessionId ?? "today",
items.length,
- first ? getTimelineCarouselItemStart(first).getTime() : 0,
- last ? getTimelineCarouselItemStart(last).getTime() : 0,
+ first ? getTimelineCarouselAnchorToken(first) : 0,
+ last ? getTimelineCarouselAnchorToken(last) : 0,
].join(":");
}
+function getTimelineCarouselAnchorToken(item: TimelineCarouselItem): string {
+ return item.kind === "create-note"
+ ? item.id
+ : String(getTimelineCarouselItemStart(item).getTime());
+}
+
function getTimelineCarouselItemStart(item: TimelineCarouselItem): Date {
return item.start;
}