From 5073cb7b0940ffb1ddcfb7223ee60ffc1aeccddc Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Fri, 23 Oct 2026 14:21:03 +0000 Subject: [PATCH 1/2] fix(web): show a nested session's own group badge and allow moving it to a group In Custom mode a subtree is placed by its root, so a child session renders under its parent in the parent's section. A child filed in another group had no visible sign of that group. A nested row now shows its own group chip when its normalised group id differs from its direct parent's (phaseSidebarNestedRowShowsCustomGroup, with unit tests). "Move to group" is offered on every row. The old gate (treeParentKey == null) hid it on orphaned top-level rows, whose parent is not rendered, not on nested rows as its comment said. AGENTS.md: Tushar's Manager session may merge a green, reviewed PR into stage by its own judgement and verify it on stagebkt3; bkmain still needs Tushar, asked once on the Manager's batched Toolyard decision card. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 19 +++++- .../src/components/PhaseGroupedSidebar.tsx | 58 +++++++++++-------- .../sidebar/PhaseGroupedSidebar.logic.test.ts | 36 ++++++++++++ .../sidebar/PhaseGroupedSidebar.logic.ts | 26 +++++++++ docs/user/thread-sidebar.md | 7 +++ 5 files changed, 119 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 408b8d72808ba..124a87fb76cb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,18 @@ confirm the running service is on the merge SHA. Report once, when it is deploye when it is merged. "Merged" is not a delivery; the reviewer cannot verify anything until expbkt3 is actually serving the new build, and a build or install can still fail after a green PR. Ground truth is `/home/ubuntu/.t3/expbkt3-dev/deployed-sha` matching the merge -commit, plus `t3-expbkt3.service` active. This authorisation covers `expbkmain` only — -`bkmain` still needs a human, because deploying it kills the team's live sessions. +commit, plus `t3-expbkt3.service` active. This authorisation covers `expbkmain`, and +`stage` for the Manager session below, only — `bkmain` still needs a human, because +deploying it kills the team's live sessions. + +**Tushar's Manager session may merge to `stage` by its own judgement.** Tushar's Manager +session (the bk-docs `manager` skill, `tushar` child) may merge a green PR into `stage` +without a further ask, once CI is green and the change is reviewed. It then owns the +change until it is live: it verifies it running on stagebkt3.dev.beknown.live, the same +way as the `expbkmain` rule above. A merge or promotion to `bkmain` still needs Tushar. +The Manager asks once, as one item on its batched Toolyard decision card, because a +`bkmain` deploy restarts bkt3 and stops the live sessions; Tushar then picks the time. +(Tushar, 2026-10-23.) ## Building features that survive upstream merges @@ -223,7 +233,10 @@ For authorized mobile verification, a missing or outdated native client is a bui - Never make a PR unless the developer explicitly asks you to do so. - Merging a green PR into `expbkmain` needs no further ask, but it is not finished at merge: follow it through the deploy and report only once expbkt3 is serving it. See - "Beknown fork and deployments". Merging to `bkmain` still needs a human. + "Beknown fork and deployments". Tushar's Manager session may also merge a green, + reviewed PR into `stage` by its own judgement, then verify it on stagebkt3. Merging to + `bkmain` still needs a human (for the Manager: one item on its batched Toolyard + decision card). - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. - UI changes need before/after images. Motion or timing needs a short video. diff --git a/apps/web/src/components/PhaseGroupedSidebar.tsx b/apps/web/src/components/PhaseGroupedSidebar.tsx index 0c1e5f67e9d5b..eeec146e07cbc 100644 --- a/apps/web/src/components/PhaseGroupedSidebar.tsx +++ b/apps/web/src/components/PhaseGroupedSidebar.tsx @@ -262,6 +262,8 @@ import { PHASE_SIDEBAR_SECTION_ACTION_BUTTON_CLASS_NAME, PHASE_SIDEBAR_SECTION_MANAGED_HEADER_CLASS_NAME, phaseSidebarCustomGroupAccentStyle, + // T3-CUSTOM(expbkt3): a nested row's own group chip in Custom mode. + phaseSidebarNestedRowShowsCustomGroup, phaseSidebarSectionActionsClassName, phaseSidebarSectionHeaderClassName, } from "./sidebar/PhaseGroupedSidebar.logic"; @@ -1113,7 +1115,11 @@ interface PhaseThreadRowProps { // `groupActions` absent = the host cannot store custom groups. readonly groupActions?: PhaseThreadRowGroupActions; readonly customGroupId?: string | null; - /** The group's display label, shown as a chip outside Custom mode only. */ + /** + * The group's display label, shown as a chip outside Custom mode. In Custom + * mode only on Running shelf rows and on nested rows filed in another group + * than their parent. + */ readonly customGroupLabel?: string | null; /** The group's colour (a hex value) from the shared registry, tinting that chip. */ readonly customGroupColor?: string | null; @@ -1602,27 +1608,26 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) // T3-CUSTOM(expbkt3): custom groups. Always offered, whichever mode is // showing — placing a session is cheap, and the group is waiting when the // user switches to Custom. - // A nested row is placed with its parent, so offering to move it alone - // would show a tick for a group it never appears in. - const groupItems = - groupActions && treeParentKey == null - ? [ - { - id: "move-to-group", - label: "Move to group", - children: [ - ...groupActions.groups.map((group) => ({ - id: `group:${group.id}`, - label: customGroupId === group.id ? `${group.label} ✓` : group.label, - })), - ...(customGroupId != null - ? [{ id: "group:none", label: "Remove from group" }] - : []), - { id: "group:new", label: "New group…" }, - ], - }, - ] - : []; + // Offered on nested rows too. A nested row still renders under its parent + // in Custom mode (a subtree is placed by its root), but it now shows its + // own group chip when that group differs from its parent's, so the move + // visibly took effect. The group also applies when the row is detached. + const groupItems = groupActions + ? [ + { + id: "move-to-group", + label: "Move to group", + children: [ + ...groupActions.groups.map((group) => ({ + id: `group:${group.id}`, + label: customGroupId === group.id ? `${group.label} ✓` : group.label, + })), + ...(customGroupId != null ? [{ id: "group:none", label: "Remove from group" }] : []), + { id: "group:new", label: "New group…" }, + ], + }, + ] + : []; // Side-by-side sessions. First in the menu because it is the one item that // starts work rather than tidying up after it, and because the whole point // is opening a second session without leaving the one you are reading. @@ -4094,7 +4099,8 @@ export function PhaseGroupedSidebar() { // clean while silently leaving every treeXxx prop undefined, which reads as // "the feature is off" rather than as a build error. tree?: PhaseThreadRowTreeProps, - // T3-CUSTOM(expbkt3): Running shelf rows left their group, so they name it. + // T3-CUSTOM(expbkt3): Running shelf rows left their group, so they name it, + // and so does a nested row filed in another group than its parent. showGroup = false, ) => { const key = scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)); @@ -4191,7 +4197,11 @@ export function PhaseGroupedSidebar() { aria-label={`Sessions started by ${node.row.thread.title}`} className="mt-0.5 space-y-0.5 border-l border-sidebar-border pl-1" > - {node.children.map((child) => renderTreeNode(child))} + {/* T3-CUSTOM(expbkt3): a child filed in another group than its + parent names that group, since it renders in the parent's section. */} + {node.children.map((child) => + renderTreeNode(child, phaseSidebarNestedRowShowsCustomGroup(child.row, node.row)), + )} ) : null} diff --git a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts index 705d5ce40c14e..cf39f346eac76 100644 --- a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts +++ b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts @@ -65,6 +65,8 @@ import { resolvePhaseSidebarWorkBadge, sanitizePhaseSidebarFilters, sanitizePhaseSidebarSort, + // T3-CUSTOM(expbkt3): a nested row's own group chip in Custom mode. + phaseSidebarNestedRowShowsCustomGroup, type PhaseSidebarRow, } from "./PhaseGroupedSidebar.logic"; @@ -2159,3 +2161,37 @@ describe("phase sidebar pinning", () => { }); }); // T3-CUSTOM(expbkt3): END + +// T3-CUSTOM(expbkt3): BEGIN — a nested row's own group chip in Custom mode. +describe("phaseSidebarNestedRowShowsCustomGroup", () => { + it("shows the chip when the child's group differs from its parent's", () => { + expect( + phaseSidebarNestedRowShowsCustomGroup({ customGroup: "Today" }, { customGroup: "Manager" }), + ).toBe(true); + }); + + it("shows the chip when the parent has no group and the child has one", () => { + expect( + phaseSidebarNestedRowShowsCustomGroup({ customGroup: "Today" }, { customGroup: null }), + ).toBe(true); + }); + + it("hides the chip when the child is in its parent's group", () => { + expect( + phaseSidebarNestedRowShowsCustomGroup({ customGroup: "Today" }, { customGroup: "Today" }), + ).toBe(false); + }); + + it("compares groups by their normalised id, not by the label's case", () => { + expect( + phaseSidebarNestedRowShowsCustomGroup({ customGroup: "today" }, { customGroup: "Today" }), + ).toBe(false); + }); + + it("hides the chip when the child has no group", () => { + expect( + phaseSidebarNestedRowShowsCustomGroup({ customGroup: null }, { customGroup: "Today" }), + ).toBe(false); + }); +}); +// T3-CUSTOM(expbkt3): END diff --git a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts index 14f7eb2e610d0..19ccda979a0ea 100644 --- a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts +++ b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts @@ -11,6 +11,11 @@ export * from "@t3tools/client-runtime/state/phase-sidebar"; import type { PhaseSidebarPhaseId } from "@t3tools/client-runtime/state/phase-sidebar"; +// T3-CUSTOM(expbkt3): a nested row's own group chip in Custom mode. +import { + phaseSidebarCustomGroupIdForRow, + type PhaseSidebarRow, +} from "@t3tools/client-runtime/state/phase-sidebar"; // T3-CUSTOM(expbkt3): a custom group's colour is an inline style (a hex value). import type { CSSProperties } from "react"; // T3-CUSTOM(expbkt3): Linear tags on a session. @@ -367,3 +372,24 @@ export function resolvePhaseSidebarShelfRows(input: { routedRow !== undefined && routedIndex >= page.length ? [...page, routedRow] : page; return { rendered, hiddenCount: rows.length - rendered.length, containsRoutedThread }; } + +// T3-CUSTOM(expbkt3): BEGIN — a nested row's own group chip in Custom mode. +/** + * Whether a NESTED row shows its own custom-group chip in Custom mode. + * + * Custom mode places a whole subtree by its root, so a child renders under its + * parent in the parent's section, whatever its own group is. A top-level row + * needs no chip there (the section header names its group), but a child filed + * in a different group would hide that group completely. So a child shows its + * own chip only when it has a group and that group differs from its direct + * parent's. Groups compare by their normalised id, so "Today" and "today" are + * the same group. + */ +export function phaseSidebarNestedRowShowsCustomGroup( + row: Pick, + parent: Pick, +): boolean { + const ownGroupId = phaseSidebarCustomGroupIdForRow(row); + return ownGroupId !== null && ownGroupId !== phaseSidebarCustomGroupIdForRow(parent); +} +// T3-CUSTOM(expbkt3): END diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 74485d0f961fc..00919a15bfe58 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -287,6 +287,13 @@ thread sees the same group, on every device. To file a thread, open its menu and choose **Move to group**, pick an existing group or **New group…**, or **Remove from group**. + + +In Custom mode, a session nested under a parent session stays under that +parent, in the parent's group. You can still move the nested session to a +group of its own. When its group differs from its parent's, the row shows a +chip with its own group's name. + The groups themselves are shared too. Each environment keeps a list of its From 18fa0965f28040c0dc7e40a6c2ef85d9c2f5bbb5 Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Fri, 23 Oct 2026 14:25:31 +0000 Subject: [PATCH 2/2] fix(web): name the group by its registry label on Custom-mode chips In Custom mode, a row chip (Running shelf rows and nested rows filed in another group than their parent) now shows the group's registry label, the same spelling as the section header, and falls back to the row's own label. Other modes are unchanged. Adds helper tests for whitespace and case normalisation and for two rows with no group. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/PhaseGroupedSidebar.tsx | 10 +++++++++- .../sidebar/PhaseGroupedSidebar.logic.test.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/PhaseGroupedSidebar.tsx b/apps/web/src/components/PhaseGroupedSidebar.tsx index eeec146e07cbc..eb24fd3563097 100644 --- a/apps/web/src/components/PhaseGroupedSidebar.tsx +++ b/apps/web/src/components/PhaseGroupedSidebar.tsx @@ -4156,8 +4156,16 @@ export function PhaseGroupedSidebar() { // T3-CUSTOM(expbkt3): custom groups. {...(groupActions ? { groupActions } : {})} customGroupId={phaseSidebarCustomGroupIdForRow(row)} + // T3-CUSTOM(expbkt3): in Custom mode a chip names the group as the + // registry spells it (the section header's label), not the row's own spelling. customGroupLabel={ - grouping.groupBy === "custom" && !showGroup ? null : (row.customGroup ?? null) + grouping.groupBy !== "custom" + ? (row.customGroup ?? null) + : showGroup + ? (customGroupLabels.get(phaseSidebarCustomGroupIdForRow(row) ?? "") ?? + row.customGroup ?? + null) + : null } customGroupColor={customGroupColors.get(phaseSidebarCustomGroupIdForRow(row) ?? "") ?? null} linearIssueStatuses={linearStatusesForRow(row)} diff --git a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts index cf39f346eac76..bdd25ddfbae90 100644 --- a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts +++ b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.test.ts @@ -2188,6 +2188,18 @@ describe("phaseSidebarNestedRowShowsCustomGroup", () => { ).toBe(false); }); + it("compares groups after trimming whitespace and folding case", () => { + expect( + phaseSidebarNestedRowShowsCustomGroup({ customGroup: " Today " }, { customGroup: "today" }), + ).toBe(false); + }); + + it("hides the chip when neither the child nor its parent has a group", () => { + expect( + phaseSidebarNestedRowShowsCustomGroup({ customGroup: null }, { customGroup: null }), + ).toBe(false); + }); + it("hides the chip when the child has no group", () => { expect( phaseSidebarNestedRowShowsCustomGroup({ customGroup: null }, { customGroup: "Today" }),