gloomberb 0.8.1 → 0.8.2

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.
Files changed (122) hide show
  1. package/README.md +102 -52
  2. package/package.json +1 -1
  3. package/src/api-client/chat.ts +8 -0
  4. package/src/api-client/index.test.ts +30 -0
  5. package/src/api-client/index.ts +4 -0
  6. package/src/api-client/normalizers.ts +1 -0
  7. package/src/api-client/types.ts +1 -0
  8. package/src/capabilities/factories.ts +4 -0
  9. package/src/components/chart/comparison/data.test.ts +54 -0
  10. package/src/components/chart/comparison/data.ts +47 -24
  11. package/src/components/chart/core/projection.ts +51 -27
  12. package/src/components/data-table/stack-view.test.tsx +14 -11
  13. package/src/components/data-table/stack-view.tsx +1 -2
  14. package/src/components/data-table/view.test.tsx +24 -18
  15. package/src/components/data-table/view.tsx +313 -83
  16. package/src/components/feed-data-table/stack-view.tsx +6 -8
  17. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  18. package/src/components/layout/pane/footer/index.tsx +14 -2
  19. package/src/components/layout/pane/title.ts +5 -1
  20. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  21. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  22. package/src/components/ticker/list-table-view.tsx +11 -25
  23. package/src/components/ui/choice-dialog.tsx +12 -2
  24. package/src/components/ui/multi-select/dialog.tsx +17 -5
  25. package/src/components/ui/shortcut-hint.tsx +14 -2
  26. package/src/components/ui/table-layout.test.ts +2 -2
  27. package/src/components/ui/table-layout.ts +8 -2
  28. package/src/components/ui/tabs.tsx +20 -6
  29. package/src/components/ui/ui.test.tsx +30 -5
  30. package/src/market-data/coordinator/auxiliary.ts +29 -1
  31. package/src/market-data/coordinator/index.ts +17 -1
  32. package/src/market-data/hooks.tsx +17 -1
  33. package/src/market-data/selectors.ts +4 -0
  34. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  35. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  36. package/src/plugins/builtin/account-management/model.ts +13 -3
  37. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  38. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  39. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  40. package/src/plugins/builtin/analytics/index.tsx +0 -3
  41. package/src/plugins/builtin/analytics/view.tsx +6 -5
  42. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  43. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  44. package/src/plugins/builtin/changelog/index.tsx +40 -27
  45. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  46. package/src/plugins/builtin/changelog/model.ts +98 -0
  47. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  48. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  49. package/src/plugins/builtin/chat/channels.ts +36 -19
  50. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  51. package/src/plugins/builtin/chat/content/channel-navigation.ts +6 -0
  52. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  53. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  54. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  55. package/src/plugins/builtin/chat/content/index.tsx +61 -0
  56. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +20 -1
  58. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  59. package/src/plugins/builtin/chat/content/types.ts +1 -0
  60. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  61. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  62. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  63. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  64. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  65. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  66. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  67. package/src/plugins/builtin/chat/layout.ts +94 -31
  68. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  69. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  70. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  71. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  72. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  73. package/src/plugins/builtin/chat/message/types.ts +2 -0
  74. package/src/plugins/builtin/chat/pane.tsx +35 -7
  75. package/src/plugins/builtin/chat/sidebar.test.tsx +1 -0
  76. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  77. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  78. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  79. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  80. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  81. package/src/plugins/builtin/earnings/index.tsx +8 -7
  82. package/src/plugins/builtin/econ/index.tsx +6 -8
  83. package/src/plugins/builtin/help/index.tsx +6 -0
  84. package/src/plugins/builtin/holders/pane.tsx +7 -6
  85. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  86. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  87. package/src/plugins/builtin/options/view.tsx +16 -8
  88. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  89. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  90. package/src/plugins/builtin/research/index.test.ts +30 -1
  91. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  92. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  93. package/src/plugins/builtin/sec/index.tsx +132 -20
  94. package/src/plugins/builtin/sectors/index.tsx +13 -23
  95. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  96. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  97. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  98. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  99. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  100. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  101. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  102. package/src/plugins/prediction-markets/pane.tsx +10 -9
  103. package/src/plugins/registry/index.ts +3 -0
  104. package/src/plugins/runtime/context.tsx +1 -0
  105. package/src/plugins/runtime/index.tsx +4 -0
  106. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  107. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  108. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  109. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  110. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  111. package/src/sources/gloomberb-cloud/index.ts +4 -0
  112. package/src/sources/provider-router/cache.ts +1 -0
  113. package/src/sources/provider-router/document-routes.ts +45 -0
  114. package/src/sources/provider-router/index.ts +4 -0
  115. package/src/sources/sec-edgar/content.ts +9 -3
  116. package/src/sources/sec-edgar.test.ts +108 -1
  117. package/src/sources/sec-edgar.ts +105 -1
  118. package/src/sources/yahoo-finance.ts +4 -0
  119. package/src/test-support/plugin-runtime.ts +1 -0
  120. package/src/types/data-provider.ts +11 -0
  121. package/src/ui/host.tsx +1 -0
  122. package/src/version.ts +1 -1
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Portfolio, TickerRecord } from "../../../types/ticker";
3
+ import { buildPortfolioChoices, countPortfolioHoldings, NO_PORTFOLIO_VALUE } from "./model";
4
+
5
+ function makeTicker(symbol: string, portfolios: string[]): TickerRecord {
6
+ return {
7
+ metadata: {
8
+ ticker: symbol,
9
+ exchange: "XNAS",
10
+ currency: "USD",
11
+ name: symbol,
12
+ portfolios,
13
+ watchlists: [],
14
+ positions: [],
15
+ custom: {},
16
+ tags: [],
17
+ },
18
+ };
19
+ }
20
+
21
+ describe("account management model", () => {
22
+ test("counts portfolio holdings from app ticker state", () => {
23
+ const tickers = new Map([
24
+ ["AAPL", makeTicker("AAPL", ["main"])],
25
+ ["MSFT", makeTicker("MSFT", ["main", "income"])],
26
+ ["SPY", makeTicker("SPY", [])],
27
+ ]);
28
+
29
+ expect(countPortfolioHoldings(tickers)).toEqual({ main: 2, income: 1 });
30
+ });
31
+
32
+ test("describes public portfolio analytics in the shared portfolio picker", () => {
33
+ const portfolios: Portfolio[] = [
34
+ { id: "main", name: "Main Portfolio", currency: "USD" },
35
+ ];
36
+
37
+ const choices = buildPortfolioChoices(portfolios, { main: 2 });
38
+
39
+ expect(choices).toEqual([
40
+ {
41
+ id: NO_PORTFOLIO_VALUE,
42
+ label: "None",
43
+ detail: "Off",
44
+ description: "Do not show portfolio analytics on your public profile.",
45
+ },
46
+ {
47
+ id: "main",
48
+ label: "Main Portfolio",
49
+ detail: "2 tickers",
50
+ description: "Shares this portfolio's YTD % and SPY Beta on your public profile.",
51
+ },
52
+ ]);
53
+ });
54
+ });
@@ -1,6 +1,6 @@
1
1
  import type { ChoiceDialogChoice } from "../../../components";
2
2
  import type { AccountProfile } from "../../../api-client";
3
- import type { Portfolio } from "../../../types/ticker";
3
+ import type { Portfolio, TickerRecord } from "../../../types/ticker";
4
4
 
5
5
  export type AccountFieldKey =
6
6
  | "username"
@@ -75,14 +75,24 @@ export function formatPlan(plan: AccountProfile["plan"] | null | undefined): str
75
75
  return plan === "pro" ? "Pro" : "Free";
76
76
  }
77
77
 
78
+ export function countPortfolioHoldings(tickers: ReadonlyMap<string, TickerRecord>): Record<string, number> {
79
+ const counts: Record<string, number> = {};
80
+ for (const record of tickers.values()) {
81
+ for (const portfolioId of record.metadata.portfolios) {
82
+ counts[portfolioId] = (counts[portfolioId] ?? 0) + 1;
83
+ }
84
+ }
85
+ return counts;
86
+ }
87
+
78
88
  export function buildPortfolioChoices(portfolios: Portfolio[], holdingCounts: Record<string, number>): ChoiceDialogChoice[] {
79
89
  return [
80
- { id: NO_PORTFOLIO_VALUE, label: "None", detail: "Off", description: "No public analytics portfolio." },
90
+ { id: NO_PORTFOLIO_VALUE, label: "None", detail: "Off", description: "Do not show portfolio analytics on your public profile." },
81
91
  ...portfolios.map((portfolio) => ({
82
92
  id: portfolio.id,
83
93
  label: portfolio.name,
84
94
  detail: `${holdingCounts[portfolio.id] ?? 0} tickers`,
85
- description: portfolio.description || `${portfolio.currency} portfolio`,
95
+ description: "Shares this portfolio's YTD % and SPY Beta on your public profile.",
86
96
  })),
87
97
  ];
88
98
  }
@@ -13,6 +13,7 @@ import {
13
13
  BASE_FIELD_ORDER,
14
14
  NO_PORTFOLIO_VALUE,
15
15
  buildPortfolioChoices,
16
+ countPortfolioHoldings,
16
17
  emptyToNull,
17
18
  portfolioOptionIds,
18
19
  profileToDraft,
@@ -49,15 +50,7 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
49
50
  const bodyHeight = Math.max(5, height);
50
51
  const fieldOrder = BASE_FIELD_ORDER;
51
52
 
52
- const portfolioHoldingCounts = useMemo(() => {
53
- const counts: Record<string, number> = {};
54
- for (const record of Object.values(tickers)) {
55
- for (const portfolioId of record.metadata.portfolios) {
56
- counts[portfolioId] = (counts[portfolioId] ?? 0) + 1;
57
- }
58
- }
59
- return counts;
60
- }, [tickers]);
53
+ const portfolioHoldingCounts = useMemo(() => countPortfolioHoldings(tickers), [tickers]);
61
54
  const portfolioChoices = useMemo(
62
55
  () => buildPortfolioChoices(portfolios, portfolioHoldingCounts),
63
56
  [portfolioHoldingCounts, portfolios],
@@ -138,6 +131,7 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
138
131
 
139
132
  const openPortfolioDialog = useCallback(async () => {
140
133
  setActiveField("sharedPortfolioId");
134
+ const currentPortfolioChoiceId = draftRef.current.sharedPortfolioId || NO_PORTFOLIO_VALUE;
141
135
  const selected = await dialog.prompt<string>({
142
136
  closeOnClickOutside: false,
143
137
  content: (context: PromptContext<string>) => (
@@ -145,7 +139,7 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
145
139
  {...context}
146
140
  title="Shared Portfolio"
147
141
  choices={portfolioChoices}
148
- footer="↑↓ choose · Enter/click select · Esc cancel"
142
+ selectedChoiceId={currentPortfolioChoiceId}
149
143
  />
150
144
  ),
151
145
  }).catch(() => "");
@@ -365,7 +359,7 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
365
359
  <PickerRow
366
360
  label="Profile Analytics"
367
361
  value={selectedPortfolioLabel(portfolios, draft.sharedPortfolioId)}
368
- detail={draft.sharedPortfolioId ? "YTD% + beta source" : "No shared portfolio"}
362
+ detail={draft.sharedPortfolioId ? "Shares portfolio YTD % + SPY Beta" : "No public portfolio analytics"}
369
363
  active={activeField === "sharedPortfolioId"}
370
364
  width={formWidth}
371
365
  onFocus={() => setActiveField("sharedPortfolioId")}
@@ -78,7 +78,7 @@ export function PasswordChangeDialog({
78
78
 
79
79
  const fieldWidth = 42;
80
80
  return (
81
- <DialogFrame title="Change Password" footer="Esc cancel">
81
+ <DialogFrame title="Change Password">
82
82
  <Box flexDirection="column" gap={1}>
83
83
  <TextField
84
84
  label={activeField === "current" ? "> Current Password" : " Current Password"}
@@ -279,7 +279,11 @@ export function AlertsPane({ focused, width, height, close }: PaneProps) {
279
279
  return (
280
280
  <DataTableView<AlertRule, AlertColumn>
281
281
  focused={focused}
282
- selectedIndex={selectedIdx}
282
+ selection={{
283
+ kind: "index",
284
+ selectedIndex: selectedIdx,
285
+ onChange: (index) => setSelectedIdx(index),
286
+ }}
283
287
  onRootKeyDown={handleTableKeyDown}
284
288
  rootWidth={width}
285
289
  rootHeight={height}
@@ -290,8 +294,6 @@ export function AlertsPane({ focused, width, height, close }: PaneProps) {
290
294
  sortDirection="asc"
291
295
  onHeaderClick={() => {}}
292
296
  getItemKey={(alert) => alert.id}
293
- isSelected={(_alert, index) => index === selectedIdx}
294
- onSelect={(_alert, index) => setSelectedIdx(index)}
295
297
  onActivate={(alert) => {
296
298
  if (alert.status === "triggered") rearmAlert(alert.id);
297
299
  }}
@@ -198,8 +198,6 @@ function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
198
198
  const effectiveSelectedSectorId = selectedSectorId && sortedSectorRows.some((row) => row.id === selectedSectorId)
199
199
  ? selectedSectorId
200
200
  : sortedSectorRows[0]?.id ?? null;
201
- const selectedSectorIdx = sortedSectorRows.findIndex((row) => row.id === effectiveSelectedSectorId);
202
- const safeSelectedSectorIdx = selectedSectorIdx >= 0 ? selectedSectorIdx : 0;
203
201
  const sectorColumns = useMemo(() => buildSectorColumns(width), [width]);
204
202
  const hasPositions = portfolioTickers.length > 0;
205
203
 
@@ -302,7 +300,6 @@ function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
302
300
 
303
301
  <SectorAllocationTable
304
302
  focused={focused}
305
- selectedIndex={safeSelectedSectorIdx}
306
303
  resetScrollKey={activePortfolioId}
307
304
  columns={sectorColumns}
308
305
  rows={sortedSectorRows}
@@ -143,7 +143,6 @@ export function PortfolioHistorySection({
143
143
 
144
144
  export function SectorAllocationTable({
145
145
  focused,
146
- selectedIndex,
147
146
  resetScrollKey,
148
147
  columns,
149
148
  rows,
@@ -153,7 +152,6 @@ export function SectorAllocationTable({
153
152
  onSelectSector,
154
153
  }: {
155
154
  focused: boolean;
156
- selectedIndex: number;
157
155
  resetScrollKey: string;
158
156
  columns: SectorTableColumn[];
159
157
  rows: SectorTableRow[];
@@ -165,7 +163,12 @@ export function SectorAllocationTable({
165
163
  return (
166
164
  <DataTableView<SectorTableRow, SectorTableColumn>
167
165
  focused={focused}
168
- selectedIndex={selectedIndex}
166
+ selection={{
167
+ kind: "id",
168
+ selectedId: selectedSectorId,
169
+ getId: (row) => row.id,
170
+ onChange: (id) => onSelectSector(id),
171
+ }}
169
172
  resetScrollKey={resetScrollKey}
170
173
  columns={columns}
171
174
  items={rows}
@@ -173,8 +176,6 @@ export function SectorAllocationTable({
173
176
  sortDirection={sort.direction}
174
177
  onHeaderClick={onHeaderClick}
175
178
  getItemKey={(row) => row.id}
176
- isSelected={(row) => selectedSectorId === row.id}
177
- onSelect={(row) => onSelectSector(row.id)}
178
179
  emptyStateTitle="No sector data available"
179
180
  emptyStateHint="Load profile data or add sectors to the portfolio positions."
180
181
  renderCell={(row, column) => {
@@ -276,18 +276,18 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
276
276
  detailTitle={selectedRow?.label}
277
277
  rootWidth={tableWidth}
278
278
  rootHeight={bodyHeight}
279
- selectedIndex={Math.min(selectedIndex, Math.max(0, rows.length - 1))}
280
- onSelectIndex={selectBrokerRow}
281
- onActivateIndex={openSelectedDetail}
279
+ selection={{
280
+ kind: "index",
281
+ selectedIndex: Math.min(selectedIndex, Math.max(0, rows.length - 1)),
282
+ onChange: (index, row) => selectBrokerRow(index, row),
283
+ }}
284
+ onActivate={(row, index) => openSelectedDetail(index, row)}
282
285
  columns={columns}
283
286
  items={rows}
284
287
  sortColumnId={null}
285
288
  sortDirection="asc"
286
289
  onHeaderClick={() => {}}
287
290
  getItemKey={(row) => row.id}
288
- isSelected={(row) => selectedRow?.id === row.id}
289
- onSelect={(row, index) => selectBrokerRow(index, row)}
290
- onActivate={(row, index) => openSelectedDetail(index, row)}
291
291
  renderCell={renderBrokerCell}
292
292
  emptyStateTitle="No broker profiles."
293
293
  emptyStateHint="Add a broker profile to test connections and sync positions."
@@ -373,16 +373,16 @@ export function BuildoutPane({ focused, width, height }: PaneProps) {
373
373
  )}
374
374
  columns={columns}
375
375
  items={rows}
376
- selectedIndex={selectedIndex}
377
- onSelectIndex={(index) => setSelectedIndex(index)}
378
- onActivateIndex={(_index, row) => activateRow(row)}
376
+ selection={{
377
+ kind: "index",
378
+ selectedIndex,
379
+ onChange: (index) => setSelectedIndex(index),
380
+ }}
381
+ onActivate={activateRow}
379
382
  sortColumnId={sortColumnId}
380
383
  sortDirection={sortDirection}
381
384
  onHeaderClick={handleHeaderClick}
382
385
  getItemKey={rowKey}
383
- isSelected={(row) => selectedRow ? rowKey(row) === rowKey(selectedRow) : false}
384
- onSelect={(_row, index) => setSelectedIndex(index)}
385
- onActivate={activateRow}
386
386
  renderCell={renderCell}
387
387
  bodyAfter={hiddenCompanyCount > 0 ? (
388
388
  <CompaniesUpgradeCta
@@ -16,11 +16,16 @@ import { fetchChangelogReleases, type ChangelogRelease } from "../../../updater/
16
16
  import { colors } from "../../../theme/colors";
17
17
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
18
18
  import { isPlainKey } from "../../../utils/keyboard";
19
- import { usePluginPaneState } from "../../runtime";
19
+ import {
20
+ DEFAULT_CHANGELOG_SORT,
21
+ nextChangelogSortPreference,
22
+ resolveSelectedReleaseIndex,
23
+ sortChangelogReleases,
24
+ type ChangelogColumnId,
25
+ } from "./model";
20
26
 
21
27
  const CHANGELOG_LIMIT = 40;
22
28
 
23
- type ChangelogColumnId = "date" | "version" | "title";
24
29
  type ChangelogColumn = DataTableColumn & { id: ChangelogColumnId };
25
30
  type LoadStatus = "idle" | "loading" | "loaded" | "error";
26
31
 
@@ -103,7 +108,8 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
103
108
  const [releases, setReleases] = useState<ChangelogRelease[]>([]);
104
109
  const [status, setStatus] = useState<LoadStatus>("idle");
105
110
  const [error, setError] = useState<string | null>(null);
106
- const [selectedIdx, setSelectedIdx] = usePluginPaneState<number>("selectedIdx", 0);
111
+ const [selectedReleaseId, setSelectedReleaseId] = useState<string | null>(null);
112
+ const [sortPreference, setSortPreference] = useState(DEFAULT_CHANGELOG_SORT);
107
113
  const [openReleaseId, setOpenReleaseId] = useState<string | null>(null);
108
114
  const detailScrollRef = useRef<ScrollBoxRenderable>(null);
109
115
  const abortRef = useRef<AbortController | null>(null);
@@ -143,11 +149,18 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
143
149
  };
144
150
  }, [loadReleases]);
145
151
 
152
+ const sortedReleases = useMemo(
153
+ () => sortChangelogReleases(releases, sortPreference),
154
+ [releases, sortPreference],
155
+ );
156
+ const activeSelectedIdx = resolveSelectedReleaseIndex(sortedReleases, selectedReleaseId);
157
+ const activeSelectedReleaseId = sortedReleases[activeSelectedIdx]?.id ?? null;
158
+
146
159
  useEffect(() => {
147
- if (releases.length > 0 && selectedIdx >= releases.length) {
148
- setSelectedIdx(Math.max(0, releases.length - 1));
160
+ if (activeSelectedReleaseId !== selectedReleaseId) {
161
+ setSelectedReleaseId(activeSelectedReleaseId);
149
162
  }
150
- }, [releases.length, selectedIdx, setSelectedIdx]);
163
+ }, [activeSelectedReleaseId, selectedReleaseId]);
151
164
 
152
165
  const openRelease = useMemo(
153
166
  () =>
@@ -177,9 +190,6 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
177
190
  });
178
191
 
179
192
  const columns = useMemo(() => buildColumns(width, releases), [releases, width]);
180
- const activeSelectedIdx = releases.length > 0
181
- ? Math.max(0, Math.min(selectedIdx, releases.length - 1))
182
- : -1;
183
193
 
184
194
  const scrollDetailBy = useCallback((delta: number) => {
185
195
  const scrollBox = detailScrollRef.current;
@@ -237,6 +247,14 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
237
247
  }
238
248
  }, []);
239
249
 
250
+ const handleHeaderClick = useCallback((columnId: string) => {
251
+ setSortPreference((current) => nextChangelogSortPreference(current, columnId));
252
+ }, []);
253
+
254
+ const selectRelease = useCallback((release: ChangelogRelease) => {
255
+ setSelectedReleaseId(release.id);
256
+ }, []);
257
+
240
258
  const footerInfo = useMemo<PaneFooterSegment[]>(() => {
241
259
  const segments: PaneFooterSegment[] = [];
242
260
  if (status === "loading") {
@@ -251,17 +269,8 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
251
269
  parts: [{ text: "error", tone: "warning" }],
252
270
  });
253
271
  }
254
- if (releases.length > 0) {
255
- segments.push({
256
- id: "versions",
257
- parts: [
258
- { text: "versions", tone: "label" },
259
- { text: String(releases.length), tone: "value" },
260
- ],
261
- });
262
- }
263
272
  return segments;
264
- }, [releases.length, status]);
273
+ }, [status]);
265
274
 
266
275
  const footerHints = useMemo<PaneHint[]>(() => [{
267
276
  id: "refresh",
@@ -296,7 +305,7 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
296
305
  );
297
306
  }
298
307
 
299
- if (releases.length === 0) {
308
+ if (sortedReleases.length === 0) {
300
309
  return (
301
310
  <Box paddingX={1} paddingY={1}>
302
311
  <Text fg={colors.textDim}>No changelog entries found.</Text>
@@ -319,18 +328,22 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
319
328
  <Box flexGrow={1} />
320
329
  )}
321
330
  detailTitle={openRelease ? releaseDetailTitle(openRelease) : undefined}
322
- selectedIndex={activeSelectedIdx}
323
- onSelectIndex={(index) => setSelectedIdx(index)}
324
- onActivateIndex={(_index, release) => setOpenReleaseId(release.id)}
331
+ selection={{
332
+ kind: "id",
333
+ selectedId: activeSelectedReleaseId,
334
+ getId: (release) => release.id,
335
+ onChange: (_id, release) => selectRelease(release),
336
+ }}
337
+ onActivate={(release) => setOpenReleaseId(release.id)}
325
338
  onDetailKeyDown={handleDetailKeyDown}
326
339
  rootWidth={width}
327
340
  rootHeight={height}
328
341
  columns={columns}
329
- items={releases}
342
+ items={sortedReleases}
343
+ sortColumnId={sortPreference.columnId}
344
+ sortDirection={sortPreference.direction}
345
+ onHeaderClick={handleHeaderClick}
330
346
  getItemKey={(release) => release.id}
331
- isSelected={(_release, index) => index === activeSelectedIdx}
332
- onSelect={(_release, index) => setSelectedIdx(index)}
333
- onActivate={(release) => setOpenReleaseId(release.id)}
334
347
  renderCell={renderCell}
335
348
  emptyStateTitle="No changelog entries."
336
349
  showHorizontalScrollbar={false}
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ChangelogRelease } from "../../../updater/github-releases";
3
+ import {
4
+ DEFAULT_CHANGELOG_SORT,
5
+ nextChangelogSortPreference,
6
+ resolveSelectedReleaseIndex,
7
+ sortChangelogReleases,
8
+ } from "./model";
9
+
10
+ function release(
11
+ id: string,
12
+ version: string,
13
+ title: string,
14
+ publishedAt: string,
15
+ ): ChangelogRelease {
16
+ return {
17
+ id,
18
+ tagName: version,
19
+ version,
20
+ title,
21
+ publishedAt,
22
+ body: "",
23
+ url: "",
24
+ };
25
+ }
26
+
27
+ describe("changelog table model", () => {
28
+ const releases = [
29
+ release("8", "v0.8.0", "Desktop fixes", "2026-05-20T10:00:00Z"),
30
+ release("10", "v0.10.0", "Accounts", "2026-05-22T10:00:00Z"),
31
+ release("9", "v0.9.0", "Buildout", "2026-05-21T10:00:00Z"),
32
+ ];
33
+
34
+ test("sorts releases by newest date by default", () => {
35
+ expect(
36
+ sortChangelogReleases(releases, DEFAULT_CHANGELOG_SORT).map((entry) => entry.id),
37
+ )
38
+ .toEqual(["10", "9", "8"]);
39
+ });
40
+
41
+ test("sorts version tags numerically", () => {
42
+ expect(
43
+ sortChangelogReleases(
44
+ releases,
45
+ { columnId: "version", direction: "asc" },
46
+ ).map((entry) => entry.version),
47
+ )
48
+ .toEqual(["v0.8.0", "v0.9.0", "v0.10.0"]);
49
+ });
50
+
51
+ test("cycles header sort direction without returning an undefined handler state", () => {
52
+ const versionSort = nextChangelogSortPreference(DEFAULT_CHANGELOG_SORT, "version");
53
+ expect(versionSort).toEqual({ columnId: "version", direction: "desc" });
54
+ expect(nextChangelogSortPreference(versionSort, "version"))
55
+ .toEqual({ columnId: "version", direction: "asc" });
56
+ });
57
+
58
+ test("ignores unknown header ids instead of creating broken sort state", () => {
59
+ expect(nextChangelogSortPreference(DEFAULT_CHANGELOG_SORT, "missing"))
60
+ .toBe(DEFAULT_CHANGELOG_SORT);
61
+ });
62
+
63
+ test("resolves selected rows by stable release id after sorting", () => {
64
+ const sorted = sortChangelogReleases(
65
+ releases,
66
+ { columnId: "title", direction: "asc" },
67
+ );
68
+
69
+ expect(sorted.map((entry) => entry.id)).toEqual(["10", "9", "8"]);
70
+ expect(resolveSelectedReleaseIndex(sorted, "8")).toBe(2);
71
+ });
72
+
73
+ test("falls back to the first release when the selected id disappears", () => {
74
+ expect(resolveSelectedReleaseIndex(releases, "removed")).toBe(0);
75
+ expect(resolveSelectedReleaseIndex([], "removed")).toBe(-1);
76
+ });
77
+ });
@@ -0,0 +1,98 @@
1
+ import type { ChangelogRelease } from "../../../updater/github-releases";
2
+ import type { SortDirection } from "../../../utils/sort-values";
3
+
4
+ export type ChangelogColumnId = "date" | "version" | "title";
5
+
6
+ export interface ChangelogSortPreference {
7
+ columnId: ChangelogColumnId;
8
+ direction: SortDirection;
9
+ }
10
+
11
+ export const DEFAULT_CHANGELOG_SORT: ChangelogSortPreference = {
12
+ columnId: "date",
13
+ direction: "desc",
14
+ };
15
+
16
+ function isChangelogColumnId(columnId: string): columnId is ChangelogColumnId {
17
+ return columnId === "date" || columnId === "version" || columnId === "title";
18
+ }
19
+
20
+ function defaultDirection(columnId: ChangelogColumnId): SortDirection {
21
+ return columnId === "title" ? "asc" : "desc";
22
+ }
23
+
24
+ function releaseDateValue(release: ChangelogRelease): number {
25
+ const timestamp = Date.parse(release.publishedAt);
26
+ return Number.isFinite(timestamp) ? timestamp : 0;
27
+ }
28
+
29
+ function compareText(left: string, right: string): number {
30
+ return left.localeCompare(right, undefined, {
31
+ numeric: true,
32
+ sensitivity: "base",
33
+ });
34
+ }
35
+
36
+ function compareReleaseValue(
37
+ left: ChangelogRelease,
38
+ right: ChangelogRelease,
39
+ columnId: ChangelogColumnId,
40
+ ): number {
41
+ switch (columnId) {
42
+ case "date":
43
+ return releaseDateValue(left) - releaseDateValue(right);
44
+ case "version":
45
+ return compareText(left.version, right.version);
46
+ case "title":
47
+ return compareText(left.title, right.title);
48
+ }
49
+ }
50
+
51
+ export function sortChangelogReleases(
52
+ releases: ChangelogRelease[],
53
+ sortPreference: ChangelogSortPreference,
54
+ ): ChangelogRelease[] {
55
+ return releases
56
+ .map((release, index) => ({ release, index }))
57
+ .sort((left, right) => {
58
+ const comparison = compareReleaseValue(
59
+ left.release,
60
+ right.release,
61
+ sortPreference.columnId,
62
+ );
63
+ const directed = sortPreference.direction === "asc"
64
+ ? comparison
65
+ : -comparison;
66
+ return directed || left.index - right.index;
67
+ })
68
+ .map((entry) => entry.release);
69
+ }
70
+
71
+ export function nextChangelogSortPreference(
72
+ current: ChangelogSortPreference,
73
+ columnId: string,
74
+ ): ChangelogSortPreference {
75
+ if (!isChangelogColumnId(columnId)) return current;
76
+ const nextColumnId = columnId;
77
+ if (current.columnId !== nextColumnId) {
78
+ return {
79
+ columnId: nextColumnId,
80
+ direction: defaultDirection(nextColumnId),
81
+ };
82
+ }
83
+ return {
84
+ columnId: nextColumnId,
85
+ direction: current.direction === "asc" ? "desc" : "asc",
86
+ };
87
+ }
88
+
89
+ export function resolveSelectedReleaseIndex(
90
+ releases: ChangelogRelease[],
91
+ selectedReleaseId: string | null,
92
+ ): number {
93
+ const selectedIndex = selectedReleaseId
94
+ ? releases.findIndex((release) => release.id === selectedReleaseId)
95
+ : -1;
96
+ if (selectedIndex >= 0) return selectedIndex;
97
+ return releases.length > 0 ? 0 : -1;
98
+ }
@@ -0,0 +1,53 @@
1
+ import type { ChatChannel } from "../../../api-client";
2
+ import { normalizeChannelId } from "./controller/state";
3
+
4
+ function normalizeMentionUsername(username: string | null | undefined): string | null {
5
+ const normalized = username?.trim().replace(/^@+/, "");
6
+ return normalized || null;
7
+ }
8
+
9
+ function formatMentionUsername(username: string | null | undefined): string | null {
10
+ const normalized = normalizeMentionUsername(username);
11
+ return normalized ? `@${normalized}` : null;
12
+ }
13
+
14
+ function formatDirectChannelLabel(channel: ChatChannel, fallbackId: string) {
15
+ const usernameLabel = formatMentionUsername(channel.dmUser?.username);
16
+ if (usernameLabel) return usernameLabel;
17
+
18
+ const displayName = channel.dmUser?.displayName?.trim();
19
+ if (displayName) return displayName;
20
+
21
+ const channelName = channel.name?.trim();
22
+ if (channelName && channelName !== channel.id) {
23
+ return channelName.startsWith("@")
24
+ ? formatMentionUsername(channelName) ?? channelName
25
+ : channelName;
26
+ }
27
+
28
+ return fallbackId.startsWith("dm:") ? "DM" : fallbackId;
29
+ }
30
+
31
+ export function formatChannelLabel(channel: ChatChannel | undefined, fallbackId: string) {
32
+ if (!channel) return fallbackId;
33
+ if (channel.kind === "direct") {
34
+ return formatDirectChannelLabel(channel, fallbackId);
35
+ }
36
+ if (channel.kind === "group") {
37
+ return channel.name?.trim() || "Group";
38
+ }
39
+ return channel.name?.trim() || fallbackId;
40
+ }
41
+
42
+ export function formatChatPaneTitle(channel: ChatChannel | undefined, fallbackId: string) {
43
+ const normalizedFallbackId = normalizeChannelId(fallbackId);
44
+ if (channel?.kind === "direct") {
45
+ return formatChannelLabel(channel, normalizedFallbackId);
46
+ }
47
+ if (channel?.kind === "group") {
48
+ return formatChannelLabel(channel, normalizedFallbackId);
49
+ }
50
+ if (!channel && normalizedFallbackId.startsWith("dm:")) return "DM";
51
+ if (!channel && (normalizedFallbackId.startsWith("grp:") || normalizedFallbackId.startsWith("group:"))) return "Group";
52
+ return `#${formatChannelLabel(channel, normalizedFallbackId)}`;
53
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ChatChannel } from "../../../api-client";
3
+ import {
4
+ channelPrefix,
5
+ formatChannelLabel,
6
+ formatChatPaneTitle,
7
+ } from "./channels";
8
+
9
+ describe("chat channel labels", () => {
10
+ test("formats direct-message labels and titles with one mention prefix", () => {
11
+ const channel: ChatChannel = {
12
+ id: "dm:c7f89ce26f9da83d6be08a4838738074",
13
+ name: "@risto",
14
+ kind: "direct",
15
+ created_at: "2026-05-27T10:30:03.712Z",
16
+ dmUser: { id: "u2", username: "@risto", displayName: "Risto" },
17
+ };
18
+
19
+ expect(formatChannelLabel(channel, channel.id)).toBe("@risto");
20
+ expect(channelPrefix(channel, true)).toBe(" ");
21
+ expect(formatChatPaneTitle(channel, channel.id)).toBe("@risto");
22
+ expect(formatChatPaneTitle(undefined, channel.id)).toBe("DM");
23
+ });
24
+
25
+ test("uses a neutral group title before private channel metadata loads", () => {
26
+ expect(formatChatPaneTitle(undefined, "grp:93f14c6b9827c30f")).toBe("Group");
27
+ });
28
+ });