Skip to content

feat(ui): show pinned tasks by project - #4613

Merged
Astro-Han merged 4 commits into
apache:mainfrom
Phoenix500526:fix/project-view-pinned-section
Sep 3, 2026
Merged

feat(ui): show pinned tasks by project#4613
Astro-Han merged 4 commits into
apache:mainfrom
Phoenix500526:fix/project-view-pinned-section

Conversation

@Phoenix500526

@Phoenix500526 Phoenix500526 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Pinning a task in By project previously changed the menu action to
Unpin, but left the task in place with no visible indication that Pin had
taken effect. By time already presents pinned tasks in a dedicated global
section, so the same state looked different across the two grouping modes.

Add the global Pinned section above project groups in By project.
Pinned tasks appear there once instead of being duplicated in their project,
while their underlying project membership and project summary counts remain
unchanged. Tasks from archived projects stay inside the archived section.

This implements the behavior discussed in
Discussion #4453.

Fixes #4612

Verification

  • npm run build -w @maka/ui
  • node --test packages/ui/dist/__tests__/session-history-row-actions.test.js
    (10 passed)
  • npm run typecheck:stories -w @maka/desktop
  • npm exec -- biome check packages/ui/src/session-history-list.tsx packages/ui/src/__tests__/session-history-row-actions.test.tsx packages/ui/stories/session-list-panel.stories.tsx
  • Verified the By project layout in a live Desktop render using the
    production sidebar components.

Before Change

image image

After Change

image image

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: OpenAI Codex implemented the UI behavior, regression test,
Storybook fixture, and verification with the contributor's review.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Pinning appeared to do nothing in the project view because only the time
view exposed the global pinned section. Keep the presentation consistent
while preserving each task's project membership.

CLOSES apache#4612

Generated-by: Codex
Signed-off-by: Jiawei Zhao <Phoenix500526@163.com>
@github-actions github-actions Bot added the effort/S Under 100 readable lines label Sep 3, 2026
@Astro-Han

Copy link
Copy Markdown
Contributor

The problem is real and the direction is right: a pinned task should stay at the top in both grouping modes. That is what option 1 in #4453 cannot give you, since a task that stays inside its project still sits wherever that project falls. So the flattening is the price this design pays, not a flaw in it. Sorry that thread sat unanswered until you had to build something to get a reply.

The presentation is where I disagree. In the project view this puts a SideNavSection (Pinned) and a row of SideNavItems (the project rows) side by side at the same level, but in Astryx those two are not siblings. SideNavSection is documented as "Section grouping for navigation items", renders role="group" with aria-labelledby, and the example in its own types is a Section containing Items.

Project groups, dark

Pinned is a small grey section heading, maka-agent and 产品文档 are navigation items with a folder icon. Two visual grammars in one column holding the same kind of content. Every project row has a disclosure, Pinned has none, so collapsing every project still cannot put it away, and it has no row menu either. Yet the task under it is indented exactly like a task inside a project, so the indentation promises a container while nothing about the row behaves like one.

The shape your screenshots do not reach is where it shows most:

Only task pinned, dark

独苗项目's only visible task is pinned. hasSessions reads the filtered list, so the row loses its disclosure and its subtree; hoverSummary reads summarySessions and still announces 1 task; ProjectItemActions flips from before-disclosure to trailing. This is the same count you removed from the project header in #4448 last week, for the reason that one fact should not be readable in two places. The visible half is gone; the accessible half is the one that now disagrees with the subtree.

What I would rather see

Two sibling SideNavSections: one titled Pinned holding the pinned rows, one titled Projects holding the project SideNavItems. Section-contains-Item comes back, project rows keep their folder icon, disclosure and row menu untouched, and the pinned section stops needing a disclosure it cannot have. Both modes then share one structure: By time is Pinned / Recent, By project is Pinned / Projects.

I built it rather than describing it, so you can disagree with something concrete. Branch spike/4613-two-sections (eae5747), on top of your ef0a5a3.

Production is +33/−20 across two files, a net removal: the fix for the count is to delete the summarySessions prop rather than add a parameter around it, and with it gone hasSessions, the menu position and the accessible count read one list again. The only addition is a projects copy entry beside pinned and recent. Archived projects stay inside the Projects section as its last item, because what it holds is projects; its own section would be a one-child section whose heading repeats its only row.

Tests and stories are +141/−5 including the fixture for the shape above, and both new assertions fail against this PR as it stands: the description comes out as 1 task where the test wants 0, and the top-level [role="group"] list is expected to be exactly ['Pinned', 'Projects'] where the current implementation has one group. packages/ui suites 33/33, @maka/ui typecheck, typecheck:stories, format and lint clean.

By time is untouched: magick compare -metric AE reports 0 differing pixels in both light and dark. Light versions of both comparisons:

Project groups, light

Only task pinned, light

The red check is not yours

test / Desktop e2e failed on e2e/transcript-scroll.spec.ts:628. The same job failed on main at 4019fc50 on a different spec, e2e/streaming-remount.spec.ts:142. This diff touches only packages/ui/src/session-history-list.tsx and cannot reach the transcript scroller. A rerun should clear it.

Three small simplifications are inline, none behavioural. Take the branch, take part of it, or push back on it.

Evidence boundary: rendered both implementations through the desktop Storybook from one worktree, same fixtures and viewport, light and dark; ran the packages/ui suites, both typechecks, format and lint locally; verified the Astryx contract from the shipped SideNavSection types. I did not run the Playwright suite or exercise pin and unpin in the packaged app.

AI-assisted review: drafted with Maka. The proposal branch, the renders and the pixel comparison are mine.

简体中文

问题是真的,方向也对:置顶的任务在两种分组模式下都该稳定在最上面。这正是 #4453 里方案一给不了的,任务留在项目里,它的绝对位置就取决于那个项目排在第几。所以层级被打平是这个设计要付的代价,不是它的缺陷。那个帖子一直没人回,让你只能先做出来才拿得到答复,抱歉。

我不同意的是呈现方式。项目视图里,这次改动把一个 SideNavSection(置顶)和一排 SideNavItem(项目行)平铺在同一层,而在 Astryx 里这两个不是兄弟。SideNavSection 的文档写的是「Section grouping for navigation items」,渲染成 role="group"aria-labelledby,它自己类型定义里给的例子就是 Section 装 Item。

「置顶」是小号灰字的分区标题,maka-agent产品文档 是带文件夹图标的导航条目,同一竖列上两种视觉语法,装的却是同一类内容。项目行都有折叠箭头,置顶段没有,所以把所有项目都收起来也收不掉它,它也没有行内菜单。但它下面的任务缩进和项目里的任务一模一样,缩进承诺了一个容器,而这一行没有一处行为像容器。

你截图没截到的形状最能说明问题:独苗项目 唯一可见的任务被置顶后,hasSessions 读过滤后的列表,这行就失去了折叠箭头和子树;hoverSummarysummarySessions,仍然报着 1 task;ProjectItemActions 的位置也从 before-disclosure 跳到 trailing。这正是你上周在 #4448 里从项目标题上删掉的那个计数,理由是同一个事实不该有两个地方能读到。可见的那份删了,现在跟子树对不上的是无障碍的那份。

我更希望的样子

两个平级的 SideNavSection:一个标题「置顶」装置顶的行,一个标题「项目」装项目的 SideNavItem。Section 装 Item 的关系回来了,项目行的文件夹图标、折叠箭头、行内菜单一行不用动,置顶段也不再需要一个它给不了的折叠箭头。两种模式从此结构相同:按时间是「置顶 / 最近」,按项目是「置顶 / 项目」。

我把它做出来了而不是只描述,这样你能对着具体的东西反驳。分支 spike/4613-two-sectionseae5747),基于你的 ef0a5a3

生产代码 +33/−20,两个文件,是净删:计数那个问题的修法是把 summarySessions 删掉而不是再加一个参数去圆场,删掉之后 hasSessions、菜单位置、无障碍计数又只读同一份列表。唯一新增的是一条 projects 文案,放在 pinnedrecent 旁边。已归档项目留在「项目」section 里当最后一个条目,因为它装的就是项目;单独给它一个 section 会得到一个只有一个子项、标题还跟那个子项重名的分区。

测试和 story 是 +141/−5,包含上面那个形状的 fixture,两条新断言对着现在的实现都会红:描述会是 1 task 而测试要 0,顶层的 [role="group"] 期望恰好是 ['Pinned', 'Projects'] 而现在只有一个 group。packages/ui 测试 33/33,@maka/ui typecheck、typecheck:stories、format、lint 都干净。

按时间视图一行没动,magick compare -metric AE 在明暗两套下都报 0 个像素差异。

CI 那项红不是你的问题

挂的是 test / Desktop e2ee2e/transcript-scroll.spec.ts:628main4019fc50 上同一个 job 也挂了,挂在另一个 spec e2e/streaming-remount.spec.ts:142。这次改动只碰 packages/ui/src/session-history-list.tsx,够不到 transcript 的滚动,重跑一下应该就过。

行内还有三条小的化简,都不改行为。分支整个拿走、拿一部分、或者直接反驳都行。

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three simplifications I mentioned in the comment above, inline. None change behaviour, and they apply whichever structure this ends up with.

简体中文

上面那条评论里提到的三处化简,写在行内了。都不改行为,而且不管最后用哪种结构都适用。

