Skip to content

feat(harmonyos): replace the sidebar source switcher with workspaces - #2342

Merged
wgqqqqq merged 8 commits into
GCWing:mainfrom
wgqqqqq:feat/harmonyos-sidebar-workspaces
Aug 17, 2026
Merged

feat(harmonyos): replace the sidebar source switcher with workspaces#2342
wgqqqqq merged 8 commits into
GCWing:mainfrom
wgqqqqq:feat/harmonyos-sidebar-workspaces

Conversation

@wgqqqqq

@wgqqqqq wgqqqqq commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

The HarmonyOS sidebar asked the user to pick a conversation source — Local or Remote — before showing them anything. That is an implementation detail: where a session runs is not how anyone looks for it, and the switcher cost a permanent row of chrome to express it.

Conversations and workspaces now share one scroll. They are different kinds of thing (a timeline and a set of places), so they stack rather than compete for the same pane, and neither carries a label saying which machine it runs on.

Verifying the new sidebar on a device turned up three follow-ons that are folded in here: the workspace section's "connect a desktop" row did not connect, a SymbolGlyph sizing trap that had been quietly pulling icons off centre across the app, and a transcript that stacked a reasoning card above the tools it drove instead of folding the two together.

Changes

Sidebar workspaces

  • Drop ConversationSource and ConversationSourceSwitcher. AppRouteContract.conversationSource / routeForConversationSource become isRemoteRoute / remoteSurfaceDestination. isRemoteRoute is stated as the complement of isGeneralComposerRoute so the two predicates cannot drift apart — a route is one or the other, never both. remoteSurfaceDestination resumes an in-flight remote session rather than dropping the user on a picker they did not ask for.
  • New SidebarWorkspaceSection, reaching the sidebar through a @BuilderParam slot. The sidebar has no business knowing about remote state, so the host wires it.
  • Cap the conversation list at six rows behind an overflow row. The shared scroll means the list can no longer take every pixel it wants; six leaves the workspace section visible on a phone without hunting for it.
  • Fade rows out under the floating footer via a new page_bg_fade token (PAGE_BG at zero alpha, defined in both the base and dark palettes). Without it a workspace row mid-scroll is visually sliced by the chat button sitting on top of it.

Make the connect-a-desktop row connect

  • The row was wired to onSidebar.enterCode, which only moves the visible surface to Remote. Offline that resolves to AppRoute.RemoteHome, whose wide-layout detail pane is RemoteSurfaceHost in placeholder mode — a header and two lines of status text, no control. So the row named the last-known machine and then did nothing, and a second press returned early on the isRemoteRoute guard. RemoteSurfaceEntryPolicy already states that reaching the remote surface and asking to pair are different commands and must not share an entry point; the row now uses onSidebar.connectDesktop, which opens the connect sheet. The section header's + shares that event and is relabelled by the connection it adds, since the phone cannot create a workspace.
  • Drop workspace entries with no path. syncRemotePageSummary seeds the current workspace with the placeholder name 「未连接」 and an empty path, and the projection turned any named current workspace into an entry. On the Remote surface that never showed (you only got there connected); in the sidebar it is on screen from launch, so a reconnecting phone listed a folder called 「未连接」 that opens nothing.

SymbolGlyph sizing

The connect sheet's back arrow sat visibly left of its round button. Probing on a device established why, and the same defect turned out to be spread across the app.

SymbolGlyph draws its ink left-anchored inside any box forced wider than the glyph's natural advance box; vertically it stays centred, so only the horizontal axis is affected, and a box forced smaller makes the glyph overflow rather than scale. Most symbols have a square advance box of fontSize * 1.013, but chevron_left and chevron_right are only fontSize * 0.507 wide — so every .width(N).height(N) on a chevron shifted it left by (N - fontSize * 0.507) / 2.

