Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,7 @@ export type ChannelsSurface =
| "channel_history"
| "channel_artifacts"
| "channel_inbox"
| "recent_tasks"
| "pinned"
| "dashboards_grid"
| "canvas"
Expand All @@ -814,6 +815,7 @@ export type ChannelActionType =
| "view_history"
| "view_artifacts"
| "view_inbox"
| "view_recent_tasks"
| "open_artifact"
| "file_task"
| "unfile_task"
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/browser-tabs-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export const browserTabSchema = z.object({
* blank). Pairs with `channelId`: the two together identify a channel tab.
*/
channelSection: z.string().nullable().default(null),
/**
* Standalone Channels-space route this tab fronts when it isn't a canvas,
* task, or channel — e.g. `/website/recent-tasks`. Null for every id-backed
* tab and for a blank tab. Lets a route-only view be a first-class tab
* (labelled, deduped, restored) without a channel/task/canvas reference.
*/
routeId: z.string().nullable().default(null),
/** Gap-spaced ordering key within a window. Reindexed on collision. */
position: z.number(),
/**
Expand Down
98 changes: 98 additions & 0 deletions packages/shared/src/browser-tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,31 @@ describe("openOrFocusTab", () => {
expect(inboxAgain.tabId).toBe(inbox.tabId);
});

it("dedups a route-only tab (e.g. recent tasks) within a window", () => {
const first = openOrFocusTab(snapshot(), {
windowId: "w1",
dashboardId: null,
taskId: null,
channelId: null,
routeId: "/website/recent-tasks",
makeId,
now,
});
expect(first.opened).toBe(true);
const again = openOrFocusTab(first.snapshot, {
windowId: "w1",
dashboardId: null,
taskId: null,
channelId: null,
routeId: "/website/recent-tasks",
makeId,
now,
});
expect(again.opened).toBe(false);
expect(again.tabId).toBe(first.tabId);
expect(again.snapshot.tabs).toHaveLength(1);
});

it("appends new tabs after existing ones", () => {
const a = open(snapshot(), "w1", "dash-a");
const b = open(a.snapshot, "w1", "dash-b");
Expand Down Expand Up @@ -174,6 +199,7 @@ describe("reorderTab", () => {
taskId: null,
channelId: null,
channelSection: null,
routeId: null,
position: 1,
scrollState: null,
createdAt: 0,
Expand All @@ -186,6 +212,7 @@ describe("reorderTab", () => {
taskId: null,
channelId: null,
channelSection: null,
routeId: null,
position: 2,
scrollState: null,
createdAt: 0,
Expand Down Expand Up @@ -319,6 +346,7 @@ describe("decideTabNavigation", () => {
taskId: null,
channelId: "c1",
channelSection: null,
routeId: null,
stampTabId: "tab-a",
});
});
Expand All @@ -342,6 +370,7 @@ describe("decideTabNavigation", () => {
taskId: null,
channelId: "c1",
channelSection: null,
routeId: null,
stampTabId: "tab-a",
});
});
Expand All @@ -359,6 +388,7 @@ describe("decideTabNavigation", () => {
taskId: null,
channelId: "c1",
channelSection: null,
routeId: null,
stampTabId: null,
});
});
Expand Down Expand Up @@ -386,6 +416,7 @@ describe("decideTabNavigation", () => {
taskId: null,
channelId: "c1",
channelSection: "artifacts",
routeId: null,
stampTabId: "tab-a",
});
});
Expand All @@ -403,6 +434,7 @@ describe("decideTabNavigation", () => {
taskId: null,
channelId: "c1",
channelSection: "inbox",
routeId: null,
stampTabId: null,
});
});
Expand Down Expand Up @@ -446,6 +478,59 @@ describe("decideTabNavigation", () => {
}),
).toEqual({ type: "noop" });
});

it("opens a route-only tab (e.g. recent tasks) when there is no active tab", () => {
expect(
decideTabNavigation({
...base,
routeRouteId: "/website/recent-tasks",
}),
).toEqual({
type: "open",
dashboardId: null,
taskId: null,
channelId: null,
channelSection: null,
routeId: "/website/recent-tasks",
stampTabId: null,
});
});

it("replaces the active tab when navigating to a route-only view", () => {
expect(
decideTabNavigation({
...base,
serverActiveTabId: "tab-a",
activeTab: { id: "tab-a", dashboardId: "d1", taskId: null },
routeRouteId: "/website/recent-tasks",
}),
).toEqual({
type: "replace",
tabId: "tab-a",
dashboardId: null,
taskId: null,
channelId: null,
channelSection: null,
routeId: "/website/recent-tasks",
stampTabId: "tab-a",
});
});

it("only stamps when the active tab already shows the route-only view", () => {
expect(
decideTabNavigation({
...base,
serverActiveTabId: "tab-a",
activeTab: {
id: "tab-a",
dashboardId: null,
taskId: null,
routeId: "/website/recent-tasks",
},
routeRouteId: "/website/recent-tasks",
}),
).toEqual({ type: "stamp", stampTabId: "tab-a" });
});
});

function openChannel(s: TabsSnapshot, windowId: string, channelId: string) {
Expand Down Expand Up @@ -475,6 +560,19 @@ describe("activeTabIsBlank", () => {
expect(activeTabIsBlank(t.snapshot)).toBe(false);
});

it("is false when the active tab is a route-only tab (recent tasks)", () => {
const t = openOrFocusTab(snapshot(), {
windowId: "w1",
dashboardId: null,
taskId: null,
channelId: null,
routeId: "/website/recent-tasks",
makeId,
now,
});
expect(activeTabIsBlank(t.snapshot)).toBe(false);
});

it("is false when there is no active tab", () => {
expect(activeTabIsBlank(snapshot())).toBe(false);
});
Expand Down
38 changes: 34 additions & 4 deletions packages/shared/src/browser-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ export function activeTabIsBlank(snapshot: TabsSnapshot): boolean {
if (!w?.activeTabId) return false;
const t = snapshot.tabs.find((x) => x.id === w.activeTabId);
return (
!!t && t.dashboardId == null && t.taskId == null && t.channelId == null
!!t &&
t.dashboardId == null &&
t.taskId == null &&
t.channelId == null &&
t.routeId == null
);
}

Expand Down Expand Up @@ -89,14 +93,17 @@ export type TabIdentity = {
taskId: string | null;
channelId: string | null;
channelSection: string | null;
/** A standalone route (e.g. recent tasks) when the tab has no id target. */
routeId: string | null;
};

function sameIdentity(a: TabIdentity, b: TabIdentity): boolean {
return (
a.dashboardId === b.dashboardId &&
a.taskId === b.taskId &&
a.channelId === b.channelId &&
a.channelSection === b.channelSection
a.channelSection === b.channelSection &&
a.routeId === b.routeId
);
}

Expand All @@ -111,16 +118,24 @@ export function openOrFocusTab(
windowId: string;
channelId: string | null;
channelSection?: string | null;
routeId?: string | null;
makeId: IdFactory;
now: Clock;
},
): OpenTabResult {
const { windowId, dashboardId, taskId, channelId, makeId, now } = input;
const channelSection = input.channelSection ?? null;
const routeId = input.routeId ?? null;
const existing = snapshot.tabs.find(
(t) =>
t.windowId === windowId &&
sameIdentity(t, { dashboardId, taskId, channelId, channelSection }),
sameIdentity(t, {
dashboardId,
taskId,
channelId,
channelSection,
routeId,
}),
);
if (existing) {
const ts = now();
Expand All @@ -143,6 +158,7 @@ export function openOrFocusTab(
taskId,
channelId,
channelSection,
routeId,
makeId,
now,
});
Expand All @@ -154,6 +170,7 @@ function appendTab(
windowId: string;
channelId: string | null;
channelSection?: string | null;
routeId?: string | null;
makeId: IdFactory;
now: Clock;
},
Expand All @@ -169,6 +186,7 @@ function appendTab(
taskId,
channelId,
channelSection: input.channelSection ?? null,
routeId: input.routeId ?? null,
position: lastPos + POSITION_GAP,
scrollState: null,
createdAt: ts,
Expand Down Expand Up @@ -212,6 +230,7 @@ export function setTabTarget(
tabId: string;
channelId: string | null;
channelSection?: string | null;
routeId?: string | null;
now: Clock;
},
): TabsSnapshot {
Expand All @@ -228,6 +247,7 @@ export function setTabTarget(
taskId: input.taskId,
channelId: input.channelId,
channelSection: input.channelSection ?? null,
routeId: input.routeId ?? null,
lastActiveAt: ts,
}
: t,
Expand Down Expand Up @@ -348,6 +368,7 @@ export type TabNavDecision =
taskId: string | null;
channelId: string | null;
channelSection: string | null;
routeId: string | null;
stampTabId: string | null;
}
| {
Expand All @@ -356,6 +377,7 @@ export type TabNavDecision =
taskId: string | null;
channelId: string | null;
channelSection: string | null;
routeId: string | null;
stampTabId: string | null;
}
| { type: "stamp"; stampTabId: string }
Expand All @@ -373,6 +395,7 @@ export function decideTabNavigation(input: {
taskId: string | null;
channelId?: string | null;
channelSection?: string | null;
routeId?: string | null;
} | null;
/** Canvas in the current route, if any. */
routeDashboardId: string | null;
Expand All @@ -381,6 +404,8 @@ export function decideTabNavigation(input: {
routeChannelId: string | null;
/** Channel sub-section in the current route, if any. */
routeChannelSection?: string | null;
/** Standalone Channels-space route (e.g. recent tasks) with no id target. */
routeRouteId?: string | null;
}): TabNavDecision {
const {
historyTabId,
Expand All @@ -391,6 +416,7 @@ export function decideTabNavigation(input: {
routeChannelId,
} = input;
const routeChannelSection = input.routeChannelSection ?? null;
const routeRouteId = input.routeRouteId ?? null;

// Tagged entry for a DIFFERENT tab → a tab switch or a back/forward replay.
// Focus it (this is how "back returns to the previous tab" resolves). When
Expand All @@ -409,8 +435,9 @@ export function decideTabNavigation(input: {
taskId: routeTaskId,
channelId: routeChannelId,
channelSection: routeChannelSection,
routeId: routeRouteId,
};
if (!routeDashboardId && !routeTaskId && !routeChannelId) {
if (!routeDashboardId && !routeTaskId && !routeChannelId && !routeRouteId) {
return { type: "noop" };
}

Expand All @@ -422,6 +449,7 @@ export function decideTabNavigation(input: {
taskId: activeTab.taskId,
channelId: activeTab.channelId ?? null,
channelSection: activeTab.channelSection ?? null,
routeId: activeTab.routeId ?? null,
},
routeIdentity,
)
Expand All @@ -433,6 +461,7 @@ export function decideTabNavigation(input: {
taskId: routeTaskId,
channelId: routeChannelId,
channelSection: routeChannelSection,
routeId: routeRouteId,
stampTabId: serverActiveTabId,
};
}
Expand All @@ -443,6 +472,7 @@ export function decideTabNavigation(input: {
taskId: routeTaskId,
channelId: routeChannelId,
channelSection: routeChannelSection,
routeId: routeRouteId,
stampTabId: serverActiveTabId,
};
}
Expand Down
Loading
Loading