const activeGroups = props.groups.filter((group) => group.project?.archivedAt === undefined);
const archivedGroups = props.groups.filter((group) => group.project?.archivedAt !== undefined);
const pinnedSessions = groupSessionsForHistory(
activeGroups.flatMap((group) => group.sessions.filter((session) => session.isFlagged)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: the pin predicate is applied twice in one expression. groupSessionsForHistory already splits on isFlagged and puts the result in the pinned bucket, so this pre-filter is doing work the next line undoes the need for. Passing the sessions through unfiltered leaves one place that decides what pinned means:

Suggested change
activeGroups.flatMap((group) => group.sessions.filter((session) => session.isFlagged)),
activeGroups.flatMap((group) => group.sessions),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied. The full active-session list now goes through groupSessionsForHistory, leaving the pinned predicate in one place.

const pinnedSessions = groupSessionsForHistory(
activeGroups.flatMap((group) => group.sessions.filter((session) => session.isFlagged)),
locale,
).find((group) => group.id === 'pinned')?.sessions ?? [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: the group you just built already carries its own label, so copy.pinned further down is a second place naming the same section. Keeping the group and reading .label off it in the JSX removes that split, and it also makes the length > 0 check unnecessary since groupSessionsForHistory only emits the bucket when it is non-empty:

Suggested change
).find((group) => group.id === 'pinned')?.sessions ?? [];
).find((group) => group.id === 'pinned');

Then {pinnedGroup && <SideNavSection title={pinnedGroup.label} ...>} at the render site.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied. The render now consumes pinnedGroup directly, including its label and sessions.

label: string;
project?: ProjectRecord;
sessions: SessionSummary[];
summarySessions?: SessionSummary[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: the only call site always passes this, so the optional marker and the ?? props.sessions fallback below are a branch that cannot be reached. Making it required states the contract that the project branch actually relies on, which is that the summary counts full membership and never the filtered subtree:

Suggested change
summarySessions?: SessionSummary[];
summarySessions: SessionSummary[];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by the two-section change: summarySessions was removed entirely, and the project summary now reads the same filtered sessions that its subtree renders.

props.streamingSessionIds,
),
[props.project, props.sessions, props.streamingSessionIds],
[props.project, props.sessions, props.streamingSessionIds, props.summarySessions],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: props.sessions is no longer read inside this memo, so listing it here recomputes the summary whenever the filtered subtree changes identity even though the summary cannot have changed:

Suggested change
[props.project, props.sessions, props.streamingSessionIds, props.summarySessions],
[props.project, props.streamingSessionIds, props.summarySessions],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved with the same removal: the memo now depends only on project, sessions, and streamingSessionIds; summarySessions no longer exists.

Astro-Han and others added 3 commits September 3, 2026 14:32
The pinned zone and the project rows are two different Astryx primitives
laid out as siblings. SideNavSection is a group of navigation items, not
one of them: it renders role="group" with aria-labelledby, and its
documented shape is a Section containing Items. Flattening one Section
next to a row of SideNavItems puts a group heading and navigation items
on the same level, and the seam shows. The pinned heading is small grey
text where the project rows are full items with a folder icon; the
project rows collapse and the pinned zone cannot; the project rows carry
a row menu and the pinned zone does not. The one thing the two do share
is the indent under them, so the layout promises a hierarchy it gives no
way to operate.

Render the two as sibling sections instead, "Pinned" and "Projects".
The containment relationship is restored, project rows stay items inside
a section with their icon, disclosure and row menu untouched, and the
two grouping modes end up structurally symmetric: Pinned / Recent by
time, Pinned / Projects by project. Archived projects stay the last item
of the Projects section, because what that row holds is projects.

Hoisting the pinned tasks out of a project also left the project row
describing rows it no longer shows. The row derived its disclosure and
its menu placement from the filtered list while the hover summary read
the unfiltered one, so a project whose only visible task was pinned drew
no chevron and no subtree while its accessible description still
announced 1 task. Drop the second list: the summary reads the same
sessions the row renders, so the count, the chevron and the menu
placement have one source. A story fixture covers that shape.

Generated-by: Claude Code
The scroll E2E recurred because moving to zero was treated as the
request even though a settled scroller cannot move farther. Dispatching
the upward wheel matches the production trigger and removes timing
dependence on a scroll event.

Signed-off-by: Jiawei Zhao <Phoenix500526@163.com>
Generated-by: OpenAI Codex
The history grouping helper already owns the pinned predicate and label.
Using its result directly keeps that definition in one place.

Signed-off-by: Jiawei Zhao <Phoenix500526@163.com>

Generated-by: OpenAI Codex
@Phoenix500526

Copy link
Copy Markdown
Contributor Author

Thanks — I took the two-section proposal, including the pinned-only project fixture and the accessible-count correction, and applied the remaining lookup simplifications from the inline comments. I also included the explicit wheel trigger for the unrelated transcript-scroll flake; the affected UI suite is green and that E2E passed 10/10 locally.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on af54c482d. You took the two-section shape, and the follow-up you did on top of it is better than what I handed you.

7c23d373a puts Pinned and Projects at the same level as two SideNavSections, so Section-contains-Item comes back and the project rows keep their folder icon, disclosure and row menu untouched. Both grouping modes now share one structure.

af54c482d is the part I did not think of. Instead of filtering isFlagged first and then finding the pinned group in the result, you feed the whole set to groupSessionsForHistory and take the group it produces, title included. That removes the project view's separate reference to copy.pinned, so what counts as pinned, how it is ordered and what it is called all resolve in one place (session-history-list.tsx:1484). One authority instead of two that agree by hand.

With summarySessions gone, hasSessions, the row menu position and the accessible count read the same list again. The story fixture and the two assertions pin the shape rather than describing it: the top-level [role="group"] list is exactly Pinned and Projects, and a project whose only visible task is pinned reports zero tasks instead of one.

The single line in transcript-scroll.spec.ts is unrelated to this change and I read it as correct. That spec's own comment says it is testing the wheel-to-load path, and setting scrollTop = 0 alone was missing that step; dispatching the wheel event is supplying the driver the case always needed, not papering over the failure. request top history explicitly describes it accurately.

Thanks for pushing back with an implementation rather than a description. Merging.

简体中文

af54c482d 上批准。两个 section 的形状你采纳了,而且在它上面又做的那一步比我给你的版本更好。

7c23d373a 把「置顶」和「项目」放成两个平级的 SideNavSection,Section 装 Item 的关系回来了,项目行的文件夹图标、折叠箭头、行内菜单一行没动,两种分组模式现在结构相同。

af54c482d 是我没想到的那一步。你不再先按 isFlagged 过滤、再去结果里找置顶分组,而是把整份列表喂给 groupSessionsForHistory,直接取它产出的那个分组,连标题一起用。这样项目视图里对 copy.pinned 的单独引用就没了,「哪些算置顶、怎么排、叫什么」全都落在 session-history-list.tsx:1484 一处。一个权威,而不是两个靠人手对齐的地方。

summarySessions 删掉之后,hasSessions、行内菜单的位置和无障碍计数又读同一份列表了。story fixture 和那两条断言是把形状钉住而不是描述它:顶层的 [role="group"] 恰好是「置顶」和「项目」,而唯一可见任务被置顶的项目报的是 0 条任务而不是 1 条。

transcript-scroll.spec.ts 那一行和这次改动无关,我看了一下是对的。那条测试自己的注释就写着它测的是 wheel 触发加载那条路径,而只设 scrollTop = 0 少了这一步;派发 wheel 事件是把这个用例本来就缺的驱动补上,不是把失败盖过去。commit 名 request top history explicitly 说得很准。

谢谢你用一个实现而不是一段描述来反驳我。合了。

@Astro-Han
Astro-Han merged commit 148f8eb into apache:main Sep 3, 2026
1 check passed
@Phoenix500526
Phoenix500526 deleted the fix/project-view-pinned-section branch September 3, 2026 10:03
ggbdpq pushed a commit to ggbdpq/maka that referenced this pull request Sep 4, 2026
…#4613)

Pinned tasks were only pinned within their own group. Grouping by project put a
pinned task wherever its project happened to fall, so the one list position a
user pins something to reach was not stable across the two grouping modes.

The project view now renders two sibling `SideNavSection`s, Pinned and Projects,
the same shape the by-time view already had as Pinned and Recent. Project rows
stay `SideNavItem`s inside the Projects section, so Section-contains-Item holds
in both modes and the rows keep their folder icon, disclosure and row menu.
Archived projects remain the last item of the Projects section, since what that
section holds is projects.

The pinned rows and their heading come from the group `groupSessionsForHistory`
already produces, rather than from a separate `isFlagged` filter and a second
reference to the copy. What counts as pinned, how it is ordered and what it is
called now resolve in one place for both modes.

No prop carries an unfiltered copy of the list alongside the filtered one, so
`hasSessions`, the row menu position and the hover summary's task count read the
same sessions. A project whose only visible task is pinned no longer reports a
task it does not show.

The by-time view is unchanged.

Generated-by: Claude Code
Generated-by: Codex
Generated-by: OpenAI Codex

Generated-by: GLM-5.3-Flash (ZCode)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/S Under 100 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(ui): show pinned tasks in project view

2 participants