Of 96 SymbolGlyph call sites, 58 forced a size. They were fixed by the role that forced size was playing:

  • Glyphs inside a fixed-size centring Stack or circular Button (9 sites): the forced box overrode the container's centring, by up to 4.5vp. Dropping the size lets the container centre the natural box.
  • Trailing disclosure chevrons in list rows (7 sites): the forced 16–18vp box left ~10vp of dead space between the arrow and the row's right padding, so arrows never lined up with the card edge.
  • Collapse indicators that swap chevron_right / chevron_down (3 sites): these keep a fixed slot — it is what stops the label jumping between states — but the slot moves to a wrapping Stack and the glyph inside goes unsized, matching the idiom already in SubagentTaskCard.
  • Leading icon slots in list rows are deliberately left alone. Because ink is left-anchored, the shared forced width is exactly what keeps icon and text left edges aligned down a column of rows whose fontSize values differ.

AGENTS.md gains the measured rule. The bullet it replaces told authors to "give the glyph its visual size", which is what produced the bug in the first place.

Reasoning folds into the tool summary it drove

A transcript reads think → act → think → act, but the app rendered a single 「已思考」 sitting on top of 「已运行 4 个工具」 for a message whose items alternate.

structuredGroups buffers a maximal run of thinking entries and settled tools into one activity group, and ActivityGroup rendered every thinking part in that group above every tool in it. A run is broken only by a non-foldable entry — assistant text, a subagent card, or a live/failed/pending tool — so a silent chain of reasoning and tool calls collapses into one group and the later reasoning was hoisted over tools that had already finished when the model wrote it.

Reasoning and the tools it drove are one stretch of work, so they now fold behind a single summary row rather than standing as two cards:

  • ToolStatusList takes the group's thinking parts and weaves them among its tool rows. Collapsed shows only 「已运行 N 个工具」; expanding reveals thinking and tool rows in transcript order. A lone tool has no summary to unfold, so its reasoning stays on screen rather than being lost.
  • ActivityGroupPolicy states the placement rules. mergeThinkingParts keeps one header for an uninterrupted stretch of reasoning while pinning blocks on either side of a tool call to where they happened; weaveRows never places thinking above a summary row, which is the fold's own header.
  • Flush buffered tools before pushing a group, so a failed or live tool held in the buffer cannot render below an activity group that came after it.

ToolStatusList also built its collapsed summary detail, operation labels, Todo/Task fallbacks, and failure suffix from Chinese string literals while the rest of the component already used RemoteI18n. Those move into the message table; the component now has no user-visible literals left.

Test plan

The documented local verification loop from AGENTS.md passes:

"$HVIGORW" --mode module -p product=default -p module=entry@default assembleHap --no-daemon
"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon
pnpm run theme:color-audit:all

ConversationPresentationUnit.test.ets, RemoteControllersUnit.test.ets, and DeviceSmoke.test.ets are updated for the route-contract rename and the removed switcher. The color audit reports no new near pairs.

Installed on a physical HarmonyOS device (wide layout, dark theme) and confirmed on screen:

  • The source switcher is gone; 最近对话 and 工作区 stack in one scroll, and the workspace section shows the offline state with the "connect a desktop" row.
  • The connect-a-desktop row opens the connect sheet, and no 「未连接」 placeholder folder is listed on a reconnecting phone.
  • Icon geometry measured from device screenshots rather than eyeballed: the settings close button's ink centre is now 0.32vp from the circle centre (this class of site was ~7vp off before); the model row's trailing chevron leaves an 18.6vp right gap against the row's 18vp padding (~29vp before); and a thinking block's label starts at the same x in both collapsed and expanded states, so the slot did not collapse.

The fold was checked on the same real message that showed the defect. It previously rendered 已思考 above 已运行 4 个工具; it now renders 已运行 4 个工具 alone, and expanding that row reads 已思考 / GetToolSpec / GetToolSpec / 已思考 / 运行命令 / 运行命令 · gh auth status 2>&1; gh api … / 已思考 — transcript order, with the reasoning text intact when a block inside the fold is opened. No message key leaks into the UI.

Not yet exercised on device: the conversation-list overflow row (needs more than six conversations) and the connected-workspace state.

