From ae9ddd720df831e5a91500b30299937ceaae418a Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Wed, 30 Sep 2026 06:06:15 +0200 Subject: [PATCH 1/3] feat(today): add pinned, recent and spaces sections to the spaces pane The web today-rail-nav Spaces pane now uses the same collapsible Pinned, Recent and Spaces sections as the PostHog Desktop Work column. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 85589ca0-f089-4bfd-b89c-8568fd2d4ed5 --- .../src/layout/today/TodayPaneSection.tsx | 48 +++ frontend/src/layout/today/TodayShell.scss | 142 +++++++++ .../src/layout/today/TodaySpacesSidebar.tsx | 274 ++++++++++++------ frontend/src/layout/today/todaySpacesLogic.ts | 203 ++++++++++++- .../src/layout/today/todayWorkItems.test.ts | 44 +++ frontend/src/layout/today/todayWorkItems.ts | 113 ++++++++ .../project-homepage/today/Today.stories.tsx | 54 +++- 7 files changed, 772 insertions(+), 106 deletions(-) create mode 100644 frontend/src/layout/today/TodayPaneSection.tsx create mode 100644 frontend/src/layout/today/todayWorkItems.test.ts create mode 100644 frontend/src/layout/today/todayWorkItems.ts diff --git a/frontend/src/layout/today/TodayPaneSection.tsx b/frontend/src/layout/today/TodayPaneSection.tsx new file mode 100644 index 000000000000..4b76cc1031bc --- /dev/null +++ b/frontend/src/layout/today/TodayPaneSection.tsx @@ -0,0 +1,48 @@ +import { ReactNode } from 'react' + +import { IconChevronDown, IconChevronRight } from '@posthog/icons' + +import { cn } from 'lib/utils/css-classes' + +interface TodayPaneSectionProps { + label: string + open: boolean + count: number + onToggle: () => void + actions?: JSX.Element | null + divider?: boolean + dataAttr: string + children: ReactNode +} + +export function TodayPaneSection({ + label, + open, + count, + onToggle, + actions, + divider = false, + dataAttr, + children, +}: TodayPaneSectionProps): JSX.Element { + const Caret = open ? IconChevronDown : IconChevronRight + return ( +
+
+ + {open && actions &&
{actions}
} +
+ {open &&
{children}
} +
+ ) +} diff --git a/frontend/src/layout/today/TodayShell.scss b/frontend/src/layout/today/TodayShell.scss index 70e418f1604f..ddb864ce9eee 100644 --- a/frontend/src/layout/today/TodayShell.scss +++ b/frontend/src/layout/today/TodayShell.scss @@ -51,6 +51,136 @@ letter-spacing: 0.08em; } +.TodayPaneSection { + display: flex; + flex-direction: column; + padding-bottom: 8px; + + &:first-child { + margin-top: 12px; + } + + &--divider { + border-top: 1px solid var(--today-line); + } +} + +.TodayPaneSection__header { + display: flex; + gap: 4px; + align-items: center; + height: 32px; + margin-top: 4px; +} + +.TodayPaneSection__toggle { + display: flex; + flex: 1 1 auto; + gap: 4px; + align-items: center; + min-width: 0; + padding: 4px 8px; + text-align: left; + border-radius: 6px; + transition: color 0.16s; + + @media (hover: hover) and (pointer: fine) { + &:hover { + color: var(--today-ink); + } + } +} + +.TodayPaneSection__caret { + flex: 0 0 11px; + width: 11px; + height: 11px; + opacity: 0.7; +} + +.TodayPaneSection__count { + margin-left: 2px; + font-variant-numeric: tabular-nums; + color: var(--today-muted); +} + +.TodayPaneSection__actions { + display: flex; + flex: 0 0 auto; + align-items: center; +} + +.TodayPaneSection__body { + display: flex; + flex-direction: column; + gap: 1px; +} + +.TodayPane__group { + padding: 12px 8px 4px; + font-size: 11px; + font-weight: 500; + color: var(--today-muted); + + &--first { + padding-top: 4px; + } +} + +.TodayPane__dot { + width: 6px; + height: 6px; + background: var(--today-faint); + border-radius: 50%; + + &[data-status='queued'], + &[data-status='in_progress'] { + background: var(--warning); + } + + &[data-status='completed'] { + background: var(--success); + } + + &[data-status='failed'] { + background: var(--danger); + } + + &[data-kind='chat'] { + background: transparent; + border: 1px solid var(--today-faint); + } +} + +.TodayPane__add { + display: flex; + gap: 6px; + align-items: center; + padding: 6px 8px; + margin-top: 4px; + font-size: 12px; + font-weight: 500; + color: var(--today-muted); + text-align: left; + border: 1px dashed var(--today-input-border); + border-radius: 8px; + transition: + color 0.16s, + border-color 0.16s; + + svg { + width: 13px; + height: 13px; + } + + @media (hover: hover) and (pointer: fine) { + &:hover { + color: var(--today-ink); + border-color: var(--today-focus-border); + } + } +} + .TodayPane__state { display: flex; flex-direction: column; @@ -162,3 +292,15 @@ opacity: 1; } } + +.TodayPaneRow__wrap:has(.TodayPaneRow__chevron) { + .TodayPaneRow--withAction { + padding-right: 8px; + } + + .TodayPaneRow__action { + right: 30px; + background: var(--today-soft); + border-radius: 6px; + } +} diff --git a/frontend/src/layout/today/TodaySpacesSidebar.tsx b/frontend/src/layout/today/TodaySpacesSidebar.tsx index e099c0c39f9b..cb39f80f408c 100644 --- a/frontend/src/layout/today/TodaySpacesSidebar.tsx +++ b/frontend/src/layout/today/TodaySpacesSidebar.tsx @@ -1,7 +1,8 @@ import { useActions, useValues } from 'kea' import { router } from 'kea-router' +import { Fragment } from 'react' -import { IconChevronRight, IconPlus } from '@posthog/icons' +import { IconChevronRight, IconList, IconPlus, IconStar, IconStarFilled } from '@posthog/icons' import { LemonButton } from '@posthog/lemon-ui' import { Spinner } from 'lib/lemon-ui/Spinner' @@ -9,24 +10,45 @@ import { cn } from 'lib/utils/css-classes' import { urls } from 'scenes/urls' import { TodayPaneRow } from './TodayPaneRow' +import { TodayPaneSection } from './TodayPaneSection' import { spaceLabel, todaySpacesLogic } from './todaySpacesLogic' +import { TodayWorkItem, shortTimeAgo } from './todayWorkItems' export function TodaySpacesSidebar(): JSX.Element { const { - sortedSpaces, + visibleSpaces, + browsingSpaces, spacesLoading, spacesUnavailable, expandedSpaceIds, spaceTasks, loadingSpaceIds, failedSpaceIds, - recentChats, - conversationHistoryLoading, + pinnedItems, + recentItems, + recentGroups, + recentLoading, + collapsedSections, } = useValues(todaySpacesLogic) - const { toggleSpace, loadSpaces, loadSpaceTasks } = useActions(todaySpacesLogic) + const { toggleSpace, loadSpaces, loadSpaceTasks, toggleSection, setBrowsingSpaces, setSpaceStarred } = + useActions(todaySpacesLogic) const { location, searchParams } = useValues(router) const onAi = location.pathname.endsWith('/ai') + const renderItem = (item: TodayWorkItem, dataAttr: string): JSX.Element => ( + } + meta={shortTimeAgo(item.timestamp)} + to={item.kind === 'chat' ? urls.ai(item.id) : urls.aiTask(item.id)} + active={onAi && (item.kind === 'chat' ? searchParams.chat : searchParams.task) === item.id} + dataAttr={dataAttr} + /> + ) + + const hasPinned = pinnedItems.length > 0 + return (
+ )} + + )} +
) diff --git a/frontend/src/layout/today/todaySpacesLogic.ts b/frontend/src/layout/today/todaySpacesLogic.ts index 83d84215b26e..618513ffdb7c 100644 --- a/frontend/src/layout/today/todaySpacesLogic.ts +++ b/frontend/src/layout/today/todaySpacesLogic.ts @@ -1,16 +1,25 @@ import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' +import { lemonToast } from '@posthog/lemon-ui' + import { maxGlobalLogic } from 'scenes/max/maxGlobalLogic' import { teamLogic } from 'scenes/teamLogic' +import { userLogic } from 'scenes/userLogic' -import { ConversationDetail } from '~/types' +import { ConversationDetail, UserType } from '~/types' -import { taskChannelsList, tasksList } from 'products/tasks/frontend/generated/api' +import { taskChannelsList, taskChannelsStarCreate, tasksList } from 'products/tasks/frontend/generated/api' import { ChannelDTOApi, TaskListItemApi } from 'products/tasks/frontend/generated/api.schemas' +import { TodayWorkGroup, TodayWorkItem, buildRecentItems, groupByDay, sessionItem } from './todayWorkItems' + const SPACE_TASK_LIMIT = 8 -const RECENT_CHAT_LIMIT = 12 +const PINNED_SESSION_LIMIT = 20 +const RECENT_SESSION_LIMIT = 30 +const RECENT_ITEM_LIMIT = 30 + +export type TodayWorkSectionId = 'pinned' | 'recent' | 'spaces' /** The personal space first, then the team's general space, then starred spaces, then the rest by name. */ export function sortSpaces(spaces: ChannelDTOApi[]): ChannelDTOApi[] { @@ -23,24 +32,74 @@ export function spaceLabel(space: Pick): return space.system_role === 'personal' ? 'Me' : space.name } +export function starredSpaces(spaces: ChannelDTOApi[]): ChannelDTOApi[] { + return [ + ...spaces.filter((space) => space.system_role === 'personal'), + ...spaces + .filter((space) => space.system_role !== 'personal' && space.starred) + .sort((first, second) => first.name.localeCompare(second.name)), + ] +} + // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface todaySpacesLogicValues { + browsingSpaces: boolean + collapsedSections: TodayWorkSectionId[] conversationHistory: ConversationDetail[] // maxGlobalLogic conversationHistoryLoading: boolean // maxGlobalLogic currentTeamId: number | null // teamLogic expandedSpaceIds: string[] failedSpaceIds: string[] loadingSpaceIds: string[] - recentChats: ConversationDetail[] + pinnedItems: TodayWorkItem[] + pinnedTasks: TaskListItemApi[] + pinnedTasksLoading: boolean + recentGroups: TodayWorkGroup[] + recentItems: TodayWorkItem[] + recentLoading: boolean + recentTasks: TaskListItemApi[] + recentTasksLoading: boolean sortedSpaces: ChannelDTOApi[] spaceTasks: Record spaces: ChannelDTOApi[] spacesLoading: boolean spacesUnavailable: boolean + user: UserType | null // userLogic + visibleSpaces: ChannelDTOApi[] } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface todaySpacesLogicActions { + loadPinnedTasks: () => any + loadPinnedTasksFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadPinnedTasksSuccess: ( + pinnedTasks: TaskListItemApi[], + payload?: any + ) => { + pinnedTasks: TaskListItemApi[] + payload?: any + } + loadRecentTasks: () => any + loadRecentTasksFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadRecentTasksSuccess: ( + recentTasks: TaskListItemApi[], + payload?: any + ) => { + recentTasks: TaskListItemApi[] + payload?: any + } loadSpaceTasks: (spaceId: string) => { spaceId: string } @@ -62,6 +121,16 @@ export interface todaySpacesLogicActions { spaces: ChannelDTOApi[] payload?: any } + setBrowsingSpaces: (browsingSpaces: boolean) => { + browsingSpaces: boolean + } + setSpaceStarred: ( + spaceId: string, + starred: boolean + ) => { + spaceId: string + starred: boolean + } setSpaceTasks: ( spaceId: string, tasks: TaskListItemApi[] @@ -69,6 +138,9 @@ export interface todaySpacesLogicActions { spaceId: string tasks: TaskListItemApi[] } + toggleSection: (sectionId: TodayWorkSectionId) => { + sectionId: TodayWorkSectionId + } toggleSpace: (spaceId: string) => { spaceId: string } @@ -78,7 +150,15 @@ export interface todaySpacesLogicActions { export interface todaySpacesLogicMeta { __keaTypeGenInternalSelectorTypes: { sortedSpaces: (spaces: ChannelDTOApi[]) => ChannelDTOApi[] - recentChats: (conversationHistory: ConversationDetail[]) => ConversationDetail[] + visibleSpaces: (browsingSpaces: boolean, sortedSpaces: ChannelDTOApi[]) => ChannelDTOApi[] + pinnedItems: (pinnedTasks: TaskListItemApi[]) => TodayWorkItem[] + recentItems: ( + recentTasks: TaskListItemApi[], + conversationHistory: ConversationDetail[], + pinnedTasks: TaskListItemApi[] + ) => TodayWorkItem[] + recentGroups: (recentItems: TodayWorkItem[]) => TodayWorkGroup[] + recentLoading: (recentTasksLoading: boolean, conversationHistoryLoading: boolean) => boolean } } @@ -92,13 +172,23 @@ export type todaySpacesLogicType = MakeLogicType< export const todaySpacesLogic = kea([ path(['layout', 'today', 'todaySpacesLogic']), connect(() => ({ - values: [teamLogic, ['currentTeamId'], maxGlobalLogic, ['conversationHistory', 'conversationHistoryLoading']], + values: [ + teamLogic, + ['currentTeamId'], + userLogic, + ['user'], + maxGlobalLogic, + ['conversationHistory', 'conversationHistoryLoading'], + ], })), actions({ toggleSpace: (spaceId: string) => ({ spaceId }), loadSpaceTasks: (spaceId: string) => ({ spaceId }), setSpaceTasks: (spaceId: string, tasks: TaskListItemApi[]) => ({ spaceId, tasks }), loadSpaceTasksFailure: (spaceId: string) => ({ spaceId }), + toggleSection: (sectionId: TodayWorkSectionId) => ({ sectionId }), + setBrowsingSpaces: (browsingSpaces: boolean) => ({ browsingSpaces }), + setSpaceStarred: (spaceId: string, starred: boolean) => ({ spaceId, starred }), }), loaders(({ values }) => ({ spaces: [ @@ -114,6 +204,39 @@ export const todaySpacesLogic = kea([ }, }, ], + pinnedTasks: [ + [] as TaskListItemApi[], + { + loadPinnedTasks: async () => { + if (!values.currentTeamId) { + return [] + } + const response = await tasksList(String(values.currentTeamId), { + pinned: true, + basic: true, + limit: PINNED_SESSION_LIMIT, + }) + return response.results + }, + }, + ], + recentTasks: [ + [] as TaskListItemApi[], + { + loadRecentTasks: async () => { + if (!values.currentTeamId || !values.user) { + return [] + } + const response = await tasksList(String(values.currentTeamId), { + created_by: values.user.id, + ordering: '-last_activity_at', + basic: true, + limit: RECENT_SESSION_LIMIT, + }) + return response.results + }, + }, + ], })), reducers({ expandedSpaceIds: [ @@ -124,6 +247,25 @@ export const todaySpacesLogic = kea([ state.includes(spaceId) ? state.filter((id) => id !== spaceId) : [...state, spaceId], }, ], + collapsedSections: [ + [] as TodayWorkSectionId[], + { persist: true }, + { + toggleSection: (state, { sectionId }) => + state.includes(sectionId) ? state.filter((id) => id !== sectionId) : [...state, sectionId], + }, + ], + browsingSpaces: [ + false, + { persist: true }, + { + setBrowsingSpaces: (_, { browsingSpaces }) => browsingSpaces, + }, + ], + spaces: { + setSpaceStarred: (state, { spaceId, starred }) => + state.map((space) => (space.id === spaceId ? { ...space, starred } : space)), + }, spacesUnavailable: [false, { loadSpaces: () => false, loadSpacesFailure: () => true }], // Each space loads on its own, so expanding several at once never drops one space's sessions. spaceTasks: [ @@ -148,10 +290,38 @@ export const todaySpacesLogic = kea([ }), selectors({ sortedSpaces: [(s) => [s.spaces], (spaces: ChannelDTOApi[]): ChannelDTOApi[] => sortSpaces(spaces)], - recentChats: [ - (s) => [s.conversationHistory], - (conversationHistory: ConversationDetail[]): ConversationDetail[] => - conversationHistory.slice(0, RECENT_CHAT_LIMIT), + visibleSpaces: [ + (s) => [s.browsingSpaces, s.sortedSpaces], + (browsingSpaces: boolean, sortedSpaces: ChannelDTOApi[]): ChannelDTOApi[] => + browsingSpaces ? sortedSpaces : starredSpaces(sortedSpaces), + ], + pinnedItems: [ + (s) => [s.pinnedTasks], + (pinnedTasks: TaskListItemApi[]): TodayWorkItem[] => + pinnedTasks.filter((task) => !task.archived).map(sessionItem), + ], + recentItems: [ + (s) => [s.recentTasks, s.conversationHistory, s.pinnedTasks], + ( + recentTasks: TaskListItemApi[], + conversationHistory: ConversationDetail[], + pinnedTasks: TaskListItemApi[] + ): TodayWorkItem[] => + buildRecentItems( + recentTasks, + conversationHistory, + pinnedTasks.map((task) => task.id), + RECENT_ITEM_LIMIT + ), + ], + recentGroups: [ + (s) => [s.recentItems], + (recentItems: TodayWorkItem[]): TodayWorkGroup[] => groupByDay(recentItems), + ], + recentLoading: [ + (s) => [s.recentTasksLoading, s.conversationHistoryLoading], + (recentTasksLoading: boolean, conversationHistoryLoading: boolean): boolean => + recentTasksLoading || conversationHistoryLoading, ], }), listeners(({ actions, values }) => ({ @@ -183,8 +353,21 @@ export const todaySpacesLogic = kea([ } } }, + setSpaceStarred: async ({ spaceId, starred }) => { + if (!values.currentTeamId) { + return + } + try { + await taskChannelsStarCreate(String(values.currentTeamId), spaceId, { starred }) + } catch { + lemonToast.error(`Couldn’t ${starred ? 'star' : 'unstar'} this space. Try again.`) + actions.loadSpaces() + } + }, })), afterMount(({ actions }) => { actions.loadSpaces() + actions.loadPinnedTasks() + actions.loadRecentTasks() }), ]) diff --git a/frontend/src/layout/today/todayWorkItems.test.ts b/frontend/src/layout/today/todayWorkItems.test.ts new file mode 100644 index 000000000000..64ae6b085f83 --- /dev/null +++ b/frontend/src/layout/today/todayWorkItems.test.ts @@ -0,0 +1,44 @@ +import { dayjs } from 'lib/dayjs' + +import { ConversationDetail } from '~/types' + +import { TaskListItemApi } from 'products/tasks/frontend/generated/api.schemas' + +import { buildRecentItems, groupByDay } from './todayWorkItems' + +const session = (id: string, lastActivityAt: string, archived = false): TaskListItemApi => + ({ id, title: `Session ${id}`, last_activity_at: lastActivityAt, archived }) as TaskListItemApi + +const chat = (id: string, updatedAt: string): ConversationDetail => + ({ id, title: `Chat ${id}`, updated_at: updatedAt, created_at: updatedAt }) as ConversationDetail + +describe('todayWorkItems', () => { + it('merges sessions and chats by latest activity, without pinned or archived sessions', () => { + const items = buildRecentItems( + [ + session('s-pinned', '2026-03-10T12:00:00Z'), + session('s-old', '2026-03-08T09:00:00Z'), + session('s-archived', '2026-03-10T11:00:00Z', true), + ], + [chat('c-new', '2026-03-10T10:00:00Z')], + ['s-pinned'], + 10 + ) + + expect(items.map((item) => `${item.kind}:${item.id}`)).toEqual(['chat:c-new', 'session:s-old']) + }) + + it.each([ + ['2026-03-10T08:00:00Z', 'Today'], + ['2026-03-10T23:00:00Z', 'Today'], + ['2026-03-09T08:00:00Z', 'Yesterday'], + ['2026-03-06T08:00:00Z', 'Friday'], + ['2026-02-20T08:00:00Z', 'Feb 20'], + ['2025-12-20T08:00:00Z', 'Dec 20, 2025'], + ])('labels an item from %s as %s', (timestamp, label) => { + const now = dayjs('2026-03-10T12:00:00Z') + const groups = groupByDay(buildRecentItems([session('s', timestamp)], [], [], 10), now) + + expect(groups.map((group) => group.label)).toEqual([label]) + }) +}) diff --git a/frontend/src/layout/today/todayWorkItems.ts b/frontend/src/layout/today/todayWorkItems.ts new file mode 100644 index 000000000000..330efaa1877f --- /dev/null +++ b/frontend/src/layout/today/todayWorkItems.ts @@ -0,0 +1,113 @@ +import { Dayjs, dayjs } from 'lib/dayjs' + +import { ConversationDetail } from '~/types' + +import { TaskListItemApi } from 'products/tasks/frontend/generated/api.schemas' + +export type TodayWorkItemKind = 'session' | 'chat' + +export interface TodayWorkItem { + kind: TodayWorkItemKind + id: string + title: string + timestamp: string | null + status: string | null +} + +export interface TodayWorkGroup { + key: string + label: string + items: TodayWorkItem[] +} + +export function sessionItem(task: TaskListItemApi): TodayWorkItem { + return { + kind: 'session', + id: task.id, + title: task.title, + timestamp: task.last_activity_at ?? task.updated_at ?? task.created_at ?? null, + status: task.latest_run?.status ?? null, + } +} + +export function chatItem(conversation: ConversationDetail): TodayWorkItem { + return { + kind: 'chat', + id: conversation.id, + title: conversation.title ?? '', + timestamp: conversation.updated_at ?? conversation.created_at, + status: null, + } +} + +export function buildRecentItems( + sessions: TaskListItemApi[], + chats: ConversationDetail[], + pinnedSessionIds: string[], + limit: number +): TodayWorkItem[] { + const pinned = new Set(pinnedSessionIds) + return [ + ...sessions.filter((task) => !pinned.has(task.id) && !task.archived).map(sessionItem), + ...chats.map(chatItem), + ] + .sort((first, second) => timeOf(second) - timeOf(first)) + .slice(0, limit) +} + +export function groupByDay(items: TodayWorkItem[], now: Dayjs = dayjs()): TodayWorkGroup[] { + const groups: TodayWorkGroup[] = [] + for (const item of items) { + const time = item.timestamp ? earlierOf(dayjs(item.timestamp), now) : null + const key = time ? time.format('YYYY-MM-DD') : 'undated' + const last = groups[groups.length - 1] + if (last?.key === key) { + last.items.push(item) + } else { + groups.push({ key, label: time ? dayLabel(time, now) : 'Earlier', items: [item] }) + } + } + return groups +} + +export function dayLabel(time: Dayjs, now: Dayjs = dayjs()): string { + const days = now.startOf('day').diff(time.startOf('day'), 'day') + if (days <= 0) { + return 'Today' + } + if (days === 1) { + return 'Yesterday' + } + if (days < 7) { + return time.format('dddd') + } + return time.year() === now.year() ? time.format('MMM D') : time.format('MMM D, YYYY') +} + +export function shortTimeAgo(timestamp: string | null, now: Dayjs = dayjs()): string { + if (!timestamp) { + return '' + } + const minutes = Math.max(0, now.diff(dayjs(timestamp), 'minute')) + if (minutes < 1) { + return 'now' + } + if (minutes < 60) { + return `${minutes}m` + } + if (minutes < 60 * 24) { + return `${Math.floor(minutes / 60)}h` + } + if (minutes < 60 * 24 * 7) { + return `${Math.floor(minutes / (60 * 24))}d` + } + return `${Math.floor(minutes / (60 * 24 * 7))}w` +} + +function earlierOf(first: Dayjs, second: Dayjs): Dayjs { + return first.isAfter(second) ? second : first +} + +function timeOf(item: TodayWorkItem): number { + return item.timestamp ? dayjs(item.timestamp).valueOf() : 0 +} diff --git a/frontend/src/scenes/project-homepage/today/Today.stories.tsx b/frontend/src/scenes/project-homepage/today/Today.stories.tsx index 9130a2e329ed..2e3f83b60356 100644 --- a/frontend/src/scenes/project-homepage/today/Today.stories.tsx +++ b/frontend/src/scenes/project-homepage/today/Today.stories.tsx @@ -49,8 +49,48 @@ const SPACES = [ ] const CONVERSATIONS = [ - { id: 'chat-1', title: 'Why did signups spike on Monday?', status: 'idle', type: 'assistant' }, - { id: 'chat-2', title: 'Weekly retention by plan', status: 'idle', type: 'assistant' }, + { + id: 'chat-1', + title: 'Why did signups spike on Monday?', + status: 'idle', + type: 'assistant', + updated_at: '2026-09-28T16:10:00Z', + }, + { + id: 'chat-2', + title: 'Weekly retention by plan', + status: 'idle', + type: 'assistant', + updated_at: '2026-09-25T09:00:00Z', + }, +] + +const PINNED_SESSIONS = [ + { + id: 'task-pinned', + title: 'Fix the flaky checkout test', + archived: false, + last_activity_at: '2026-09-28T17:40:00Z', + latest_run: { status: 'in_progress' }, + }, +] + +const RECENT_SESSIONS = [ + ...PINNED_SESSIONS, + { + id: 'task-1', + title: 'Add a retry to the billing webhook', + archived: false, + last_activity_at: '2026-09-28T18:05:00Z', + latest_run: { status: 'completed' }, + }, + { + id: 'task-2', + title: 'Investigate the drop in trial starts', + archived: false, + last_activity_at: '2026-09-27T11:20:00Z', + latest_run: { status: 'failed' }, + }, ] const LIBRARY = [ @@ -136,7 +176,15 @@ const meta: Meta = { }, ], '/api/projects/:team_id/task_channels/': SPACES, - '/api/projects/:team_id/tasks/': EMPTY_PAGINATED_RESPONSE, + '/api/projects/:team_id/tasks/': ({ request }) => { + const params = new URL(request.url).searchParams + const results = params.get('pinned') + ? PINNED_SESSIONS + : params.get('created_by') + ? RECENT_SESSIONS + : [] + return [200, { results, count: results.length, next: null, previous: null }] + }, '/api/environments/:team_id/conversations/': { results: CONVERSATIONS, next: null }, '/api/environments/:team_id/file_system/': ({ request }) => { const type = new URL(request.url).searchParams.get('type') From 3a827b4421f5de29bab6a64d578f68faeedaf570 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Wed, 30 Sep 2026 06:22:14 +0200 Subject: [PATCH 2/3] fix(today): sync kea typegen and show recent load errors Reorder the inline typegen block so the CI typegen check passes. Show an error state with a retry button when recent sessions fail to load. Use local-time fixtures in the day-label tests so they pass in any TZ. Generated-By: PostHog Desktop Task-Id: 8ee12b4a-4ffe-4706-9769-51722a1653cb --- .../src/layout/today/TodaySpacesSidebar.tsx | 24 +++++++++++++++++-- frontend/src/layout/today/todaySpacesLogic.ts | 8 ++++--- .../src/layout/today/todayWorkItems.test.ts | 22 ++++++++--------- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/frontend/src/layout/today/TodaySpacesSidebar.tsx b/frontend/src/layout/today/TodaySpacesSidebar.tsx index cb39f80f408c..139b850e8084 100644 --- a/frontend/src/layout/today/TodaySpacesSidebar.tsx +++ b/frontend/src/layout/today/TodaySpacesSidebar.tsx @@ -28,10 +28,18 @@ export function TodaySpacesSidebar(): JSX.Element { recentItems, recentGroups, recentLoading, + recentTasksUnavailable, collapsedSections, } = useValues(todaySpacesLogic) - const { toggleSpace, loadSpaces, loadSpaceTasks, toggleSection, setBrowsingSpaces, setSpaceStarred } = - useActions(todaySpacesLogic) + const { + toggleSpace, + loadSpaces, + loadSpaceTasks, + loadRecentTasks, + toggleSection, + setBrowsingSpaces, + setSpaceStarred, + } = useActions(todaySpacesLogic) const { location, searchParams } = useValues(router) const onAi = location.pathname.endsWith('/ai') @@ -84,6 +92,18 @@ export function TodaySpacesSidebar(): JSX.Element {
+ ) : recentTasksUnavailable && !recentItems.length ? ( +
+ Recent sessions didn’t load. + loadRecentTasks()} + data-attr="today-recent-retry" + > + Try again + +
) : !recentItems.length ? (
Sessions and chats you open show up here.
) : ( diff --git a/frontend/src/layout/today/todaySpacesLogic.ts b/frontend/src/layout/today/todaySpacesLogic.ts index 618513ffdb7c..3fb9b4d59eec 100644 --- a/frontend/src/layout/today/todaySpacesLogic.ts +++ b/frontend/src/layout/today/todaySpacesLogic.ts @@ -43,11 +43,12 @@ export function starredSpaces(spaces: ChannelDTOApi[]): ChannelDTOApi[] { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface todaySpacesLogicValues { - browsingSpaces: boolean - collapsedSections: TodayWorkSectionId[] conversationHistory: ConversationDetail[] // maxGlobalLogic conversationHistoryLoading: boolean // maxGlobalLogic currentTeamId: number | null // teamLogic + user: UserType | null // userLogic + browsingSpaces: boolean + collapsedSections: TodayWorkSectionId[] expandedSpaceIds: string[] failedSpaceIds: string[] loadingSpaceIds: string[] @@ -59,12 +60,12 @@ export interface todaySpacesLogicValues { recentLoading: boolean recentTasks: TaskListItemApi[] recentTasksLoading: boolean + recentTasksUnavailable: boolean sortedSpaces: ChannelDTOApi[] spaceTasks: Record spaces: ChannelDTOApi[] spacesLoading: boolean spacesUnavailable: boolean - user: UserType | null // userLogic visibleSpaces: ChannelDTOApi[] } @@ -267,6 +268,7 @@ export const todaySpacesLogic = kea([ state.map((space) => (space.id === spaceId ? { ...space, starred } : space)), }, spacesUnavailable: [false, { loadSpaces: () => false, loadSpacesFailure: () => true }], + recentTasksUnavailable: [false, { loadRecentTasks: () => false, loadRecentTasksFailure: () => true }], // Each space loads on its own, so expanding several at once never drops one space's sessions. spaceTasks: [ {} as Record, diff --git a/frontend/src/layout/today/todayWorkItems.test.ts b/frontend/src/layout/today/todayWorkItems.test.ts index 64ae6b085f83..7b3ca3db569b 100644 --- a/frontend/src/layout/today/todayWorkItems.test.ts +++ b/frontend/src/layout/today/todayWorkItems.test.ts @@ -16,11 +16,11 @@ describe('todayWorkItems', () => { it('merges sessions and chats by latest activity, without pinned or archived sessions', () => { const items = buildRecentItems( [ - session('s-pinned', '2026-03-10T12:00:00Z'), - session('s-old', '2026-03-08T09:00:00Z'), - session('s-archived', '2026-03-10T11:00:00Z', true), + session('s-pinned', '2026-03-10T12:00:00'), + session('s-old', '2026-03-08T09:00:00'), + session('s-archived', '2026-03-10T11:00:00', true), ], - [chat('c-new', '2026-03-10T10:00:00Z')], + [chat('c-new', '2026-03-10T10:00:00')], ['s-pinned'], 10 ) @@ -29,14 +29,14 @@ describe('todayWorkItems', () => { }) it.each([ - ['2026-03-10T08:00:00Z', 'Today'], - ['2026-03-10T23:00:00Z', 'Today'], - ['2026-03-09T08:00:00Z', 'Yesterday'], - ['2026-03-06T08:00:00Z', 'Friday'], - ['2026-02-20T08:00:00Z', 'Feb 20'], - ['2025-12-20T08:00:00Z', 'Dec 20, 2025'], + ['2026-03-10T08:00:00', 'Today'], + ['2026-03-10T23:00:00', 'Today'], + ['2026-03-09T08:00:00', 'Yesterday'], + ['2026-03-06T08:00:00', 'Friday'], + ['2026-02-20T08:00:00', 'Feb 20'], + ['2025-12-20T08:00:00', 'Dec 20, 2025'], ])('labels an item from %s as %s', (timestamp, label) => { - const now = dayjs('2026-03-10T12:00:00Z') + const now = dayjs('2026-03-10T12:00:00') const groups = groupByDay(buildRecentItems([session('s', timestamp)], [], [], 10), now) expect(groups.map((group) => group.label)).toEqual([label]) From 706bb0b6eff381ddced8c8b5290ade2bda351757 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Wed, 30 Sep 2026 06:25:00 +0200 Subject: [PATCH 3/3] chore(today): move the space star to a later pr The star action and the "Add the spaces you work in" button return with the space menu. This keeps the PR under the stamphog size gate. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 85589ca0-f089-4bfd-b89c-8568fd2d4ed5 --- frontend/src/layout/today/TodayShell.scss | 41 ----- .../src/layout/today/TodaySpacesSidebar.tsx | 140 +++++++----------- frontend/src/layout/today/todaySpacesLogic.ts | 27 +--- 3 files changed, 55 insertions(+), 153 deletions(-) diff --git a/frontend/src/layout/today/TodayShell.scss b/frontend/src/layout/today/TodayShell.scss index ddb864ce9eee..d15f088accd4 100644 --- a/frontend/src/layout/today/TodayShell.scss +++ b/frontend/src/layout/today/TodayShell.scss @@ -152,35 +152,6 @@ } } -.TodayPane__add { - display: flex; - gap: 6px; - align-items: center; - padding: 6px 8px; - margin-top: 4px; - font-size: 12px; - font-weight: 500; - color: var(--today-muted); - text-align: left; - border: 1px dashed var(--today-input-border); - border-radius: 8px; - transition: - color 0.16s, - border-color 0.16s; - - svg { - width: 13px; - height: 13px; - } - - @media (hover: hover) and (pointer: fine) { - &:hover { - color: var(--today-ink); - border-color: var(--today-focus-border); - } - } -} - .TodayPane__state { display: flex; flex-direction: column; @@ -292,15 +263,3 @@ opacity: 1; } } - -.TodayPaneRow__wrap:has(.TodayPaneRow__chevron) { - .TodayPaneRow--withAction { - padding-right: 8px; - } - - .TodayPaneRow__action { - right: 30px; - background: var(--today-soft); - border-radius: 6px; - } -} diff --git a/frontend/src/layout/today/TodaySpacesSidebar.tsx b/frontend/src/layout/today/TodaySpacesSidebar.tsx index 139b850e8084..3ccc236344a6 100644 --- a/frontend/src/layout/today/TodaySpacesSidebar.tsx +++ b/frontend/src/layout/today/TodaySpacesSidebar.tsx @@ -2,7 +2,7 @@ import { useActions, useValues } from 'kea' import { router } from 'kea-router' import { Fragment } from 'react' -import { IconChevronRight, IconList, IconPlus, IconStar, IconStarFilled } from '@posthog/icons' +import { IconChevronRight, IconList, IconPlus } from '@posthog/icons' import { LemonButton } from '@posthog/lemon-ui' import { Spinner } from 'lib/lemon-ui/Spinner' @@ -31,15 +31,8 @@ export function TodaySpacesSidebar(): JSX.Element { recentTasksUnavailable, collapsedSections, } = useValues(todaySpacesLogic) - const { - toggleSpace, - loadSpaces, - loadSpaceTasks, - loadRecentTasks, - toggleSection, - setBrowsingSpaces, - setSpaceStarred, - } = useActions(todaySpacesLogic) + const { toggleSpace, loadSpaces, loadSpaceTasks, loadRecentTasks, toggleSection, setBrowsingSpaces } = + useActions(todaySpacesLogic) const { location, searchParams } = useValues(router) const onAi = location.pathname.endsWith('/ai') @@ -156,85 +149,60 @@ export function TodaySpacesSidebar(): JSX.Element { Spaces group the sessions you and your agents work on. Create one from PostHog Desktop. ) : ( - <> - {visibleSpaces.map((space) => { - const expanded = expandedSpaceIds.includes(space.id) - const tasks = spaceTasks[space.id] - const personal = space.system_role === 'personal' - return ( -
- #} - dataAttr="today-space-row" - onClick={() => toggleSpace(space.id)} - trailing={ - - } - action={ - personal ? null : ( + visibleSpaces.map((space) => { + const expanded = expandedSpaceIds.includes(space.id) + const tasks = spaceTasks[space.id] + return ( +
+ #} + dataAttr="today-space-row" + onClick={() => toggleSpace(space.id)} + trailing={ + + } + /> + {expanded && + (tasks === undefined ? ( + loadingSpaceIds.includes(space.id) ? ( +
+ +
+ ) : failedSpaceIds.includes(space.id) ? ( +
+ Couldn’t load this space’s sessions. : } - tooltip={space.starred ? 'Unstar space' : 'Star space'} - onClick={() => setSpaceStarred(space.id, !space.starred)} - data-attr="today-space-star" - /> - ) - } - /> - {expanded && - (tasks === undefined ? ( - loadingSpaceIds.includes(space.id) ? ( -
- -
- ) : failedSpaceIds.includes(space.id) ? ( -
- Couldn’t load this space’s sessions. - loadSpaceTasks(space.id)} - data-attr="today-space-tasks-retry" - > - Try again - -
- ) : null - ) : tasks.length === 0 ? ( -
- No sessions yet. + type="secondary" + onClick={() => loadSpaceTasks(space.id)} + data-attr="today-space-tasks-retry" + > + Try again +
- ) : ( - tasks.map((task) => ( - - )) - ))} -
- ) - })} - {!browsingSpaces && visibleSpaces.length <= 1 && ( - - )} - + ) : null + ) : tasks.length === 0 ? ( +
+ No sessions yet. +
+ ) : ( + tasks.map((task) => ( + + )) + ))} +
+ ) + }) )}
diff --git a/frontend/src/layout/today/todaySpacesLogic.ts b/frontend/src/layout/today/todaySpacesLogic.ts index 3fb9b4d59eec..96e29e3a3208 100644 --- a/frontend/src/layout/today/todaySpacesLogic.ts +++ b/frontend/src/layout/today/todaySpacesLogic.ts @@ -1,15 +1,13 @@ import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' -import { lemonToast } from '@posthog/lemon-ui' - import { maxGlobalLogic } from 'scenes/max/maxGlobalLogic' import { teamLogic } from 'scenes/teamLogic' import { userLogic } from 'scenes/userLogic' import { ConversationDetail, UserType } from '~/types' -import { taskChannelsList, taskChannelsStarCreate, tasksList } from 'products/tasks/frontend/generated/api' +import { taskChannelsList, tasksList } from 'products/tasks/frontend/generated/api' import { ChannelDTOApi, TaskListItemApi } from 'products/tasks/frontend/generated/api.schemas' import { TodayWorkGroup, TodayWorkItem, buildRecentItems, groupByDay, sessionItem } from './todayWorkItems' @@ -125,13 +123,6 @@ export interface todaySpacesLogicActions { setBrowsingSpaces: (browsingSpaces: boolean) => { browsingSpaces: boolean } - setSpaceStarred: ( - spaceId: string, - starred: boolean - ) => { - spaceId: string - starred: boolean - } setSpaceTasks: ( spaceId: string, tasks: TaskListItemApi[] @@ -189,7 +180,6 @@ export const todaySpacesLogic = kea([ loadSpaceTasksFailure: (spaceId: string) => ({ spaceId }), toggleSection: (sectionId: TodayWorkSectionId) => ({ sectionId }), setBrowsingSpaces: (browsingSpaces: boolean) => ({ browsingSpaces }), - setSpaceStarred: (spaceId: string, starred: boolean) => ({ spaceId, starred }), }), loaders(({ values }) => ({ spaces: [ @@ -263,10 +253,6 @@ export const todaySpacesLogic = kea([ setBrowsingSpaces: (_, { browsingSpaces }) => browsingSpaces, }, ], - spaces: { - setSpaceStarred: (state, { spaceId, starred }) => - state.map((space) => (space.id === spaceId ? { ...space, starred } : space)), - }, spacesUnavailable: [false, { loadSpaces: () => false, loadSpacesFailure: () => true }], recentTasksUnavailable: [false, { loadRecentTasks: () => false, loadRecentTasksFailure: () => true }], // Each space loads on its own, so expanding several at once never drops one space's sessions. @@ -355,17 +341,6 @@ export const todaySpacesLogic = kea([ } } }, - setSpaceStarred: async ({ spaceId, starred }) => { - if (!values.currentTeamId) { - return - } - try { - await taskChannelsStarCreate(String(values.currentTeamId), spaceId, { starred }) - } catch { - lemonToast.error(`Couldn’t ${starred ? 'star' : 'unstar'} this space. Try again.`) - actions.loadSpaces() - } - }, })), afterMount(({ actions }) => { actions.loadSpaces()