Skip to content
Merged
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
7 changes: 5 additions & 2 deletions apps/code/src/main/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,14 @@ export function createWindow(): void {
const platformWindowConfig =
process.platform === "darwin"
? {
titleBarStyle: "hiddenInset" as const,
// "hidden", not "hiddenInset": hiddenInset keeps macOS's own inset and
// ignores trafficLightPosition's y, which parked the dots near the
// bottom of the bar. "hidden" honours the position we ask for.
titleBarStyle: "hidden" as const,
// Centre the traffic lights vertically with the title bar's back/forward
// buttons (40px bar, 24px buttons → centre at y=20; 12px dots → top at 14).
// x mirrors y so the inset from the top and the left match.
trafficLightPosition: { x: 14, y: 14 },
trafficLightPosition: { x: 14, y: 12 },
// Exposes the titlebar-area-* CSS env vars so the renderer can
// clear the traffic lights exactly; their size varies by macOS
// version (bigger on Tahoe), so it must not hardcode a width.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/canvas/channelItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ function model(over: Partial<ChannelItemModel> = {}): ChannelItemModel {
authorName: null,
authorUuid: ME.uuid,
templateId: null,
task: null,
...over,
};
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/canvas/channelItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export interface ChannelItemModel {
authorName: string | null;
authorUuid: string | null;
templateId: string | null;
/**
* The source task record for `kind: "task"` rows, `null` for canvases. Rows
* need the whole task, not a projection of it: the status dot is derived from
* session/workspace/viewed state that only the renderer holds, and the hooks
* that supply it (`useChannelTaskData`, `useTaskPrStatus`) take a `Task`.
* Carrying the reference here keeps that a lookup the list already did rather
* than a second pass over every row.
*/
task: Task | null;
}

export interface ChannelItemOwner {
Expand Down Expand Up @@ -60,6 +69,7 @@ export function buildChannelItems({
authorName: d.createdBy ?? null,
authorUuid: d.createdByUuid ?? null,
templateId: d.templateId,
task: null,
}));

const taskItems: ChannelItemModel[] = feedTasks.flatMap((task) =>
Expand All @@ -78,6 +88,7 @@ export function buildChannelItems({
authorName: null,
authorUuid: task.created_by?.uuid ?? null,
templateId: null,
task,
},
],
);
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,9 +359,11 @@ export type {
} from "./task-creation-domain";
export {
formatClockTime,
formatDaySeparatorLabel,
formatRelativeTimeLong,
formatRelativeTimeShort,
getLocalDayDiff,
getLocalDayKey,
getRelativeDateGroup,
} from "./time";
export {
Expand Down
36 changes: 36 additions & 0 deletions packages/shared/src/time.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
formatClockTime,
formatDaySeparatorLabel,
formatRelativeTimeLong,
formatRelativeTimeShort,
getLocalDayDiff,
getLocalDayKey,
getRelativeDateGroup,
} from "./time";

Expand Down Expand Up @@ -124,3 +126,37 @@ describe("getRelativeDateGroup", () => {
expect(getRelativeDateGroup(NOW - 40 * DAY)).toBe("Earlier");
});
});

describe("getLocalDayKey", () => {
it("gives two times on the same local day one key", () => {
expect(getLocalDayKey(new Date(2026, 5, 15, 0, 1))).toBe(
getLocalDayKey(new Date(2026, 5, 15, 23, 59)),
);
});

it("separates adjacent days", () => {
expect(getLocalDayKey(new Date(2026, 5, 15))).not.toBe(
getLocalDayKey(new Date(2026, 5, 16)),
);
});
});

describe("formatDaySeparatorLabel", () => {
const now = new Date(2026, 5, 15, 12);

it.each([
["today", new Date(2026, 5, 15, 9), "Today"],
["yesterday", new Date(2026, 5, 14, 9), "Yesterday"],
// Within the week the weekday alone is unambiguous.
["earlier this week", new Date(2026, 5, 11), "Thursday 11th"],
// Past a week it needs the month, and past a year the year too.
["last month", new Date(2026, 4, 20), "Wednesday, May 20th"],
["last year", new Date(2025, 11, 3), "Wednesday, December 3rd, 2025"],
])("labels %s", (_case, date: Date, expected) => {
expect(formatDaySeparatorLabel(date, now)).toBe(expected);
});

it("labels a future timestamp as today rather than counting backwards", () => {
expect(formatDaySeparatorLabel(new Date(2026, 5, 16), now)).toBe("Today");
});
});
42 changes: 42 additions & 0 deletions packages/shared/src/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,48 @@ export function getLocalDayDiff(
return Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000);
}

/**
* Local calendar-day identity, for deciding where a day separator goes. Two
* timestamps on the same day share a key regardless of time, and the key is
* built from local getters (not the UTC ISO) so the split lands on the viewer's
* midnight.
*/
export function getLocalDayKey(timestamp: number | string | Date): string {
const date = new Date(timestamp);
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
}

function ordinal(n: number): string {
const suffix = ["th", "st", "nd", "rd"];
const rem = n % 100;
return `${n}${suffix[(rem - 20) % 10] ?? suffix[rem] ?? suffix[0]}`;
}