wgqqqqq and others added 8 commits August 17, 2026 15:58
The sidebar asked the user to pick a conversation *source* — Local or
Remote — before showing them anything. That is an implementation detail:
where a session runs is not how anyone looks for it, and the switcher cost
a permanent row of chrome to express it.

Conversations and workspaces now share one scroll. They are different kinds
of thing (a timeline and a set of places), so they stack rather than compete
for the same pane, and neither carries a label saying which machine it runs
on. The workspace section arrives through a `@BuilderParam` slot: the
sidebar has no business knowing about remote state, so the host wires it.

- Drop `ConversationSource` and `ConversationSourceSwitcher`.
  `AppRouteContract.conversationSource`/`routeForConversationSource` become
  `isRemoteRoute`/`remoteSurfaceDestination`, with `isRemoteRoute` stated as
  the complement of `isGeneralComposerRoute` so the two cannot drift apart.
  `remoteSurfaceDestination` resumes an in-flight remote session instead of
  dropping the user on a picker they did not ask for.
- Cap the conversation list at six rows behind an overflow row, so the
  workspace section stays visible on a phone without hunting for it. The
  shared scroll means the list can no longer take every pixel it wants.
- Fade rows out under the floating footer via a new `page_bg_fade` token
  (PAGE_BG at zero alpha, defined per theme). Without it a workspace row
  mid-scroll is visually sliced by the chat button sitting on top of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The workspace section's "connect a desktop" row was wired to
`onSidebar.enterCode`, which only moves the visible surface to Remote.
Offline that resolves to `AppRoute.RemoteHome`, whose wide-layout detail
pane is `RemoteSurfaceHost` in placeholder mode: a header naming the
desktop the phone last held and two lines of status text, with no control
on it. So the row named the last-known machine on the right and then did
nothing, and pressing it again returned early on the `isRemoteRoute`
guard.

`RemoteSurfaceEntryPolicy` already states that reaching the remote surface
and asking to pair are different commands and must not share an entry
point. The row was on the wrong side of that line: give it
`onSidebar.connectDesktop`, which opens the connect sheet.

The section header's "+" shares the same event. Workspaces come from the
desktop and the phone cannot create one, so it is relabelled by the
connection it adds rather than a workspace it cannot make.

Also drop workspace entries with no path. `syncRemotePageSummary` seeds
the current workspace with the placeholder name 「未连接」 and an empty
path, and the projection turns any named current workspace into an entry.
On the Remote surface that never showed, because you only got there
connected; in the sidebar it is on screen from launch, so a reconnecting
phone with no workspace data yet listed a folder called 「未连接」 that
opens nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The back arrow on the desktop picker and on the account page sat visibly
left of the circle it lives in. Measured on device: circle centre x=422.5,
chevron ink centre x=399.5 — about 7vp adrift.

The cause is SymbolGlyph sizing, not the surrounding layout. A chevron's
natural advance box is roughly half as wide as it is tall (~11.6vp at
fontSize 23, against 23.2vp of height). Forcing .width(26).height(26) does
not re-centre the glyph in the enlarged box; it draws left-anchored, so the
ink lands 26/2 - 11.6/2 ≈ 7vp left of where the box centre says it should.
Vertically it looked fine only because the natural height already matched
the forced one. Probing the two glyphs side by side with tinted backgrounds
confirmed it: the unsized chevron's box measured 37px wide and centred in
the circle, while a magnifier forced to the same 26vp filled its box and
stayed centred, because its ink is square.

Dropping the forced size lets the Stack/Button centre the natural box.
Re-measured after the change: ink centre x=422.5 against a circle centre of
422.5.

Trailing chevrons elsewhere keep their forced width — there it acts as a
row gutter rather than as a centring box, and removing it would move the
rows rather than fix them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The crooked back chevron fixed in b48f60d was not a one-off. Probing on
device showed SymbolGlyph draws its ink left-anchored inside any box the
caller forces wider than the glyph's natural advance box (vertically it
stays centred, so only the horizontal axis is affected). Most symbols
have a square advance box of fontSize x 1.013, but chevron_left and
chevron_right are only half as wide as they are tall, so every
`.width(N).height(N)` on a chevron shifted it left of where the
surrounding container promised to put it.

