feat(harmonyos): replace the sidebar source switcher with workspaces - #2342
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
SymbolGlyphsizing 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
ConversationSourceandConversationSourceSwitcher.AppRouteContract.conversationSource/routeForConversationSourcebecomeisRemoteRoute/remoteSurfaceDestination.isRemoteRouteis stated as the complement ofisGeneralComposerRouteso the two predicates cannot drift apart — a route is one or the other, never both.remoteSurfaceDestinationresumes an in-flight remote session rather than dropping the user on a picker they did not ask for.SidebarWorkspaceSection, reaching the sidebar through a@BuilderParamslot. The sidebar has no business knowing about remote state, so the host wires it.page_bg_fadetoken (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
onSidebar.enterCode, which only moves the visible surface to Remote. Offline that resolves toAppRoute.RemoteHome, whose wide-layout detail pane isRemoteSurfaceHostin 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 theisRemoteRouteguard.RemoteSurfaceEntryPolicyalready states that reaching the remote surface and asking to pair are different commands and must not share an entry point; the row now usesonSidebar.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.syncRemotePageSummaryseeds 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.
SymbolGlyphdraws 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 offontSize * 1.013, butchevron_leftandchevron_rightare onlyfontSize * 0.507wide — so every.width(N).height(N)on a chevron shifted it left by(N - fontSize * 0.507) / 2.Of 96
SymbolGlyphcall sites, 58 forced a size. They were fixed by the role that forced size was playing:Stackor circularButton(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.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 wrappingStackand the glyph inside goes unsized, matching the idiom already inSubagentTaskCard.fontSizevalues differ.AGENTS.mdgains 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.
structuredGroupsbuffers a maximal run of thinking entries and settled tools into one activity group, andActivityGrouprendered 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:
ToolStatusListtakes 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.ActivityGroupPolicystates the placement rules.mergeThinkingPartskeeps one header for an uninterrupted stretch of reasoning while pinning blocks on either side of a tool call to where they happened;weaveRowsnever places thinking above a summary row, which is the fold's own header.ToolStatusListalso built its collapsed summary detail, operation labels, Todo/Task fallbacks, and failure suffix from Chinese string literals while the rest of the component already usedRemoteI18n. Those move into the message table; the component now has no user-visible literals left.Test plan
The documented local verification loop from
AGENTS.mdpasses:ConversationPresentationUnit.test.ets,RemoteControllersUnit.test.ets, andDeviceSmoke.test.etsare 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:
最近对话and工作区stack in one scroll, and the workspace section shows the offline state with the "connect a desktop" row.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.