/**
* A day separator's label: "Today" / "Yesterday" for the recent days, then a
* weekday + ordinal ("Monday 5th") within the week, adding the month (and the
* year when it differs) further back so older separators stay unambiguous.
*
* Shared by the space feed and the space sidebar's recents, so the same day is
* never named two different ways in one window.
*/
export function formatDaySeparatorLabel(
timestamp: number | string | Date,
now: Date = new Date(),
): string {
const date = new Date(timestamp);
const days = getLocalDayDiff(date, now);
if (days <= 0) return "Today";
if (days === 1) return "Yesterday";
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
const day = ordinal(date.getDate());
if (days < 7) return `${weekday} ${day}`;
const month = date.toLocaleDateString(undefined, { month: "long" });
const year =
date.getFullYear() === now.getFullYear() ? "" : `, ${date.getFullYear()}`;
return `${weekday}, ${month} ${day}${year}`;
}

export function getRelativeDateGroup(
timestamp: number | string,
): string | null {
Expand Down
7 changes: 5 additions & 2 deletions packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,11 @@ export function BrowserTabStrip() {
// decides where a task/blank tab navigates.
const inChannels = pathname.startsWith("/website");
// Top-level app pages (Inbox, Agents, Skills, MCP servers, Command Center)
// are tab targets too. useAppView normalizes both the /code routes and
// their /website mirrors to the same view.type, so a tab survives either space.
// are tab targets too. useAppView normalizes both the /code routes and their
// /website mirrors to the same view.type, so a tab survives either space. A
// top-level route that ISN'T here falls through to `task-input`, and the
// strip then reconciles the location against the wrong tab and navigates
// straight back off the page.
const view = useAppView();
const routeAppView: AppView | null = isAppView(view.type) ? view.type : null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export function ActivityRow({
)}
{item.isUnread && (
<span
className="-top-0.5 -right-0.5 absolute h-2 w-2 rounded-full bg-(--red-9)"
className="-top-0.5 -right-0.5 absolute h-2 w-2 rounded-full bg-primary"
title="New activity"
/>
)}
Expand Down
51 changes: 30 additions & 21 deletions packages/ui/src/features/canvas/components/ChannelBackRow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CaretLeftIcon, StarIcon } from "@phosphor-icons/react";
import {
Button,
Skeleton,
Tooltip,
TooltipContent,
Expand All @@ -22,8 +23,9 @@ import { track } from "@posthog/ui/shell/analytics";
function RowStar({ channel }: { channel: Channel }) {
const { isStarred, toggleStar } = useChannelStarToggle(channel);
return (
<button
type="button"
<Button
variant="default"
size="icon-sm"
aria-label={isStarred ? "Unstar space" : "Star space"}
onClick={() => {
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
Expand All @@ -35,10 +37,10 @@ function RowStar({ channel }: { channel: Channel }) {
}}
// Parks in the row's reserved well: 8px padding + 6px gap = 14px from the
// right edge.
className="-translate-y-1/2 absolute top-1/2 right-[6px] flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground"
className="-translate-y-1/2 absolute top-1/2 right-[6px] text-muted-foreground"
>
<StarIcon size={14} weight={isStarred ? "fill" : "regular"} />
</button>
</Button>
);
}

Expand All @@ -54,14 +56,20 @@ export function ChannelBackRow({ channelId }: { channelId: string }) {
const { channels, isLoading } = useChannels();
const current = channels.find((c) => c.id === channelId);
const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME;
const glyph = channelGlyph(current?.name, {
size: 14,
space: spacesLayout,
className: "text-muted-foreground",
});

return (
<div className="relative mx-2 mt-1">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
<Button
variant="default"
left
aria-label="Back to spaces"
onClick={() => {
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
Expand All @@ -71,25 +79,26 @@ export function ChannelBackRow({ channelId }: { channelId: string }) {
});
showChannelList();
}}
// Fixed height with an unconditional star well: sized off its
// contents, a starrable channel ran 4px taller than #me and
// everything below shifted on switch. No border — it's a row in
// the sidebar like the ones under it, not a control sitting on
// top.
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left transition-colors hover:bg-fill-hover"
// Quill's own height and radius, so this reads as one of the rows
// under it rather than a control sitting on top. The star well is
// unconditional (see the reserved span below): sized off its
// contents, a starrable channel ran taller than #me and everything
// below shifted on switch.
className="w-full gap-1.5 text-left"
>
<CaretLeftIcon
size={12}
className="shrink-0 text-muted-foreground"
weight="bold"
/>
<span className="flex w-4 shrink-0 items-center justify-center">
{channelGlyph(current?.name, {
size: 14,
space: spacesLayout,
className: "text-muted-foreground",
})}
</span>
{/* Only #me still has a glyph under the layout, and its well is
drawn only when there's something in it — an empty 16px column
in front of every other space's name is worse than the name
starting where the caret leaves off. */}
{glyph && (
<span className="flex w-4 shrink-0 items-center justify-center text-foreground">
{glyph}
</span>
)}
<span className="min-w-0 flex-1 truncate font-semibold text-[13px] text-foreground">
{current ? (
current.name
Expand All @@ -102,7 +111,7 @@ export function ChannelBackRow({ channelId }: { channelId: string }) {
)}
</span>
<span aria-hidden className="size-6 shrink-0" />
</button>
</Button>
}
/>
<TooltipContent side="bottom">Back to spaces</TooltipContent>
Expand Down
Loading
Loading