Two shapes of the same bug:

- Glyphs inside a fixed-size centring Stack or circular Button: the
  forced box overrode the container's centring and pulled the icon off
  centre by up to 4.5vp. Dropping the size lets the container centre the
  natural box. Measured on the settings close button: ink centre now
  0.3vp from the circle centre, was ~7vp.

- Trailing disclosure chevrons in list rows: the forced 16-18vp box left
  ~10vp of dead space between the arrow and the row's right padding, so
  the arrows never lined up with the card edge. The right gap on the
  model row now measures 18.6vp against the 18vp padding.

Collapse indicators that toggle between chevron_right and chevron_down
keep their fixed slot -- it is what stops the label jumping between the
two states -- but the slot moves to a wrapping Stack and the glyph inside
it goes unsized, matching the idiom already used in SubagentTaskCard.
Verified on device that the label x is identical in both states.

Leading icon slots in list rows are left as they are: because ink is
left-anchored, the forced width is what keeps icon and text left edges
aligned across rows of differing fontSize.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous bullet told authors to "give the glyph its visual size",
which is exactly what produced the off-centre chevrons fixed in
257a945. Replace it with the metrics measured on device, the reason
forcing a width moves a chevron left, the Stack-wrapper idiom for
collapse indicators, and the one case where a forced width is the right
call.
An activity group buffers a maximal run of thinking entries and settled
tools, then renders every thinking part above every tool it holds. A run
is only broken by a non-foldable entry such as assistant text, so a
silent chain of thought -> act -> thought collapses into one group and
the later thinking is hoisted above tools that had already run when the
model wrote it. On device that showed up as a single "已思考" sitting on
top of "已运行 4 个工具" for a message whose transcript alternates.

Open a new group whenever a thinking entry follows a tool in the same
run, and flush buffered tools before pushing a group so tools that ran
earlier cannot land below it. Consecutive thinking segments still merge
into one header.

Test plan:
- assembleHap + LocalTest for entry@default, no ERROR lines.
- On device (foldable inner screen, dark theme), the same message now
  renders 已思考 / 已运行 2 个工具 / 已思考 / 已运行 2 个工具 / 已思考
  where it previously rendered 已思考 / 已运行 4 个工具.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ToolStatusList built its collapsed summary detail, tool operation labels,
Todo/Task fallbacks, and the failure suffix from Chinese string literals
while the rest of the component already used RemoteI18n. Move all of them
into the message table so the component has no user-visible literals left.

Test plan:
- assembleHap + LocalTest for entry@default, no ERROR lines.
- On device, an expanded tool group still reads
  "运行命令 · gh auth status 2>&1; gh api ..." and the collapsed row still
  reads "已运行 2 个工具"; no message keys leak into the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A transcript reads think -> act -> think -> act, but the app rendered one
「已思考」 stacked on top of 「已运行 N 个工具」: `ActivityGroup` merged every
thinking part in the group above every tool in it, hoisting later reasoning
over tools that had already finished when the model wrote it.

Reasoning and the tools it drove are one stretch of work, so they now fold
behind a single summary row instead of standing as two cards. `ToolStatusList`
takes the group's thinking parts and weaves them among its tool rows:
collapsed shows only 「已运行 N 个工具」, expanding reveals thinking and tools
in transcript order. A lone tool has no summary to unfold, so its reasoning
stays on screen rather than being lost.

`ActivityGroupPolicy` gains the placement rules — `mergeThinkingParts` keeps a
single header for an uninterrupted stretch of reasoning while pinning blocks on
either side of a tool call to where they happened, and `weaveRows` never puts
thinking above a summary row, which is the fold's own header.

Also drops `collapsedSummaryDetail`, which nothing called, and the four
`chat.tools*Count` message keys it was the only reader of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wgqqqqq
wgqqqqq merged commit 3d3abfb into GCWing:main Aug 17, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant