Skip to content

Commit aa6b6ca

Browse files
adamleithpclaude
andauthored
refactor(spaces): rework sidebar row status, pins and row menus (#4008)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ac6df3c commit aa6b6ca

35 files changed

Lines changed: 2133 additions & 531 deletions

apps/code/src/main/window.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,14 @@ export function createWindow(): void {
208208
const platformWindowConfig =
209209
process.platform === "darwin"
210210
? {
211-
titleBarStyle: "hiddenInset" as const,
211+
// "hidden", not "hiddenInset": hiddenInset keeps macOS's own inset and
212+
// ignores trafficLightPosition's y, which parked the dots near the
213+
// bottom of the bar. "hidden" honours the position we ask for.
214+
titleBarStyle: "hidden" as const,
212215
// Centre the traffic lights vertically with the title bar's back/forward
213216
// buttons (40px bar, 24px buttons → centre at y=20; 12px dots → top at 14).
214217
// x mirrors y so the inset from the top and the left match.
215-
trafficLightPosition: { x: 14, y: 14 },
218+
trafficLightPosition: { x: 14, y: 12 },
216219
// Exposes the titlebar-area-* CSS env vars so the renderer can
217220
// clear the traffic lights exactly; their size varies by macOS
218221
// version (bigger on Tahoe), so it must not hardcode a width.

packages/core/src/canvas/channelItems.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ function model(over: Partial<ChannelItemModel> = {}): ChannelItemModel {
157157
authorName: null,
158158
authorUuid: ME.uuid,
159159
templateId: null,
160+
task: null,
160161
...over,
161162
};
162163
}

packages/core/src/canvas/channelItems.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ export interface ChannelItemModel {
1717
authorName: string | null;
1818
authorUuid: string | null;
1919
templateId: string | null;
20+
/**
21+
* The source task record for `kind: "task"` rows, `null` for canvases. Rows
22+
* need the whole task, not a projection of it: the status dot is derived from
23+
* session/workspace/viewed state that only the renderer holds, and the hooks
24+
* that supply it (`useChannelTaskData`, `useTaskPrStatus`) take a `Task`.
25+
* Carrying the reference here keeps that a lookup the list already did rather
26+
* than a second pass over every row.
27+
*/
28+
task: Task | null;
2029
}
2130

2231
export interface ChannelItemOwner {
@@ -60,6 +69,7 @@ export function buildChannelItems({
6069
authorName: d.createdBy ?? null,
6170
authorUuid: d.createdByUuid ?? null,
6271
templateId: d.templateId,
72+
task: null,
6373
}));
6474

6575
const taskItems: ChannelItemModel[] = feedTasks.flatMap((task) =>
@@ -78,6 +88,7 @@ export function buildChannelItems({
7888
authorName: null,
7989
authorUuid: task.created_by?.uuid ?? null,
8090
templateId: null,
91+
task,
8192
},
8293
],
8394
);

packages/shared/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,9 +359,11 @@ export type {
359359
} from "./task-creation-domain";
360360
export {
361361
formatClockTime,
362+
formatDaySeparatorLabel,
362363
formatRelativeTimeLong,
363364
formatRelativeTimeShort,
364365
getLocalDayDiff,
366+
getLocalDayKey,
365367
getRelativeDateGroup,
366368
} from "./time";
367369
export {

packages/shared/src/time.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import {
33
formatClockTime,
4+
formatDaySeparatorLabel,
45
formatRelativeTimeLong,
56
formatRelativeTimeShort,
67
getLocalDayDiff,
8+
getLocalDayKey,
79
getRelativeDateGroup,
810
} from "./time";
911

@@ -124,3 +126,37 @@ describe("getRelativeDateGroup", () => {
124126
expect(getRelativeDateGroup(NOW - 40 * DAY)).toBe("Earlier");
125127
});
126128
});
129+
130+
describe("getLocalDayKey", () => {
131+
it("gives two times on the same local day one key", () => {
132+
expect(getLocalDayKey(new Date(2026, 5, 15, 0, 1))).toBe(
133+
getLocalDayKey(new Date(2026, 5, 15, 23, 59)),
134+
);
135+
});
136+
137+
it("separates adjacent days", () => {
138+
expect(getLocalDayKey(new Date(2026, 5, 15))).not.toBe(
139+
getLocalDayKey(new Date(2026, 5, 16)),
140+
);
141+
});
142+
});
143+
144+
describe("formatDaySeparatorLabel", () => {
145+
const now = new Date(2026, 5, 15, 12);
146+
147+
it.each([
148+
["today", new Date(2026, 5, 15, 9), "Today"],
149+
["yesterday", new Date(2026, 5, 14, 9), "Yesterday"],
150+
// Within the week the weekday alone is unambiguous.
151+
["earlier this week", new Date(2026, 5, 11), "Thursday 11th"],
152+
// Past a week it needs the month, and past a year the year too.
153+
["last month", new Date(2026, 4, 20), "Wednesday, May 20th"],
154+
["last year", new Date(2025, 11, 3), "Wednesday, December 3rd, 2025"],
155+
])("labels %s", (_case, date: Date, expected) => {
156+
expect(formatDaySeparatorLabel(date, now)).toBe(expected);
157+
});
158+
159+
it("labels a future timestamp as today rather than counting backwards", () => {
160+
expect(formatDaySeparatorLabel(new Date(2026, 5, 16), now)).toBe("Today");
161+
});
162+
});

packages/shared/src/time.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,48 @@ export function getLocalDayDiff(
7575
return Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000);
7676
}
7777

78+
/**
79+
* Local calendar-day identity, for deciding where a day separator goes. Two
80+
* timestamps on the same day share a key regardless of time, and the key is
81+
* built from local getters (not the UTC ISO) so the split lands on the viewer's
82+
* midnight.
83+
*/
84+
export function getLocalDayKey(timestamp: number | string | Date): string {
85+
const date = new Date(timestamp);
86+
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
87+
}
88+
89+
function ordinal(n: number): string {
90+
const suffix = ["th", "st", "nd", "rd"];
91+
const rem = n % 100;
92+
return `${n}${suffix[(rem - 20) % 10] ?? suffix[rem] ?? suffix[0]}`;
93+
}
94+
95+
/**
96+
* A day separator's label: "Today" / "Yesterday" for the recent days, then a
97+
* weekday + ordinal ("Monday 5th") within the week, adding the month (and the
98+
* year when it differs) further back so older separators stay unambiguous.
99+
*
100+
* Shared by the space feed and the space sidebar's recents, so the same day is
101+
* never named two different ways in one window.
102+
*/
103+
export function formatDaySeparatorLabel(
104+
timestamp: number | string | Date,
105+
now: Date = new Date(),
106+
): string {
107+
const date = new Date(timestamp);
108+
const days = getLocalDayDiff(date, now);
109+
if (days <= 0) return "Today";
110+
if (days === 1) return "Yesterday";
111+
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
112+
const day = ordinal(date.getDate());
113+
if (days < 7) return `${weekday} ${day}`;
114+
const month = date.toLocaleDateString(undefined, { month: "long" });
115+
const year =
116+
date.getFullYear() === now.getFullYear() ? "" : `, ${date.getFullYear()}`;
117+
return `${weekday}, ${month} ${day}${year}`;
118+
}
119+
78120
export function getRelativeDateGroup(
79121
timestamp: number | string,
80122
): string | null {

packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,11 @@ export function BrowserTabStrip() {
166166
// decides where a task/blank tab navigates.
167167
const inChannels = pathname.startsWith("/website");
168168
// Top-level app pages (Inbox, Agents, Skills, MCP servers, Command Center)
169-
// are tab targets too. useAppView normalizes both the /code routes and
170-
// their /website mirrors to the same view.type, so a tab survives either space.
169+
// are tab targets too. useAppView normalizes both the /code routes and their
170+
// /website mirrors to the same view.type, so a tab survives either space. A
171+
// top-level route that ISN'T here falls through to `task-input`, and the
172+
// strip then reconciles the location against the wrong tab and navigates
173+
// straight back off the page.
171174
const view = useAppView();
172175
const routeAppView: AppView | null = isAppView(view.type) ? view.type : null;
173176

packages/ui/src/features/canvas/components/ActivityView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export function ActivityRow({
181181
)}
182182
{item.isUnread && (
183183
<span
184-
className="-top-0.5 -right-0.5 absolute h-2 w-2 rounded-full bg-(--red-9)"
184+
className="-top-0.5 -right-0.5 absolute h-2 w-2 rounded-full bg-primary"
185185
title="New activity"
186186
/>
187187
)}

packages/ui/src/features/canvas/components/ChannelBackRow.tsx

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CaretLeftIcon, StarIcon } from "@phosphor-icons/react";
22
import {
3+
Button,
34
Skeleton,
45
Tooltip,
56
TooltipContent,
@@ -22,8 +23,9 @@ import { track } from "@posthog/ui/shell/analytics";
2223
function RowStar({ channel }: { channel: Channel }) {
2324
const { isStarred, toggleStar } = useChannelStarToggle(channel);
2425
return (
25-
<button
26-
type="button"
26+
<Button
27+
variant="default"
28+
size="icon-sm"
2729
aria-label={isStarred ? "Unstar space" : "Star space"}
2830
onClick={() => {
2931
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
@@ -35,10 +37,10 @@ function RowStar({ channel }: { channel: Channel }) {
3537
}}
3638
// Parks in the row's reserved well: 8px padding + 6px gap = 14px from the
3739
// right edge.
38-
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"
40+
className="-translate-y-1/2 absolute top-1/2 right-[6px] text-muted-foreground"
3941
>
4042
<StarIcon size={14} weight={isStarred ? "fill" : "regular"} />
41-
</button>
43+
</Button>
4244
);
4345
}
4446

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

5865
return (
5966
<div className="relative mx-2 mt-1">
6067
<Tooltip>
6168
<TooltipTrigger
6269
render={
63-
<button
64-
type="button"
70+
<Button
71+
variant="default"
72+
left
6573
aria-label="Back to spaces"
6674
onClick={() => {
6775
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
@@ -71,25 +79,26 @@ export function ChannelBackRow({ channelId }: { channelId: string }) {
7179
});
7280
showChannelList();
7381
}}
74-
// Fixed height with an unconditional star well: sized off its
75-
// contents, a starrable channel ran 4px taller than #me and
76-
// everything below shifted on switch. No border — it's a row in
77-
// the sidebar like the ones under it, not a control sitting on
78-
// top.
79-
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left transition-colors hover:bg-fill-hover"
82+
// Quill's own height and radius, so this reads as one of the rows
83+
// under it rather than a control sitting on top. The star well is
84+
// unconditional (see the reserved span below): sized off its
85+
// contents, a starrable channel ran taller than #me and everything
86+
// below shifted on switch.
87+
className="w-full gap-1.5 text-left"
8088
>
8189
<CaretLeftIcon
8290
size={12}
8391
className="shrink-0 text-muted-foreground"
84-
weight="bold"
8592
/>
86-
<span className="flex w-4 shrink-0 items-center justify-center">
87-
{channelGlyph(current?.name, {
88-
size: 14,
89-
space: spacesLayout,
90-
className: "text-muted-foreground",
91-
})}
92-
</span>
93+
{/* Only #me still has a glyph under the layout, and its well is
94+
drawn only when there's something in it — an empty 16px column
95+
in front of every other space's name is worse than the name
96+
starting where the caret leaves off. */}
97+
{glyph && (
98+
<span className="flex w-4 shrink-0 items-center justify-center text-foreground">
99+
{glyph}
100+
</span>
101+
)}
93102
<span className="min-w-0 flex-1 truncate font-semibold text-[13px] text-foreground">
94103
{current ? (
95104
current.name
@@ -102,7 +111,7 @@ export function ChannelBackRow({ channelId }: { channelId: string }) {
102111
)}
103112
</span>
104113
<span aria-hidden className="size-6 shrink-0" />
105-
</button>
114+
</Button>
106115
}
107116
/>
108117
<TooltipContent side="bottom">Back to spaces</TooltipContent>

0 commit comments

Comments
 (0)