@gperezmz/bb-plugin-thread-glance 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/app.tsx +3 -11
- package/dist/app.css +1 -1
- package/dist/app.js +31 -31
- package/dist/app.meta.json +1 -1
- package/dist/server.js +10 -10
- package/dist/server.js.map +3 -3
- package/dist/server.meta.json +1 -1
- package/features/thread-list/components/FoldRows.tsx +21 -30
- package/features/thread-list/components/GroupSection.tsx +7 -45
- package/features/thread-list/components/ListHeader.tsx +79 -0
- package/features/thread-list/components/ProviderBadge.tsx +0 -1
- package/features/thread-list/components/PullRequestProbes.tsx +27 -0
- package/features/thread-list/components/SettingsPanel.tsx +20 -34
- package/features/thread-list/components/ThreadDetails.tsx +3 -3
- package/features/thread-list/components/ThreadList.tsx +117 -83
- package/features/thread-list/components/ThreadRowView.tsx +203 -180
- package/features/thread-list/components/controller.ts +5 -10
- package/features/thread-list/components/glyphs.tsx +10 -16
- package/features/thread-list/data/useAutoExpand.ts +1 -1
- package/features/thread-list/data/useDefaultBranches.ts +35 -0
- package/features/thread-list/data/usePullRequestAnswers.ts +36 -0
- package/features/thread-list/data/useSystemFacts.ts +52 -0
- package/features/thread-list/icons.ts +1 -0
- package/features/thread-list/index.ts +0 -1
- package/features/thread-list/model/attention.ts +2 -66
- package/features/thread-list/model/branches.ts +27 -0
- package/features/thread-list/model/counters.ts +5 -5
- package/features/thread-list/model/details.ts +6 -6
- package/features/thread-list/model/expansion.ts +5 -5
- package/features/thread-list/model/groups.ts +2 -2
- package/features/thread-list/model/labels.ts +43 -18
- package/features/thread-list/model/menu.ts +1 -0
- package/features/thread-list/model/move.ts +1 -1
- package/features/thread-list/model/notes.ts +2 -28
- package/features/thread-list/model/settings.ts +1 -17
- package/features/thread-list/model/settled.ts +122 -0
- package/features/thread-list/model/share.ts +1 -7
- package/features/thread-list/model/sort.ts +5 -16
- package/features/thread-list/model/state.ts +17 -16
- package/features/thread-list/model/time.ts +7 -0
- package/features/thread-list/model/toggles.ts +35 -24
- package/features/thread-list/model/{families.ts → trees.ts} +40 -28
- package/features/thread-list/model/view.ts +202 -274
- package/features/thread-list/testing/browser.css +13 -0
- package/features/thread-list/testing/fixtures.ts +41 -12
- package/package.json +5 -1
- package/server/cli.ts +1 -1
- package/server/import.ts +36 -6
- package/server/preference-store.ts +2 -1
- package/server.ts +1 -1
- package/shared/preferences.ts +38 -27
- package/skills/thread-glance/SKILL.md +11 -12
- package/features/thread-list/data/useAttentionHold.ts +0 -19
|
@@ -20,11 +20,11 @@ import { rowIndent } from "../model/layout";
|
|
|
20
20
|
import { rowMenuItems } from "../model/menu";
|
|
21
21
|
import { noteText } from "../model/notes";
|
|
22
22
|
import { chipTone, pluginStatusWins } from "../model/state";
|
|
23
|
-
import { trailingTime } from "../model/time";
|
|
23
|
+
import { TRAILING_SLOT_SIZERS, trailingTime } from "../model/time";
|
|
24
24
|
import type { ThreadRow } from "../model/view";
|
|
25
25
|
import type { DraggedThread } from "../model/drag";
|
|
26
26
|
import type { RowController } from "./controller";
|
|
27
|
-
import {
|
|
27
|
+
import { ChipStateGlyph, GlyphIcon, NoteLine, PluginStatusGlyph, TONE_CLASS } from "./glyphs";
|
|
28
28
|
import { ProviderBadge } from "./ProviderBadge";
|
|
29
29
|
import { PullRequestBadge } from "./PullRequestBadge";
|
|
30
30
|
import { RenameEditor } from "./RenameEditor";
|
|
@@ -58,12 +58,11 @@ function swallowNextClick(): void {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* A quiet title, and its chip: the foreground mixed toward the
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* the light theme.
|
|
61
|
+
* A quiet title, and its chip: the foreground mixed toward the sidebar in
|
|
62
|
+
* oklch, which keeps 4.5:1 in both of bb's themes. Opacity blends in sRGB and
|
|
63
|
+
* lands lower in the light theme.
|
|
65
64
|
*/
|
|
66
|
-
const QUIET_TEXT = "text-[color:color-mix(in_oklch,var(--foreground)
|
|
65
|
+
const QUIET_TEXT = "text-[color:color-mix(in_oklch,var(--foreground)_68%,var(--sidebar))]";
|
|
67
66
|
|
|
68
67
|
export const ROW_ICON_BUTTON =
|
|
69
68
|
"pointer-events-auto relative z-10 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-state-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:bg-state-active";
|
|
@@ -175,7 +174,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
175
174
|
);
|
|
176
175
|
const menuItems = rowMenuItems({
|
|
177
176
|
thread,
|
|
178
|
-
unread: info.unread,
|
|
177
|
+
unread: row.depth === 0 ? row.treeUnread : info.unread,
|
|
179
178
|
splitAvailable: split.isAvailable,
|
|
180
179
|
isRoot: thread.parentThreadId === null,
|
|
181
180
|
hasSections: controller.mode === "chronological" || controller.sections.length > 0,
|
|
@@ -219,10 +218,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
219
218
|
controller.setEditingId(thread.id);
|
|
220
219
|
};
|
|
221
220
|
|
|
222
|
-
const
|
|
223
|
-
const branch = thread.environment?.branchName ?? null;
|
|
224
|
-
const showPullRequest =
|
|
225
|
-
controller.showPullRequests && row.depth === 0 && branch !== null && defaultBranch !== undefined && branch !== defaultBranch;
|
|
221
|
+
const showPullRequest = row.pullRequest !== null;
|
|
226
222
|
|
|
227
223
|
const stateSlot = miniMap ? (
|
|
228
224
|
<SplitMiniMap panes={miniMap} label={`${thread.displayTitle} — open in split; ${info.state.label}`} working={info.flags.has("working")} />
|
|
@@ -300,11 +296,15 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
300
296
|
const chip = row.chip;
|
|
301
297
|
const indent = rowIndent(row.depth);
|
|
302
298
|
const note = row.note;
|
|
303
|
-
const twoLines =
|
|
299
|
+
const twoLines = note !== null || row.branchLine !== null;
|
|
304
300
|
const dimmed = row.dimmed && !editing;
|
|
305
301
|
const menuShowing = menuOpen || contextOpen;
|
|
306
|
-
//
|
|
307
|
-
|
|
302
|
+
// A pressed shortcut's pill takes the machine's place, as it takes the time's.
|
|
303
|
+
const showMachine = row.machine !== null && shortcut === null;
|
|
304
|
+
const badgesShown = row.harness || showMachine;
|
|
305
|
+
const actionsShown = !compact && shortcut === null;
|
|
306
|
+
// Desktop: on hover the harness and machine fade out for Mark read and
|
|
307
|
+
// Archive, and the time for "…". Compact: nothing fades.
|
|
308
308
|
const fadeClass = compact
|
|
309
309
|
? ""
|
|
310
310
|
: menuShowing
|
|
@@ -366,7 +366,9 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
366
366
|
}}
|
|
367
367
|
className="absolute inset-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
|
368
368
|
/>
|
|
369
|
-
<span className="pointer-events-none relative flex size-4 shrink-0 items-center justify-center">
|
|
369
|
+
<span className="pointer-events-none relative flex size-4 shrink-0 items-center justify-center">
|
|
370
|
+
{stateSlot}
|
|
371
|
+
</span>
|
|
370
372
|
{row.nested ? (
|
|
371
373
|
// Tight against the title, and over the row's gap, so it adds 8px.
|
|
372
374
|
<span
|
|
@@ -404,27 +406,31 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
404
406
|
}}
|
|
405
407
|
/>
|
|
406
408
|
) : (
|
|
407
|
-
<span
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
409
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
410
|
+
<span
|
|
411
|
+
title={thread.displayTitle}
|
|
412
|
+
className={cn(
|
|
413
|
+
"min-w-0 truncate",
|
|
414
|
+
row.bold ? "font-semibold" : "font-normal",
|
|
415
|
+
// Children sit a step below their parent.
|
|
416
|
+
row.depth > 0 && "text-xs",
|
|
417
|
+
// Quiet threads step back so live ones lead; hover brings them back.
|
|
418
|
+
dimmed && `${QUIET_TEXT} group-hover/row:text-foreground`,
|
|
419
|
+
)}
|
|
420
|
+
>
|
|
421
|
+
{/* Plain text: mention pills lost the truncation fight. */}
|
|
422
|
+
{thread.displayTitle}
|
|
423
|
+
</span>
|
|
424
|
+
{/* On a two-line row the badge stays on the title's line, not centred beside both. */}
|
|
425
|
+
{row.pullRequest === "title" && twoLines ? <PullRequestBadge threadId={thread.id} /> : null}
|
|
420
426
|
</span>
|
|
421
427
|
)}
|
|
422
428
|
{!editing && note !== null ? (
|
|
423
429
|
<span className="min-w-0 truncate text-xs leading-4 text-muted-foreground" title={noteText(note)}>
|
|
424
430
|
<NoteLine note={note} />
|
|
425
431
|
</span>
|
|
426
|
-
) :
|
|
427
|
-
<
|
|
432
|
+
) : row.branchLine !== null && !editing ? (
|
|
433
|
+
<BranchLine row={row} branch={row.branchLine} />
|
|
428
434
|
) : null}
|
|
429
435
|
</span>
|
|
430
436
|
{row.hiddenBadge ? (
|
|
@@ -432,136 +438,174 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
432
438
|
<Icon name={ICONS.hidden} aria-hidden className="size-3.5" />
|
|
433
439
|
</span>
|
|
434
440
|
) : null}
|
|
435
|
-
{
|
|
441
|
+
{row.pullRequest === "title" && !twoLines && !editing ? (
|
|
436
442
|
<span className="pointer-events-none relative">
|
|
437
443
|
<PullRequestBadge threadId={thread.id} />
|
|
438
444
|
</span>
|
|
439
445
|
) : null}
|
|
440
|
-
{
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
452
|
-
onKeyDown={(event) => event.stopPropagation()}
|
|
453
|
-
data-tone={chipTone(chip.flag)}
|
|
454
|
-
className={cn(
|
|
455
|
-
"pointer-events-auto relative z-10 inline-flex h-5 shrink-0 items-center gap-0.5 rounded-md border px-1 text-[11px] leading-none tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
|
456
|
-
CHIP_TONE_CLASS[chipTone(chip.flag)],
|
|
457
|
-
dimmed && QUIET_TEXT,
|
|
458
|
-
)}
|
|
459
|
-
>
|
|
460
|
-
{chip.count}
|
|
461
|
-
{chip.flag !== null ? <FlagGlyph flag={chip.flag} className="size-3" /> : null}
|
|
462
|
-
{controller.harnessIcon !== "hidden"
|
|
463
|
-
? chip.providerIds.map((providerId) => (
|
|
464
|
-
<ProviderBadge key={providerId} display={controller.provider(providerId)} className="size-3 [&_*]:size-3" />
|
|
465
|
-
))
|
|
466
|
-
: null}
|
|
467
|
-
<Icon
|
|
468
|
-
name={ICONS.expand}
|
|
469
|
-
aria-hidden
|
|
470
|
-
className={cn("size-3 transition-transform duration-150", chip.expanded && "rotate-90")}
|
|
471
|
-
/>
|
|
472
|
-
</button>
|
|
473
|
-
) : null}
|
|
474
|
-
{!editing ? (
|
|
475
|
-
<span
|
|
476
|
-
className={cn(
|
|
477
|
-
"pointer-events-none relative inline-flex shrink-0 transition-opacity",
|
|
478
|
-
// The hover actions take this slot too, as bb's trailing slot does.
|
|
479
|
-
shortcut === null && fadeClass,
|
|
480
|
-
)}
|
|
481
|
-
>
|
|
482
|
-
<ProviderBadge display={provider} mode={controller.harnessIcon} />
|
|
483
|
-
</span>
|
|
484
|
-
) : null}
|
|
485
|
-
{!editing ? (
|
|
486
|
-
<span className="relative flex h-6 min-w-8 shrink-0 items-center justify-end gap-1">
|
|
487
|
-
{shortcut !== null ? (
|
|
488
|
-
<kbd aria-hidden className="pointer-events-none rounded border border-border px-1 font-sans text-[10px] leading-4 text-muted-foreground">
|
|
489
|
-
{shortcut.label}
|
|
490
|
-
</kbd>
|
|
491
|
-
) : (
|
|
492
|
-
<>
|
|
493
|
-
{row.homeGroupLabel !== null ? (
|
|
494
|
-
<span
|
|
495
|
-
title={`In ${row.homeGroupLabel}`}
|
|
496
|
-
className={cn("pointer-events-none max-w-24 truncate text-[11px] text-muted-foreground transition-opacity", fadeClass)}
|
|
497
|
-
>
|
|
498
|
-
{row.homeGroupLabel}
|
|
446
|
+
{!editing && (badgesShown || actionsShown) ? (
|
|
447
|
+
// One cell: the harness and machine at rest, Mark read and Archive
|
|
448
|
+
// on hover. The actions take no width at rest, so no title is
|
|
449
|
+
// shorter for them; on hover the title gives up only what they
|
|
450
|
+
// need beyond the badges they replace.
|
|
451
|
+
<span className="relative -ml-1.5 grid shrink-0 items-center">
|
|
452
|
+
{badgesShown ? (
|
|
453
|
+
<span className={cn("flex items-center gap-1.5 pl-1.5 [grid-area:1/1] transition-opacity", shortcut === null && fadeClass)}>
|
|
454
|
+
{row.harness ? (
|
|
455
|
+
<span className="pointer-events-none relative inline-flex shrink-0">
|
|
456
|
+
<ProviderBadge display={provider} mode={controller.harnessIcon} />
|
|
499
457
|
</span>
|
|
500
|
-
) :
|
|
458
|
+
) : null}
|
|
459
|
+
{showMachine ? (
|
|
501
460
|
<span
|
|
502
|
-
title={
|
|
503
|
-
aria-label={
|
|
504
|
-
className=
|
|
505
|
-
"pointer-events-none text-[11px] tabular-nums transition-opacity",
|
|
506
|
-
// A running timer reads as work, an age as history.
|
|
507
|
-
time.kind === "timer" ? "font-medium text-[var(--timeline-accent)]" : "text-muted-foreground",
|
|
508
|
-
fadeClass,
|
|
509
|
-
)}
|
|
461
|
+
title={`On ${row.machine}`}
|
|
462
|
+
aria-label={`On ${row.machine}`}
|
|
463
|
+
className="pointer-events-none max-w-20 truncate text-[11px] text-muted-foreground"
|
|
510
464
|
>
|
|
511
|
-
{
|
|
465
|
+
{row.machine}
|
|
512
466
|
</span>
|
|
513
467
|
) : null}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
468
|
+
</span>
|
|
469
|
+
) : null}
|
|
470
|
+
{actionsShown ? (
|
|
471
|
+
<span
|
|
472
|
+
className={cn(
|
|
473
|
+
"items-center justify-self-end gap-0.5 pl-1.5 [grid-area:1/1]",
|
|
474
|
+
menuShowing ? "flex" : "hidden group-hover/row:flex group-focus-within/row:flex",
|
|
475
|
+
)}
|
|
476
|
+
>
|
|
477
|
+
{row.treeUnread ? (
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
aria-label="Mark read"
|
|
481
|
+
title="Mark read"
|
|
482
|
+
className={ROW_ICON_BUTTON}
|
|
483
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
484
|
+
onClick={(event) => {
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
event.stopPropagation();
|
|
487
|
+
controller.onMenuAction("mark-read", thread);
|
|
488
|
+
}}
|
|
489
|
+
>
|
|
490
|
+
<Icon name={ICONS.markRead} aria-hidden className="size-4" />
|
|
491
|
+
</button>
|
|
492
|
+
) : null}
|
|
493
|
+
<button
|
|
494
|
+
type="button"
|
|
495
|
+
aria-label={archived ? "Unarchive thread" : "Archive thread"}
|
|
496
|
+
title={archived ? "Unarchive" : "Archive"}
|
|
497
|
+
className={ROW_ICON_BUTTON}
|
|
498
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
499
|
+
onClick={(event) => {
|
|
500
|
+
event.preventDefault();
|
|
501
|
+
event.stopPropagation();
|
|
502
|
+
controller.onMenuAction(archived ? "unarchive" : "archive", thread);
|
|
503
|
+
}}
|
|
523
504
|
>
|
|
524
|
-
{
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
505
|
+
<Icon name={archived ? ICONS.unarchive : ICONS.archive} aria-hidden className="size-4" />
|
|
506
|
+
</button>
|
|
507
|
+
</span>
|
|
508
|
+
) : null}
|
|
509
|
+
</span>
|
|
510
|
+
) : null}
|
|
511
|
+
{!editing ? (
|
|
512
|
+
// The chip sits 4px before the trailing slot, whose width never
|
|
513
|
+
// changes, so hover never moves it.
|
|
514
|
+
<span className="relative flex shrink-0 items-center gap-1">
|
|
515
|
+
{chip !== null ? (
|
|
516
|
+
<button
|
|
517
|
+
type="button"
|
|
518
|
+
aria-expanded={chip.expanded}
|
|
519
|
+
aria-label={chipLabel(thread.displayTitle, chip)}
|
|
520
|
+
title={chipLabel(thread.displayTitle, chip)}
|
|
521
|
+
onClick={(event) => {
|
|
522
|
+
event.preventDefault();
|
|
523
|
+
event.stopPropagation();
|
|
524
|
+
controller.onToggleChip(row);
|
|
525
|
+
}}
|
|
526
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
527
|
+
onKeyDown={(event) => event.stopPropagation()}
|
|
528
|
+
className={cn(
|
|
529
|
+
"pointer-events-auto relative z-10 inline-flex h-5 shrink-0 items-center gap-0.5 rounded-md px-0.5 text-[11px] leading-none tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
|
530
|
+
chip.flag === null
|
|
531
|
+
? cn("text-muted-foreground hover:text-foreground", dimmed && QUIET_TEXT)
|
|
532
|
+
: TONE_CLASS[chipTone(chip.flag)],
|
|
533
|
+
)}
|
|
534
|
+
>
|
|
535
|
+
{chip.flag !== null ? <ChipStateGlyph flag={chip.flag} /> : null}
|
|
536
|
+
{chip.count > 0 ? chip.count : null}
|
|
537
|
+
<Icon
|
|
538
|
+
name={ICONS.expand}
|
|
539
|
+
aria-hidden
|
|
540
|
+
className={cn("size-3 transition-transform duration-150", chip.expanded && "rotate-90")}
|
|
541
|
+
/>
|
|
542
|
+
</button>
|
|
543
|
+
) : null}
|
|
544
|
+
{/* The trailing slot: one grid cell, as wide as "…" or the widest
|
|
545
|
+
time, whichever is wider, holding the time at rest and "…" on hover. */}
|
|
546
|
+
<span data-trailing-slot="" className="relative grid h-6 shrink-0 items-center">
|
|
547
|
+
<span aria-hidden className="invisible w-6 [grid-area:1/1]" />
|
|
548
|
+
{TRAILING_SLOT_SIZERS.map((text) => (
|
|
549
|
+
<span key={text} aria-hidden className="invisible text-[11px] font-medium tabular-nums [grid-area:1/1]">
|
|
550
|
+
{text}
|
|
551
|
+
</span>
|
|
552
|
+
))}
|
|
553
|
+
{shortcut !== null ? (
|
|
554
|
+
<kbd aria-hidden className="pointer-events-none justify-self-end rounded border border-border px-1 font-sans text-[10px] leading-4 text-muted-foreground [grid-area:1/1]">
|
|
555
|
+
{shortcut.label}
|
|
556
|
+
</kbd>
|
|
557
|
+
) : (
|
|
558
|
+
<>
|
|
559
|
+
{time !== null ? (
|
|
560
|
+
<span
|
|
561
|
+
title={time.label}
|
|
562
|
+
aria-label={time.label}
|
|
563
|
+
className={cn(
|
|
564
|
+
"pointer-events-none justify-self-end text-[11px] tabular-nums transition-opacity [grid-area:1/1]",
|
|
565
|
+
// A running timer reads as work, an age as history.
|
|
566
|
+
time.kind === "timer" ? "font-medium text-[var(--timeline-accent)]" : "text-muted-foreground",
|
|
567
|
+
fadeClass,
|
|
568
|
+
)}
|
|
536
569
|
>
|
|
537
|
-
|
|
538
|
-
</
|
|
570
|
+
{time.text}
|
|
571
|
+
</span>
|
|
539
572
|
) : null}
|
|
540
|
-
<
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
573
|
+
<span
|
|
574
|
+
className={cn(
|
|
575
|
+
"flex items-center justify-self-end transition-opacity [grid-area:1/1]",
|
|
576
|
+
compact
|
|
577
|
+
? "relative"
|
|
578
|
+
: menuShowing
|
|
579
|
+
? "opacity-100"
|
|
580
|
+
: "opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100",
|
|
581
|
+
)}
|
|
582
|
+
>
|
|
583
|
+
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
|
584
|
+
<DropdownMenuTrigger asChild>
|
|
585
|
+
<button
|
|
586
|
+
type="button"
|
|
587
|
+
aria-label="Thread actions"
|
|
588
|
+
// Phones open the menu by long-press, as bb's lists do;
|
|
589
|
+
// the button stays for keyboards and screen readers.
|
|
590
|
+
className={compact ? "sr-only" : ROW_ICON_BUTTON}
|
|
591
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
592
|
+
onClick={(event) => event.stopPropagation()}
|
|
593
|
+
>
|
|
594
|
+
<Icon name={ICONS.more} aria-hidden className="size-4" />
|
|
595
|
+
</button>
|
|
596
|
+
</DropdownMenuTrigger>
|
|
597
|
+
<RowDropdownMenuContent
|
|
598
|
+
items={menuItems}
|
|
599
|
+
sections={controller.sections}
|
|
600
|
+
currentSectionId={thread.sectionId}
|
|
601
|
+
onAction={onMenuAction}
|
|
602
|
+
onCloseAutoFocus={onMenuCloseAutoFocus}
|
|
603
|
+
/>
|
|
604
|
+
</DropdownMenu>
|
|
605
|
+
</span>
|
|
606
|
+
</>
|
|
607
|
+
)}
|
|
608
|
+
</span>
|
|
565
609
|
</span>
|
|
566
610
|
) : null}
|
|
567
611
|
</div>
|
|
@@ -601,38 +645,17 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
601
645
|
);
|
|
602
646
|
});
|
|
603
647
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
multiHost: boolean;
|
|
611
|
-
defaultBranch: string | null | undefined;
|
|
612
|
-
}) {
|
|
613
|
-
const thread = row.info.thread;
|
|
614
|
-
const environment = thread.environment;
|
|
615
|
-
const parts: React.ReactNode[] = [];
|
|
616
|
-
// The branch earns the line only when it isn't the project's default:
|
|
617
|
-
// "main" on every row said nothing.
|
|
618
|
-
const branch = environment?.branchName ?? null;
|
|
619
|
-
if (branch !== null && branch !== defaultBranch) {
|
|
620
|
-
parts.push(
|
|
621
|
-
<span key="branch" className="inline-flex min-w-0 items-center gap-0.5">
|
|
648
|
+
/** The Comfortable second line: the branch, then its pull request badge. */
|
|
649
|
+
function BranchLine({ row, branch }: { row: ThreadRow; branch: string }) {
|
|
650
|
+
const environment = row.info.thread.environment;
|
|
651
|
+
return (
|
|
652
|
+
<span className="flex min-w-0 items-center gap-1.5 text-xs leading-4 text-muted-foreground">
|
|
653
|
+
<span className="inline-flex min-w-0 items-center gap-0.5">
|
|
622
654
|
<Icon name={environment?.isWorktree ? ICONS.worktree : ICONS.branch} aria-hidden className="size-3 shrink-0" />
|
|
623
655
|
<span className="truncate">{branch}</span>
|
|
624
656
|
{environment?.isWorktree ? <span className="sr-only"> (worktree)</span> : null}
|
|
625
|
-
</span
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
parts.push(
|
|
630
|
-
<span key="host" className="inline-flex shrink-0 items-center gap-0.5">
|
|
631
|
-
<Icon name={ICONS.machine} aria-hidden className="size-3" />
|
|
632
|
-
{thread.host.name}
|
|
633
|
-
</span>,
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
if (parts.length === 0) return null;
|
|
637
|
-
return <span className="flex min-w-0 items-center gap-2 text-xs leading-4 text-muted-foreground">{parts}</span>;
|
|
657
|
+
</span>
|
|
658
|
+
{row.pullRequest === "second-line" ? <PullRequestBadge threadId={row.info.thread.id} /> : null}
|
|
659
|
+
</span>
|
|
660
|
+
);
|
|
638
661
|
}
|
|
@@ -6,9 +6,9 @@ import type { PluginSidebarSection, PluginSidebarThread } from "@get-bb/plugin-s
|
|
|
6
6
|
import type { Stamps } from "@/shared/contract";
|
|
7
7
|
import type { HarnessIcon, OrganizationMode } from "@/shared/preferences";
|
|
8
8
|
import type { ThreadNotes } from "@/shared/contract";
|
|
9
|
-
import type {
|
|
9
|
+
import type { ThreadTree } from "../model/trees";
|
|
10
10
|
import type { RowMenuAction } from "../model/menu";
|
|
11
|
-
import type { OlderRow, ThreadRow } from "../model/view";
|
|
11
|
+
import type { OlderRow, SettledRow, ThreadRow } from "../model/view";
|
|
12
12
|
import type { ProviderDisplay } from "./ProviderBadge";
|
|
13
13
|
|
|
14
14
|
export interface ModelInfo {
|
|
@@ -21,11 +21,7 @@ export interface RowController {
|
|
|
21
21
|
compact: boolean;
|
|
22
22
|
comfortable: boolean;
|
|
23
23
|
setEditingId(id: string | null): void;
|
|
24
|
-
showPullRequests: boolean;
|
|
25
24
|
harnessIcon: HarnessIcon;
|
|
26
|
-
/** Undefined while unknown; the lookup starts on first ask. */
|
|
27
|
-
defaultBranchOf(thread: PluginSidebarThread): string | null | undefined;
|
|
28
|
-
multiHost: boolean;
|
|
29
25
|
/** One object per harness, the same one on every call. */
|
|
30
26
|
provider(providerId: string): ProviderDisplay;
|
|
31
27
|
sections: readonly PluginSidebarSection[];
|
|
@@ -33,8 +29,7 @@ export interface RowController {
|
|
|
33
29
|
onNavigate(): void;
|
|
34
30
|
onToggleChip(row: ThreadRow): void;
|
|
35
31
|
onToggleOlder(row: OlderRow): void;
|
|
36
|
-
|
|
37
|
-
onOpenChildren(rootId: string): void;
|
|
32
|
+
onToggleSettled(row: SettledRow): void;
|
|
38
33
|
onToggleEnvironment(environmentId: string): void;
|
|
39
34
|
onMenuAction(action: RowMenuAction, thread: PluginSidebarThread, sectionId?: string | null): void;
|
|
40
35
|
onRename(threadId: string, title: string): Promise<void>;
|
|
@@ -54,12 +49,12 @@ export interface ListLive {
|
|
|
54
49
|
now: number;
|
|
55
50
|
stamps: Stamps;
|
|
56
51
|
notes: Readonly<Record<string, ThreadNotes>>;
|
|
57
|
-
|
|
52
|
+
treeOf(threadId: string): ThreadTree | undefined;
|
|
58
53
|
}
|
|
59
54
|
|
|
60
55
|
export const ListLiveContext = createContext<ListLive>({
|
|
61
56
|
now: 0,
|
|
62
57
|
stamps: { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {} },
|
|
63
58
|
notes: {},
|
|
64
|
-
|
|
59
|
+
treeOf: () => undefined,
|
|
65
60
|
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// State glyphs, chip
|
|
2
|
-
// decided; tone and animation come from host token
|
|
1
|
+
// State glyphs, the children chip's state glyph and header counters. They
|
|
2
|
+
// draw what the model decided; tone and animation come from host token
|
|
3
|
+
// classes only.
|
|
3
4
|
import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
|
|
4
5
|
import type { PluginSidebarThreadRowStatus } from "@get-bb/plugin-sdk/app";
|
|
5
6
|
import { cn } from "@/lib/utils";
|
|
6
7
|
import type { Counters } from "../model/counters";
|
|
7
8
|
import type { RowNote } from "../model/notes";
|
|
8
|
-
import { FLAG_GLYPHS, type
|
|
9
|
+
import { FLAG_GLYPHS, type Flag, type Glyph, type Tone } from "../model/state";
|
|
9
10
|
|
|
10
11
|
export const TONE_CLASS: Record<Tone, string> = {
|
|
11
12
|
attention: "text-attention",
|
|
@@ -18,19 +19,6 @@ export const TONE_CLASS: Record<Tone, string> = {
|
|
|
18
19
|
none: "",
|
|
19
20
|
};
|
|
20
21
|
|
|
21
|
-
// The tone colour is one of bb's tokens, mixed toward the foreground for the
|
|
22
|
-
// text so it clears 4.5:1 on the sidebar in both themes; the border and fill
|
|
23
|
-
// are the same colour, thinned. Written out in full so the class scan sees them.
|
|
24
|
-
export const CHIP_TONE_CLASS: Record<ChipTone, string> = {
|
|
25
|
-
attention:
|
|
26
|
-
"border-[color:color-mix(in_oklch,var(--attention)_45%,transparent)] bg-[color-mix(in_oklch,var(--attention)_10%,transparent)] text-[color:color-mix(in_oklch,var(--attention),var(--foreground)_55%)] hover:bg-[color-mix(in_oklch,var(--attention)_20%,transparent)]",
|
|
27
|
-
destructive:
|
|
28
|
-
"border-[color:color-mix(in_oklch,var(--destructive-text)_45%,transparent)] bg-[color-mix(in_oklch,var(--destructive-text)_10%,transparent)] text-[color:color-mix(in_oklch,var(--destructive-text),var(--foreground)_55%)] hover:bg-[color-mix(in_oklch,var(--destructive-text)_20%,transparent)]",
|
|
29
|
-
working:
|
|
30
|
-
"border-[color:color-mix(in_oklch,var(--timeline-accent)_45%,transparent)] bg-[color-mix(in_oklch,var(--timeline-accent)_10%,transparent)] text-[color:color-mix(in_oklch,var(--timeline-accent),var(--foreground)_55%)] hover:bg-[color-mix(in_oklch,var(--timeline-accent)_20%,transparent)]",
|
|
31
|
-
neutral: "border-border/70 text-muted-foreground hover:bg-state-hover hover:text-foreground",
|
|
32
|
-
};
|
|
33
|
-
|
|
34
22
|
const SHINE = "animate-shine-icon motion-reduce:animate-none";
|
|
35
23
|
const SPIN = "animate-spin motion-reduce:animate-none";
|
|
36
24
|
|
|
@@ -126,6 +114,12 @@ export function FlagGlyph({ flag, className }: { flag: Flag; className?: string
|
|
|
126
114
|
return <GlyphIcon glyph={FLAG_GLYPHS[flag]} className={className} />;
|
|
127
115
|
}
|
|
128
116
|
|
|
117
|
+
/** The state glyph that leads a children chip, smaller than the Status column's; the chip's label names it. */
|
|
118
|
+
export function ChipStateGlyph({ flag }: { flag: Flag }) {
|
|
119
|
+
if (FLAG_GLYPHS[flag].icon === "dot") return <UnreadDot className="size-[5px]" />;
|
|
120
|
+
return <FlagGlyph flag={flag} className="size-3" />;
|
|
121
|
+
}
|
|
122
|
+
|
|
129
123
|
const COUNTER_ITEMS: readonly {
|
|
130
124
|
key: keyof Counters;
|
|
131
125
|
flag: Flag;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Transient auto-expansion. Transitions are diffed against the previous
|
|
2
2
|
// render, as bb does; nothing here is persisted.
|
|
3
3
|
import { useCallback, useRef, useState } from "react";
|
|
4
|
-
import type { Forest } from "../model/
|
|
4
|
+
import type { Forest } from "../model/trees";
|
|
5
5
|
import {
|
|
6
6
|
detectTransitions,
|
|
7
7
|
mergeTargets,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Each project's default branch, looked up once per list through the
|
|
2
|
+
// machine of the project's default source.
|
|
3
|
+
//
|
|
4
|
+
// Debt: the answers are kept in component state, with no refresh, because
|
|
5
|
+
// the SDK offers no cached query for a project's branches. It clears when
|
|
6
|
+
// the SDK does, or when a default branch changing mid-session matters.
|
|
7
|
+
import { useEffect, useRef, useState } from "react";
|
|
8
|
+
import { useSdk } from "@get-bb/plugin-sdk/app";
|
|
9
|
+
import { defaultSourceHostId } from "../model/branches";
|
|
10
|
+
|
|
11
|
+
/** Project id → default branch; undefined while unknown, null when the lookup found none. */
|
|
12
|
+
export type DefaultBranches = ReadonlyMap<string, string | null>;
|
|
13
|
+
|
|
14
|
+
export function useDefaultBranches(projectIds: readonly string[]): DefaultBranches {
|
|
15
|
+
const sdk = useSdk();
|
|
16
|
+
const [branches, setBranches] = useState<DefaultBranches>(() => new Map());
|
|
17
|
+
const requested = useRef(new Set<string>());
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
for (const projectId of projectIds) {
|
|
20
|
+
if (requested.current.has(projectId)) continue;
|
|
21
|
+
requested.current.add(projectId);
|
|
22
|
+
const settle = (branch: string | null) => setBranches((current) => new Map(current).set(projectId, branch));
|
|
23
|
+
sdk.projects
|
|
24
|
+
.get({ projectId })
|
|
25
|
+
.then(async (project) => {
|
|
26
|
+
const hostId = defaultSourceHostId(project.sources);
|
|
27
|
+
if (hostId === null) return null;
|
|
28
|
+
const result = await sdk.projects.branches({ projectId, hostId, limit: "1" });
|
|
29
|
+
return result.defaultBranch ?? null;
|
|
30
|
+
})
|
|
31
|
+
.then(settle, () => settle(null));
|
|
32
|
+
}
|
|
33
|
+
}, [projectIds, sdk]);
|
|
34
|
+
return branches;
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The pull request lookups that answered, by thread id. Answers arriving in
|
|
2
|
+
// one burst (a list's worth of lookups on load) land in one state update.
|
|
3
|
+
//
|
|
4
|
+
// Debt: this copies server state the host's per-row pull request hook owns,
|
|
5
|
+
// because settling reads every thread's pull request at once and the SDK
|
|
6
|
+
// offers no query over many threads. It clears when the SDK does.
|
|
7
|
+
import { useCallback, useRef, useState } from "react";
|
|
8
|
+
import type { PullRequestState } from "../model/settled";
|
|
9
|
+
|
|
10
|
+
export type PullRequestAnswers = ReadonlyMap<string, PullRequestState | null>;
|
|
11
|
+
|
|
12
|
+
export function usePullRequestAnswers(): [PullRequestAnswers, (threadId: string, state: PullRequestState | null) => void] {
|
|
13
|
+
const [answers, setAnswers] = useState<PullRequestAnswers>(() => new Map());
|
|
14
|
+
const pending = useRef(new Map<string, PullRequestState | null>());
|
|
15
|
+
const scheduled = useRef(false);
|
|
16
|
+
const onAnswer = useCallback((threadId: string, state: PullRequestState | null) => {
|
|
17
|
+
pending.current.set(threadId, state);
|
|
18
|
+
if (scheduled.current) return;
|
|
19
|
+
scheduled.current = true;
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
scheduled.current = false;
|
|
22
|
+
const batch = pending.current;
|
|
23
|
+
pending.current = new Map();
|
|
24
|
+
setAnswers((current) => {
|
|
25
|
+
let next: Map<string, PullRequestState | null> | null = null;
|
|
26
|
+
for (const [id, value] of batch) {
|
|
27
|
+
if (current.has(id) && current.get(id) === value) continue;
|
|
28
|
+
next ??= new Map(current);
|
|
29
|
+
next.set(id, value);
|
|
30
|
+
}
|
|
31
|
+
return next ?? current;
|
|
32
|
+
});
|
|
33
|
+
}, 0);
|
|
34
|
+
}, []);
|
|
35
|
+
return [answers, onAnswer];
|
|
36
|
+
}
|