granola-toolkit 0.31.0 → 0.33.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.
Files changed (3) hide show
  1. package/README.md +6 -1
  2. package/dist/cli.js +355 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -275,10 +275,13 @@ You can deep-link into a specific meeting with either:
275
275
 
276
276
  The initial browser client includes:
277
277
 
278
+ - a dedicated folder pane with an explicit All meetings scope
278
279
  - a searchable meeting list
280
+ - folder-aware meeting browsing with one-click scope changes
279
281
  - a fast local-index warm start for meeting browsing before live documents finish loading
280
282
  - sort and updated-date filters
281
283
  - quick open by meeting id or title
284
+ - browser URL state that preserves the selected folder, meeting, and tab
282
285
  - a focused meeting workspace with notes, transcript, metadata, and raw tabs
283
286
  - keyboard-first workspace switching with `1`-`4`, `[` and `]`
284
287
  - app-state status from the shared core
@@ -316,6 +319,7 @@ That keeps the current single-package repo simple, while making a future split i
316
319
 
317
320
  The initial terminal workspace includes:
318
321
 
322
+ - a folder scope inside the navigation pane, including an explicit All meetings view
319
323
  - a meeting list pane with keyboard navigation
320
324
  - a detail pane with notes, transcript, metadata, and raw views
321
325
  - an auth session overlay for import, refresh, source switching, and sign-out
@@ -324,7 +328,8 @@ The initial terminal workspace includes:
324
328
 
325
329
  The main keyboard controls are:
326
330
 
327
- - `j` / `k` or arrow keys to move between meetings
331
+ - `h` / `l`, left / right, or `Tab` to switch between folders and meetings
332
+ - `j` / `k` or arrow keys to move within the active folder or meeting list
328
333
  - `/` or `Ctrl+P` to open quick open
329
334
  - `a` to open auth session actions
330
335
  - `1`-`4` to switch detail tabs
package/dist/cli.js CHANGED
@@ -1345,6 +1345,7 @@ function renderGranolaTuiMeetingTab(bundle, tab) {
1345
1345
  `ID: ${summary.id}`,
1346
1346
  `Created: ${summary.createdAt}`,
1347
1347
  `Updated: ${summary.updatedAt}`,
1348
+ `Folders: ${summary.folders.length > 0 ? summary.folders.map((folder) => folder.name).join(", ") : "none"}`,
1348
1349
  `Tags: ${summary.tags.length > 0 ? summary.tags.join(", ") : "none"}`,
1349
1350
  `Notes source: ${summary.noteContentSource}`,
1350
1351
  `Transcript loaded: ${summary.transcriptLoaded ? "yes" : "no"}`,
@@ -1360,7 +1361,7 @@ function renderGranolaTuiMeetingTab(bundle, tab) {
1360
1361
  }
1361
1362
  }
1362
1363
  function buildGranolaTuiSummary(state, meetingSource) {
1363
- return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | list ${meetingSource}`;
1364
+ return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.folders.loaded ? `${state.folders.count} folders` : "folders pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | list ${meetingSource}`;
1364
1365
  }
1365
1366
  //#endregion
1366
1367
  //#region src/tui/theme.ts
@@ -1657,9 +1658,13 @@ var GranolaTuiWorkspace = class {
1657
1658
  focused = false;
1658
1659
  #maxMeetings;
1659
1660
  #appState;
1661
+ #activePane = "meetings";
1660
1662
  #detailError = "";
1661
1663
  #detailScroll = 0;
1662
1664
  #detailToken = 0;
1665
+ #folderError = "";
1666
+ #folderToken = 0;
1667
+ #folders = [];
1663
1668
  #listError = "";
1664
1669
  #listToken = 0;
1665
1670
  #loadingDetail = false;
@@ -1667,6 +1672,7 @@ var GranolaTuiWorkspace = class {
1667
1672
  #meetingSource = "live";
1668
1673
  #meetings = [];
1669
1674
  #overlay;
1675
+ #selectedFolderId;
1670
1676
  #selectedMeeting;
1671
1677
  #selectedMeetingId;
1672
1678
  #statusMessage = "Loading meetings…";
@@ -1679,11 +1685,13 @@ var GranolaTuiWorkspace = class {
1679
1685
  this.options = options;
1680
1686
  this.#appState = app.getState();
1681
1687
  this.#maxMeetings = options.maxMeetings ?? 200;
1688
+ this.#selectedFolderId = this.#appState.ui.selectedFolderId;
1682
1689
  }
1683
1690
  async initialise() {
1684
1691
  this.#unsubscribe = this.app.subscribe((event) => {
1685
1692
  this.handleAppUpdate(event);
1686
1693
  });
1694
+ await this.loadFolders({ setStatus: false });
1687
1695
  await this.loadMeetings({
1688
1696
  preferredMeetingId: this.options.initialMeetingId,
1689
1697
  setStatus: true
@@ -1699,7 +1707,12 @@ var GranolaTuiWorkspace = class {
1699
1707
  handleAppUpdate(event) {
1700
1708
  const previousDocumentsLoadedAt = this.#appState.documents.loadedAt;
1701
1709
  this.#appState = event.state;
1702
- if (this.#meetingSource === "index" && event.state.documents.loadedAt && event.state.documents.loadedAt !== previousDocumentsLoadedAt && !this.#loadingMeetings) this.loadMeetings({ preferredMeetingId: this.#selectedMeetingId });
1710
+ this.#selectedFolderId = event.state.ui.selectedFolderId;
1711
+ this.#selectedMeetingId = event.state.ui.selectedMeetingId ?? this.#selectedMeetingId;
1712
+ if (this.#meetingSource === "index" && event.state.documents.loadedAt && event.state.documents.loadedAt !== previousDocumentsLoadedAt && !this.#loadingMeetings) (async () => {
1713
+ await this.loadFolders({ setStatus: false });
1714
+ await this.loadMeetings({ preferredMeetingId: this.#selectedMeetingId });
1715
+ })();
1703
1716
  this.tui.requestRender();
1704
1717
  }
1705
1718
  setStatus(message, tone = "info") {
@@ -1712,6 +1725,11 @@ var GranolaTuiWorkspace = class {
1712
1725
  const selectedIndex = this.#selectedMeetingId ? this.#meetings.findIndex((meeting) => meeting.id === this.#selectedMeetingId) : -1;
1713
1726
  return selectedIndex >= 0 ? selectedIndex : 0;
1714
1727
  }
1728
+ normaliseSelectedFolderIndex() {
1729
+ if (!this.#selectedFolderId) return 0;
1730
+ const selectedIndex = this.#folders.findIndex((folder) => folder.id === this.#selectedFolderId);
1731
+ return selectedIndex >= 0 ? selectedIndex + 1 : 0;
1732
+ }
1715
1733
  ensureMeetingVisible(meeting) {
1716
1734
  const existingIndex = this.#meetings.findIndex((item) => item.id === meeting.id);
1717
1735
  if (existingIndex >= 0) this.#meetings[existingIndex] = meeting;
@@ -1721,6 +1739,30 @@ var GranolaTuiWorkspace = class {
1721
1739
  return left.title.localeCompare(right.title);
1722
1740
  });
1723
1741
  }
1742
+ async loadFolders(options = {}) {
1743
+ const token = ++this.#folderToken;
1744
+ this.#folderError = "";
1745
+ if (options.setStatus) this.setStatus(options.forceRefresh ? "Refreshing folders…" : "Loading folders…");
1746
+ try {
1747
+ const result = await this.app.listFolders({
1748
+ forceRefresh: options.forceRefresh,
1749
+ limit: 100
1750
+ });
1751
+ if (token !== this.#folderToken) return;
1752
+ this.#folders = result.folders;
1753
+ if (this.#selectedFolderId && !this.#folders.some((folder) => folder.id === this.#selectedFolderId)) this.#selectedFolderId = void 0;
1754
+ this.#folderError = "";
1755
+ } catch (error) {
1756
+ if (token !== this.#folderToken) return;
1757
+ const message = error instanceof Error ? error.message : String(error);
1758
+ this.#folderError = message;
1759
+ this.#folders = [];
1760
+ this.#selectedFolderId = void 0;
1761
+ this.setStatus(message, "error");
1762
+ } finally {
1763
+ if (token === this.#folderToken) this.tui.requestRender();
1764
+ }
1765
+ }
1724
1766
  async loadMeetings(options = {}) {
1725
1767
  const token = ++this.#listToken;
1726
1768
  this.#loadingMeetings = true;
@@ -1728,6 +1770,7 @@ var GranolaTuiWorkspace = class {
1728
1770
  if (options.setStatus !== false) this.setStatus(options.forceRefresh ? "Refreshing meetings…" : "Loading meetings…");
1729
1771
  try {
1730
1772
  const result = await this.app.listMeetings({
1773
+ folderId: this.#selectedFolderId,
1731
1774
  forceRefresh: options.forceRefresh,
1732
1775
  limit: this.#maxMeetings,
1733
1776
  preferIndex: true
@@ -1736,8 +1779,13 @@ var GranolaTuiWorkspace = class {
1736
1779
  this.#meetings = result.meetings;
1737
1780
  this.#meetingSource = result.source;
1738
1781
  this.#selectedMeetingId = options.preferredMeetingId && this.#meetings.some((meeting) => meeting.id === options.preferredMeetingId) ? options.preferredMeetingId : this.#selectedMeetingId && this.#meetings.some((meeting) => meeting.id === this.#selectedMeetingId) ? this.#selectedMeetingId : this.#meetings[0]?.id;
1782
+ if (!this.#selectedMeetingId) {
1783
+ this.#selectedMeeting = void 0;
1784
+ this.#detailError = "";
1785
+ this.#detailScroll = 0;
1786
+ }
1739
1787
  this.#listError = "";
1740
- this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : "Connected to Granola");
1788
+ this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : this.#selectedFolderId ? "Connected to Granola (folder scope)" : "Connected to Granola");
1741
1789
  } catch (error) {
1742
1790
  if (token !== this.#listToken) return;
1743
1791
  const message = error instanceof Error ? error.message : String(error);
@@ -1780,6 +1828,10 @@ var GranolaTuiWorkspace = class {
1780
1828
  }
1781
1829
  async refresh(forceRefresh) {
1782
1830
  try {
1831
+ await this.loadFolders({
1832
+ forceRefresh,
1833
+ setStatus: false
1834
+ });
1783
1835
  await this.loadMeetings({
1784
1836
  forceRefresh,
1785
1837
  preferredMeetingId: this.#selectedMeetingId
@@ -1787,7 +1839,7 @@ var GranolaTuiWorkspace = class {
1787
1839
  if (this.#selectedMeetingId) await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
1788
1840
  } catch {}
1789
1841
  }
1790
- async moveSelection(delta) {
1842
+ async moveMeetingSelection(delta) {
1791
1843
  if (this.#meetings.length === 0) return;
1792
1844
  const currentIndex = this.normaliseSelectedIndex();
1793
1845
  const nextIndex = Math.max(0, Math.min(this.#meetings.length - 1, currentIndex + delta));
@@ -1795,6 +1847,33 @@ var GranolaTuiWorkspace = class {
1795
1847
  if (!nextMeeting || nextMeeting.id === this.#selectedMeetingId) return;
1796
1848
  await this.loadMeeting(nextMeeting.id);
1797
1849
  }
1850
+ async moveFolderSelection(delta) {
1851
+ const total = this.#folders.length + 1;
1852
+ const currentIndex = this.normaliseSelectedFolderIndex();
1853
+ const nextIndex = Math.max(0, Math.min(total - 1, currentIndex + delta));
1854
+ const nextFolderId = nextIndex === 0 ? void 0 : this.#folders[nextIndex - 1]?.id;
1855
+ if (nextFolderId === this.#selectedFolderId) return;
1856
+ this.#selectedFolderId = nextFolderId;
1857
+ this.#selectedMeeting = void 0;
1858
+ this.#detailError = "";
1859
+ this.#detailScroll = 0;
1860
+ await this.loadMeetings({
1861
+ preferredMeetingId: this.#selectedMeetingId,
1862
+ setStatus: false
1863
+ });
1864
+ if (this.#selectedMeetingId) {
1865
+ await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
1866
+ return;
1867
+ }
1868
+ this.tui.requestRender();
1869
+ }
1870
+ async moveSelection(delta) {
1871
+ if (this.#activePane === "folders") {
1872
+ await this.moveFolderSelection(delta);
1873
+ return;
1874
+ }
1875
+ await this.moveMeetingSelection(delta);
1876
+ }
1798
1877
  currentDetailBody(width) {
1799
1878
  if (this.#detailError) return wrapBlock(this.#detailError, width);
1800
1879
  if (this.#loadingDetail && !this.#selectedMeeting) return wrapBlock("Loading meeting details…", width);
@@ -1832,6 +1911,10 @@ var GranolaTuiWorkspace = class {
1832
1911
  async reloadAfterAuthChange() {
1833
1912
  const preferredMeetingId = this.#selectedMeeting?.document.id ?? this.#selectedMeetingId;
1834
1913
  try {
1914
+ await this.loadFolders({
1915
+ forceRefresh: true,
1916
+ setStatus: false
1917
+ });
1835
1918
  await this.loadMeetings({
1836
1919
  forceRefresh: true,
1837
1920
  preferredMeetingId,
@@ -1956,6 +2039,21 @@ var GranolaTuiWorkspace = class {
1956
2039
  this.openAuthPanel();
1957
2040
  return;
1958
2041
  }
2042
+ if (matchesKey(data, "tab")) {
2043
+ this.#activePane = this.#activePane === "folders" ? "meetings" : "folders";
2044
+ this.tui.requestRender();
2045
+ return;
2046
+ }
2047
+ if (matchesKey(data, "left") || matchesKey(data, "h")) {
2048
+ this.#activePane = "folders";
2049
+ this.tui.requestRender();
2050
+ return;
2051
+ }
2052
+ if (matchesKey(data, "right") || matchesKey(data, "l")) {
2053
+ this.#activePane = "meetings";
2054
+ this.tui.requestRender();
2055
+ return;
2056
+ }
1959
2057
  if (matchesKey(data, "up") || matchesKey(data, "k")) {
1960
2058
  this.moveSelection(-1);
1961
2059
  return;
@@ -2021,22 +2119,53 @@ var GranolaTuiWorkspace = class {
2021
2119
  renderListPane(width, height) {
2022
2120
  const lines = [];
2023
2121
  const innerWidth = Math.max(1, width - 2);
2024
- const header = `${granolaTuiTheme.strong("Meetings")} ${granolaTuiTheme.dim(`(${this.#meetings.length})`)}`;
2025
- lines.push(padLine(header, innerWidth));
2122
+ const folderEntries = [{
2123
+ id: void 0,
2124
+ label: "All meetings",
2125
+ meta: this.#folders.length > 0 ? `${this.#folders.length} folders` : "global scope"
2126
+ }, ...this.#folders.map((folder) => ({
2127
+ id: folder.id,
2128
+ label: `${folder.isFavourite ? "★ " : ""}${folder.name || folder.id}`,
2129
+ meta: `${folder.documentCount} meetings`
2130
+ }))];
2131
+ const availableRows = Math.max(2, height - 3);
2132
+ const folderWindowSize = Math.min(Math.max(3, Math.min(8, Math.floor(availableRows * .35))), Math.max(1, availableRows - 1));
2133
+ const meetingWindowSize = Math.max(1, availableRows - folderWindowSize);
2134
+ const folderHeader = `${this.#activePane === "folders" ? granolaTuiTheme.accent("Folders") : granolaTuiTheme.strong("Folders")} ${granolaTuiTheme.dim(`(${this.#folders.length})`)}`;
2135
+ lines.push(padLine(folderHeader, innerWidth));
2136
+ if (this.#folderError) {
2137
+ lines.push(...wrapBlock(granolaTuiTheme.error(this.#folderError), innerWidth).slice(0, folderWindowSize));
2138
+ while (lines.length < 1 + folderWindowSize) lines.push(" ".repeat(innerWidth));
2139
+ } else {
2140
+ const selectedFolderIndex = this.normaliseSelectedFolderIndex();
2141
+ const folderStartIndex = Math.max(0, Math.min(selectedFolderIndex - Math.floor(folderWindowSize / 2), folderEntries.length - folderWindowSize));
2142
+ const visibleFolders = folderEntries.slice(folderStartIndex, folderStartIndex + folderWindowSize);
2143
+ for (const [offset, folder] of visibleFolders.entries()) {
2144
+ const selected = folderStartIndex + offset === selectedFolderIndex;
2145
+ const prefix = selected ? "> " : " ";
2146
+ const maxLabelWidth = Math.max(6, innerWidth - visibleWidth(prefix) - visibleWidth(folder.meta) - 1);
2147
+ const labelBlock = `${prefix}${truncateToWidth(folder.label, maxLabelWidth, "")}`;
2148
+ const line = `${labelBlock}${" ".repeat(Math.max(1, innerWidth - visibleWidth(labelBlock) - visibleWidth(folder.meta)))}${granolaTuiTheme.dim(folder.meta)}`;
2149
+ lines.push(selected ? padLine(granolaTuiTheme.selected(line), innerWidth) : padLine(line, innerWidth));
2150
+ }
2151
+ while (lines.length < 1 + folderWindowSize) lines.push(" ".repeat(innerWidth));
2152
+ }
2153
+ lines.push(padLine(granolaTuiTheme.dim(""), innerWidth));
2154
+ const meetingsHeader = `${this.#activePane === "meetings" ? granolaTuiTheme.accent("Meetings") : granolaTuiTheme.strong("Meetings")} ${granolaTuiTheme.dim(`(${this.#meetings.length})`)}`;
2155
+ lines.push(padLine(meetingsHeader, innerWidth));
2026
2156
  if (this.#listError) {
2027
- lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0, height - 1));
2157
+ lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0, meetingWindowSize));
2028
2158
  while (lines.length < height) lines.push(" ".repeat(innerWidth));
2029
2159
  return lines;
2030
2160
  }
2031
2161
  if (this.#meetings.length === 0) {
2032
- lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0, height - 1));
2162
+ lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0, meetingWindowSize));
2033
2163
  while (lines.length < height) lines.push(" ".repeat(innerWidth));
2034
2164
  return lines;
2035
2165
  }
2036
2166
  const selectedIndex = this.normaliseSelectedIndex();
2037
- const windowSize = Math.max(1, height - 1);
2038
- const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(windowSize / 2), this.#meetings.length - windowSize));
2039
- const visibleMeetings = this.#meetings.slice(startIndex, startIndex + windowSize);
2167
+ const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(meetingWindowSize / 2), this.#meetings.length - meetingWindowSize));
2168
+ const visibleMeetings = this.#meetings.slice(startIndex, startIndex + meetingWindowSize);
2040
2169
  for (const [offset, meeting] of visibleMeetings.entries()) {
2041
2170
  const selected = startIndex + offset === selectedIndex;
2042
2171
  const dateLabel = meeting.updatedAt.slice(0, 10);
@@ -2094,7 +2223,7 @@ var GranolaTuiWorkspace = class {
2094
2223
  const bodyLines = [];
2095
2224
  for (let index = 0; index < bodyHeight; index += 1) bodyLines.push(`${padLine(listLines[index] ?? "", listWidth)} | ${padLine(detailLines[index] ?? "", detailWidth)}`);
2096
2225
  const footerStatus = padLine(toneText(this.#statusTone, this.#statusMessage), width);
2097
- const footerHints = padLine(granolaTuiTheme.dim("/ quick open a auth r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
2226
+ const footerHints = padLine(granolaTuiTheme.dim("h/l or Tab pane j/k move / quick open a auth r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
2098
2227
  return [
2099
2228
  headerTitle,
2100
2229
  headerSummary,
@@ -4204,10 +4333,13 @@ const workspaceTabs = ["notes", "transcript", "metadata", "raw"];
4204
4333
  const state = {
4205
4334
  appState: null,
4206
4335
  detailError: "",
4336
+ folderError: "",
4337
+ folders: [],
4207
4338
  listError: "",
4208
4339
  meetings: [],
4209
4340
  quickOpen: "",
4210
4341
  search: "",
4342
+ selectedFolderId: null,
4211
4343
  selectedMeeting: null,
4212
4344
  selectedMeetingBundle: null,
4213
4345
  selectedMeetingId: null,
@@ -4225,6 +4357,7 @@ const els = {
4225
4357
  detailBody: document.querySelector("[data-detail-body]"),
4226
4358
  detailMeta: document.querySelector("[data-detail-meta]"),
4227
4359
  empty: document.querySelector("[data-empty]"),
4360
+ folderList: document.querySelector("[data-folder-list]"),
4228
4361
  jobsList: document.querySelector("[data-jobs-list]"),
4229
4362
  list: document.querySelector("[data-meeting-list]"),
4230
4363
  noteButton: document.querySelector("[data-export-notes]"),
@@ -4251,6 +4384,7 @@ function parseWorkspaceTab(value) {
4251
4384
  function startupSelection() {
4252
4385
  const params = new URLSearchParams(window.location.search);
4253
4386
  return {
4387
+ folderId: params.get("folder")?.trim() || "",
4254
4388
  meetingId: params.get("meeting")?.trim() || "",
4255
4389
  workspaceTab: parseWorkspaceTab(params.get("tab")),
4256
4390
  };
@@ -4259,6 +4393,12 @@ function startupSelection() {
4259
4393
  function syncBrowserUrl() {
4260
4394
  const url = new URL(window.location.href);
4261
4395
 
4396
+ if (state.selectedFolderId) {
4397
+ url.searchParams.set("folder", state.selectedFolderId);
4398
+ } else {
4399
+ url.searchParams.delete("folder");
4400
+ }
4401
+
4262
4402
  if (state.selectedMeetingId) {
4263
4403
  url.searchParams.set("meeting", state.selectedMeetingId);
4264
4404
  } else {
@@ -4302,6 +4442,11 @@ function syncFilterInputs() {
4302
4442
  function currentFilterSummary() {
4303
4443
  const parts = [];
4304
4444
 
4445
+ if (state.selectedFolderId) {
4446
+ const folder = state.folders.find((candidate) => candidate.id === state.selectedFolderId);
4447
+ parts.push("folder " + (folder ? '"' + folder.name + '"' : '"' + state.selectedFolderId + '"'));
4448
+ }
4449
+
4305
4450
  if (state.search) {
4306
4451
  parts.push('search "' + state.search + '"');
4307
4452
  }
@@ -4344,6 +4489,9 @@ function renderAppState() {
4344
4489
  : appState.index.available
4345
4490
  ? "available"
4346
4491
  : "not built";
4492
+ const folderStatus = appState.folders.loaded
4493
+ ? appState.folders.count + " folders"
4494
+ : "not loaded";
4347
4495
 
4348
4496
  els.appState.innerHTML = [
4349
4497
  '<div class="status-grid">',
@@ -4351,6 +4499,7 @@ function renderAppState() {
4351
4499
  '<div><span class="status-label">View</span><strong>' + escapeHtml(appState.ui.view) + "</strong></div>",
4352
4500
  '<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
4353
4501
  '<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
4502
+ '<div><span class="status-label">Folders</span><strong>' + escapeHtml(folderStatus) + "</strong></div>",
4354
4503
  '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
4355
4504
  '<div><span class="status-label">Index</span><strong>' + escapeHtml(indexStatus) + "</strong></div>",
4356
4505
  "</div>",
@@ -4361,6 +4510,50 @@ function renderAppState() {
4361
4510
  renderExportJobs();
4362
4511
  }
4363
4512
 
4513
+ function renderFolderList() {
4514
+ if (state.folderError) {
4515
+ els.folderList.innerHTML =
4516
+ '<div class="folder-empty folder-empty--error">' + escapeHtml(state.folderError) + "</div>";
4517
+ return;
4518
+ }
4519
+
4520
+ const buttons = [
4521
+ [
4522
+ '<button class="folder-row"' +
4523
+ (state.selectedFolderId ? "" : ' data-selected="true"') +
4524
+ ' data-folder-id="">',
4525
+ '<span class="folder-row__title">All meetings</span>',
4526
+ '<span class="folder-row__meta">Browse the full meeting list.</span>',
4527
+ "</button>",
4528
+ ].join(""),
4529
+ ];
4530
+
4531
+ for (const folder of state.folders) {
4532
+ buttons.push(
4533
+ [
4534
+ '<button class="folder-row"' +
4535
+ (folder.id === state.selectedFolderId ? ' data-selected="true"' : "") +
4536
+ ' data-folder-id="' +
4537
+ escapeHtml(folder.id) +
4538
+ '">',
4539
+ '<span class="folder-row__title">' +
4540
+ escapeHtml((folder.isFavourite ? "★ " : "") + (folder.name || folder.id)) +
4541
+ "</span>",
4542
+ '<span class="folder-row__meta">' +
4543
+ escapeHtml(String(folder.documentCount) + " meetings") +
4544
+ "</span>",
4545
+ "</button>",
4546
+ ].join(""),
4547
+ );
4548
+ }
4549
+
4550
+ if (buttons.length === 1) {
4551
+ buttons.push('<div class="folder-empty">No folders found.</div>');
4552
+ }
4553
+
4554
+ els.folderList.innerHTML = buttons.join("");
4555
+ }
4556
+
4364
4557
  function renderSecurityPanel() {
4365
4558
  els.securityPanel.hidden = !state.serverLocked;
4366
4559
  }
@@ -4504,6 +4697,7 @@ function renderMeetingDetail() {
4504
4697
  "Title: " + (record.meeting.title || record.meeting.id),
4505
4698
  "Created: " + record.meeting.createdAt,
4506
4699
  "Updated: " + record.meeting.updatedAt,
4700
+ "Folders: " + (record.meeting.folders.length ? record.meeting.folders.map((folder) => folder.name).join(", ") : "none"),
4507
4701
  "Tags: " + (record.meeting.tags.length ? record.meeting.tags.join(", ") : "none"),
4508
4702
  "Transcript loaded: " + (record.meeting.transcriptLoaded ? "yes" : "no"),
4509
4703
  ].join("\n");
@@ -4613,6 +4807,10 @@ function buildMeetingsQuery(limit = 100, refresh = false) {
4613
4807
  params.set("updatedTo", state.updatedTo);
4614
4808
  }
4615
4809
 
4810
+ if (state.selectedFolderId) {
4811
+ params.set("folderId", state.selectedFolderId);
4812
+ }
4813
+
4616
4814
  if (refresh) {
4617
4815
  params.set("refresh", "true");
4618
4816
  }
@@ -4620,6 +4818,39 @@ function buildMeetingsQuery(limit = 100, refresh = false) {
4620
4818
  return "?" + params.toString();
4621
4819
  }
4622
4820
 
4821
+ async function loadFolders(options = {}) {
4822
+ const refresh = options.refresh === true;
4823
+
4824
+ try {
4825
+ state.folderError = "";
4826
+ const params = new URLSearchParams();
4827
+ params.set("limit", "100");
4828
+ if (refresh) {
4829
+ params.set("refresh", "true");
4830
+ }
4831
+
4832
+ const payload = await fetchJson("/folders?" + params.toString());
4833
+ state.folders = payload.folders || [];
4834
+ if (
4835
+ state.selectedFolderId &&
4836
+ !state.folders.some((folder) => folder.id === state.selectedFolderId)
4837
+ ) {
4838
+ state.selectedFolderId = null;
4839
+ }
4840
+ } catch (error) {
4841
+ if (error.authRequired) {
4842
+ throw error;
4843
+ }
4844
+
4845
+ state.folderError = error instanceof Error ? error.message : String(error);
4846
+ state.folders = [];
4847
+ state.selectedFolderId = null;
4848
+ }
4849
+
4850
+ renderFolderList();
4851
+ syncBrowserUrl();
4852
+ }
4853
+
4623
4854
  async function loadMeetings(options = {}) {
4624
4855
  const preferredMeetingId = options.preferredMeetingId || state.selectedMeetingId;
4625
4856
  const refresh = options.refresh === true;
@@ -4683,10 +4914,12 @@ async function quickOpenMeeting() {
4683
4914
  try {
4684
4915
  state.quickOpen = query;
4685
4916
  const payload = await fetchJson("/meetings/resolve?q=" + encodeURIComponent(query));
4917
+ state.selectedFolderId = payload.meeting?.meeting?.folders?.[0]?.id || null;
4686
4918
  state.search = "";
4687
4919
  state.updatedFrom = "";
4688
4920
  state.updatedTo = "";
4689
4921
  syncFilterInputs();
4922
+ renderFolderList();
4690
4923
  await loadMeetings({
4691
4924
  preferredMeetingId: payload.document.id,
4692
4925
  });
@@ -4701,11 +4934,9 @@ async function quickOpenMeeting() {
4701
4934
  async function refreshAll(forceLiveMeetings = false) {
4702
4935
  setStatus("Refreshing…", "busy");
4703
4936
  try {
4704
- const [appState, authState] = await Promise.all([
4705
- fetchJson("/state"),
4706
- fetchJson("/auth/status"),
4707
- loadMeetings({ refresh: forceLiveMeetings }),
4708
- ]);
4937
+ await loadFolders({ refresh: forceLiveMeetings });
4938
+ const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status")]);
4939
+ await loadMeetings({ refresh: forceLiveMeetings });
4709
4940
  state.serverLocked = false;
4710
4941
  state.appState = {
4711
4942
  ...appState,
@@ -4856,17 +5087,40 @@ async function lockServer() {
4856
5087
 
4857
5088
  state.serverLocked = true;
4858
5089
  state.appState = null;
5090
+ state.folders = [];
4859
5091
  state.meetings = [];
5092
+ state.selectedFolderId = null;
4860
5093
  state.selectedMeeting = null;
4861
5094
  state.selectedMeetingBundle = null;
4862
5095
  state.detailError = "";
5096
+ state.folderError = "";
4863
5097
  els.serverPassword.value = "";
4864
5098
  renderSecurityPanel();
5099
+ renderFolderList();
4865
5100
  renderMeetingList();
4866
5101
  renderMeetingDetail();
4867
5102
  setStatus("Server locked", "error");
4868
5103
  }
4869
5104
 
5105
+ els.folderList.addEventListener("click", (event) => {
5106
+ if (!(event.target instanceof Element)) {
5107
+ return;
5108
+ }
5109
+
5110
+ const button = event.target.closest("[data-folder-id]");
5111
+ if (!button) {
5112
+ return;
5113
+ }
5114
+
5115
+ const nextFolderId = button.dataset.folderId || null;
5116
+ state.selectedFolderId = nextFolderId;
5117
+ state.selectedMeetingId = null;
5118
+ state.selectedMeeting = null;
5119
+ state.selectedMeetingBundle = null;
5120
+ renderFolderList();
5121
+ void loadMeetings();
5122
+ });
5123
+
4870
5124
  els.list.addEventListener("click", (event) => {
4871
5125
  if (!(event.target instanceof Element)) {
4872
5126
  return;
@@ -5071,6 +5325,7 @@ document.addEventListener("keydown", (event) => {
5071
5325
  });
5072
5326
 
5073
5327
  const initialSelection = startupSelection();
5328
+ state.selectedFolderId = initialSelection.folderId || null;
5074
5329
  state.selectedMeetingId = initialSelection.meetingId || null;
5075
5330
  state.workspaceTab = initialSelection.workspaceTab;
5076
5331
 
@@ -5086,9 +5341,12 @@ events.addEventListener("state.updated", (event) => {
5086
5341
  payload.state.documents?.loadedAt &&
5087
5342
  payload.state.documents.loadedAt !== previousLoadedAt
5088
5343
  ) {
5089
- void loadMeetings({
5090
- preferredMeetingId: state.selectedMeetingId,
5091
- });
5344
+ void (async () => {
5345
+ await loadFolders();
5346
+ await loadMeetings({
5347
+ preferredMeetingId: state.selectedMeetingId,
5348
+ });
5349
+ })();
5092
5350
  }
5093
5351
  });
5094
5352
  events.addEventListener("error", () => {
@@ -5097,6 +5355,7 @@ events.addEventListener("error", () => {
5097
5355
 
5098
5356
  syncFilterInputs();
5099
5357
  renderSecurityPanel();
5358
+ renderFolderList();
5100
5359
 
5101
5360
  void refreshAll().catch((error) => {
5102
5361
  setStatus("Error", "error");
@@ -5111,7 +5370,7 @@ const granolaWebMarkup = String.raw`
5111
5370
  <aside class="pane sidebar">
5112
5371
  <section class="hero">
5113
5372
  <h1>Granola Toolkit</h1>
5114
- <p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
5373
+ <p>Browser workspace for folders, meetings, notes, transcripts, and export flows on top of one local server instance.</p>
5115
5374
  <input class="search" data-search placeholder="Search meetings, ids, or tags" />
5116
5375
  <div class="field-row field-row--inline">
5117
5376
  <label>
@@ -5133,6 +5392,13 @@ const granolaWebMarkup = String.raw`
5133
5392
  <input class="field-input" data-updated-to type="date" />
5134
5393
  </label>
5135
5394
  </section>
5395
+ <section class="folder-panel">
5396
+ <div class="folder-panel__head">
5397
+ <h2>Folders</h2>
5398
+ <p>Pick a folder to scope the meeting browser, or stay on All meetings.</p>
5399
+ </div>
5400
+ <div class="folder-list" data-folder-list></div>
5401
+ </section>
5136
5402
  <section class="toolbar">
5137
5403
  <div>
5138
5404
  <p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
@@ -5254,11 +5520,11 @@ body {
5254
5520
 
5255
5521
  .sidebar {
5256
5522
  display: grid;
5257
- grid-template-rows: auto auto 1fr;
5523
+ grid-template-rows: auto auto auto 1fr;
5258
5524
  overflow: hidden;
5259
5525
  }
5260
5526
 
5261
- .hero, .toolbar, .detail-head {
5527
+ .hero, .toolbar, .detail-head, .folder-panel {
5262
5528
  padding: 22px 24px;
5263
5529
  border-bottom: 1px solid var(--line);
5264
5530
  }
@@ -5315,6 +5581,68 @@ body {
5315
5581
  overflow: auto;
5316
5582
  }
5317
5583
 
5584
+ .folder-panel {
5585
+ display: grid;
5586
+ gap: 14px;
5587
+ }
5588
+
5589
+ .folder-panel__head h2 {
5590
+ margin: 0;
5591
+ font-size: 0.92rem;
5592
+ letter-spacing: 0.08em;
5593
+ text-transform: uppercase;
5594
+ }
5595
+
5596
+ .folder-panel__head p {
5597
+ margin: 6px 0 0;
5598
+ color: var(--muted);
5599
+ font-size: 0.9rem;
5600
+ }
5601
+
5602
+ .folder-list {
5603
+ display: grid;
5604
+ gap: 10px;
5605
+ }
5606
+
5607
+ .folder-row {
5608
+ width: 100%;
5609
+ display: grid;
5610
+ gap: 4px;
5611
+ text-align: left;
5612
+ padding: 12px 14px;
5613
+ border: 1px solid transparent;
5614
+ border-radius: 16px;
5615
+ background: rgba(255, 255, 255, 0.72);
5616
+ color: inherit;
5617
+ cursor: pointer;
5618
+ transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
5619
+ }
5620
+
5621
+ .folder-row:hover,
5622
+ .folder-row[data-selected="true"] {
5623
+ transform: translateY(-1px);
5624
+ border-color: rgba(163, 79, 47, 0.26);
5625
+ background: var(--panel-strong);
5626
+ }
5627
+
5628
+ .folder-row__title {
5629
+ font-weight: 700;
5630
+ }
5631
+
5632
+ .folder-row__meta {
5633
+ color: var(--muted);
5634
+ font-size: 0.88rem;
5635
+ }
5636
+
5637
+ .folder-empty {
5638
+ color: var(--muted);
5639
+ font-size: 0.92rem;
5640
+ }
5641
+
5642
+ .folder-empty--error {
5643
+ color: var(--error);
5644
+ }
5645
+
5318
5646
  .meeting-row {
5319
5647
  width: 100%;
5320
5648
  display: grid;
@@ -6174,7 +6502,11 @@ function printWebRoutes() {
6174
6502
  console.log(" GET /auth/status");
6175
6503
  console.log(" GET /state");
6176
6504
  console.log(" GET /events");
6505
+ console.log(" GET /folders");
6506
+ console.log(" GET /folders/resolve?q=<query>");
6507
+ console.log(" GET /folders/:id");
6177
6508
  console.log(" GET /meetings");
6509
+ console.log(" GET /meetings?folderId=<id>");
6178
6510
  console.log(" GET /meetings/:id");
6179
6511
  console.log(" GET /exports/jobs");
6180
6512
  console.log(" POST /auth/login");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",