granola-toolkit 0.20.0 → 0.21.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 +2 -1
  2. package/dist/cli.js +161 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -208,7 +208,8 @@ The initial browser client includes:
208
208
  - a searchable meeting list
209
209
  - sort and updated-date filters
210
210
  - quick open by meeting id or title
211
- - a meeting detail view with notes and transcript panes
211
+ - a focused meeting workspace with notes, transcript, metadata, and raw tabs
212
+ - keyboard-first workspace switching with `1`-`4`, `[` and `]`
212
213
  - app-state status from the shared core
213
214
  - note and transcript export actions backed by the same local API
214
215
  - stronger empty and error states for list/detail failures
package/dist/cli.js CHANGED
@@ -2346,6 +2346,37 @@ function renderGranolaWebPage() {
2346
2346
  width: min(440px, 100%);
2347
2347
  }
2348
2348
 
2349
+ .workspace-tabs {
2350
+ display: flex;
2351
+ flex-wrap: wrap;
2352
+ align-items: center;
2353
+ gap: 10px;
2354
+ padding: 0 24px 18px;
2355
+ }
2356
+
2357
+ .workspace-tab {
2358
+ border: 1px solid var(--line);
2359
+ border-radius: 999px;
2360
+ padding: 10px 14px;
2361
+ background: rgba(255, 255, 255, 0.72);
2362
+ color: var(--muted);
2363
+ cursor: pointer;
2364
+ font: inherit;
2365
+ font-weight: 700;
2366
+ }
2367
+
2368
+ .workspace-tab[data-selected="true"] {
2369
+ background: var(--ink);
2370
+ color: white;
2371
+ border-color: var(--ink);
2372
+ }
2373
+
2374
+ .workspace-hint {
2375
+ color: var(--muted);
2376
+ font-size: 0.86rem;
2377
+ margin-left: auto;
2378
+ }
2379
+
2349
2380
  .button {
2350
2381
  border: 0;
2351
2382
  border-radius: 999px;
@@ -2402,6 +2433,17 @@ function renderGranolaWebPage() {
2402
2433
  overflow: auto;
2403
2434
  }
2404
2435
 
2436
+ .workspace-grid {
2437
+ display: grid;
2438
+ grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
2439
+ gap: 18px;
2440
+ }
2441
+
2442
+ .workspace-sidebar,
2443
+ .workspace-main {
2444
+ margin-bottom: 0;
2445
+ }
2446
+
2405
2447
  .detail-section {
2406
2448
  margin-bottom: 20px;
2407
2449
  padding: 20px;
@@ -2440,9 +2482,14 @@ function renderGranolaWebPage() {
2440
2482
  }
2441
2483
 
2442
2484
  .field-row--inline,
2443
- .toolbar-form {
2485
+ .toolbar-form,
2486
+ .workspace-grid {
2444
2487
  grid-template-columns: 1fr;
2445
2488
  }
2489
+
2490
+ .workspace-hint {
2491
+ margin-left: 0;
2492
+ }
2446
2493
  }
2447
2494
  </style>
2448
2495
  </head>
@@ -2500,6 +2547,13 @@ function renderGranolaWebPage() {
2500
2547
  </div>
2501
2548
  <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
2502
2549
  </section>
2550
+ <nav class="workspace-tabs">
2551
+ <button class="workspace-tab" data-workspace-tab="notes">Notes</button>
2552
+ <button class="workspace-tab" data-workspace-tab="transcript">Transcript</button>
2553
+ <button class="workspace-tab" data-workspace-tab="metadata">Metadata</button>
2554
+ <button class="workspace-tab" data-workspace-tab="raw">Raw</button>
2555
+ <span class="workspace-hint">1-4 switch tabs, [ and ] cycle</span>
2556
+ </nav>
2503
2557
  <div class="detail-meta" data-detail-meta></div>
2504
2558
  <div class="detail-body" data-detail-body>
2505
2559
  <div class="empty" data-empty>Select a meeting to inspect its notes and transcript.</div>
@@ -2516,10 +2570,12 @@ const state = {
2516
2570
  quickOpen: "",
2517
2571
  search: "",
2518
2572
  selectedMeeting: null,
2573
+ selectedMeetingBundle: null,
2519
2574
  selectedMeetingId: null,
2520
2575
  sort: "updated-desc",
2521
2576
  updatedFrom: "",
2522
2577
  updatedTo: "",
2578
+ workspaceTab: "notes",
2523
2579
  };
2524
2580
 
2525
2581
  const els = {
@@ -2538,6 +2594,7 @@ const els = {
2538
2594
  transcriptButton: document.querySelector("[data-export-transcripts]"),
2539
2595
  updatedFrom: document.querySelector("[data-updated-from]"),
2540
2596
  updatedTo: document.querySelector("[data-updated-to]"),
2597
+ workspaceTabs: document.querySelectorAll("[data-workspace-tab]"),
2541
2598
  };
2542
2599
 
2543
2600
  function escapeHtml(value) {
@@ -2579,6 +2636,12 @@ function currentFilterSummary() {
2579
2636
  return parts.join(", ");
2580
2637
  }
2581
2638
 
2639
+ function renderWorkspaceTabs() {
2640
+ for (const button of els.workspaceTabs) {
2641
+ button.dataset.selected = button.dataset.workspaceTab === state.workspaceTab ? "true" : "false";
2642
+ }
2643
+ }
2644
+
2582
2645
  function renderAppState() {
2583
2646
  if (!state.appState) {
2584
2647
  els.appState.innerHTML = "<p>Waiting for server state…</p>";
@@ -2615,6 +2678,7 @@ function renderMeetingList() {
2615
2678
  if (state.meetings.length === 0) {
2616
2679
  state.selectedMeetingId = null;
2617
2680
  state.selectedMeeting = null;
2681
+ state.selectedMeetingBundle = null;
2618
2682
  const filterSummary = currentFilterSummary();
2619
2683
  const message = filterSummary
2620
2684
  ? "No meetings match " + filterSummary + "."
@@ -2645,6 +2709,8 @@ function renderMeetingList() {
2645
2709
  }
2646
2710
 
2647
2711
  function renderMeetingDetail() {
2712
+ renderWorkspaceTabs();
2713
+
2648
2714
  if (state.detailError) {
2649
2715
  els.empty.hidden = false;
2650
2716
  els.empty.textContent = state.detailError;
@@ -2669,15 +2735,46 @@ function renderMeetingDetail() {
2669
2735
  '<div class="detail-chip">Transcript: ' + escapeHtml(String(record.meeting.transcriptSegmentCount)) + " segments</div>",
2670
2736
  ].join("");
2671
2737
 
2738
+ const bundle = state.selectedMeetingBundle;
2739
+ const metadataLines = [
2740
+ "Title: " + (record.meeting.title || record.meeting.id),
2741
+ "Created: " + record.meeting.createdAt,
2742
+ "Updated: " + record.meeting.updatedAt,
2743
+ "Tags: " + (record.meeting.tags.length ? record.meeting.tags.join(", ") : "none"),
2744
+ "Transcript loaded: " + (record.meeting.transcriptLoaded ? "yes" : "no"),
2745
+ ].join("\n");
2746
+
2747
+ let mainTitle = "Notes";
2748
+ let mainBody = record.noteMarkdown || "";
2749
+
2750
+ switch (state.workspaceTab) {
2751
+ case "transcript":
2752
+ mainTitle = "Transcript";
2753
+ mainBody = record.transcriptText || "(Transcript unavailable)";
2754
+ break;
2755
+ case "metadata":
2756
+ mainTitle = "Metadata";
2757
+ mainBody = metadataLines;
2758
+ break;
2759
+ case "raw":
2760
+ mainTitle = "Raw Bundle";
2761
+ mainBody = JSON.stringify(bundle || record, null, 2);
2762
+ break;
2763
+ default:
2764
+ break;
2765
+ }
2766
+
2672
2767
  els.detailBody.innerHTML = [
2673
- '<section class="detail-section">',
2674
- "<h2>Notes</h2>",
2675
- '<pre class="detail-pre">' + escapeHtml(record.noteMarkdown || "") + "</pre>",
2676
- "</section>",
2677
- '<section class="detail-section">',
2678
- "<h2>Transcript</h2>",
2679
- '<pre class="detail-pre">' + escapeHtml(record.transcriptText || "(Transcript unavailable)") + "</pre>",
2768
+ '<div class="workspace-grid">',
2769
+ '<aside class="detail-section workspace-sidebar">',
2770
+ "<h2>Meeting Metadata</h2>",
2771
+ '<pre class="detail-pre">' + escapeHtml(metadataLines) + "</pre>",
2772
+ "</aside>",
2773
+ '<section class="detail-section workspace-main">',
2774
+ "<h2>" + escapeHtml(mainTitle) + "</h2>",
2775
+ '<pre class="detail-pre">' + escapeHtml(mainBody) + "</pre>",
2680
2776
  "</section>",
2777
+ "</div>",
2681
2778
  ].join("");
2682
2779
  }
2683
2780
 
@@ -2733,6 +2830,7 @@ async function loadMeetings(options = {}) {
2733
2830
  } catch (error) {
2734
2831
  state.listError = error instanceof Error ? error.message : String(error);
2735
2832
  state.selectedMeeting = null;
2833
+ state.selectedMeetingBundle = null;
2736
2834
  state.detailError = state.listError;
2737
2835
  renderMeetingList();
2738
2836
  renderMeetingDetail();
@@ -2746,10 +2844,12 @@ async function loadMeeting(id) {
2746
2844
  try {
2747
2845
  state.detailError = "";
2748
2846
  const payload = await fetchJson("/meetings/" + encodeURIComponent(id));
2847
+ state.selectedMeetingBundle = payload;
2749
2848
  state.selectedMeeting = payload.meeting || null;
2750
2849
  renderMeetingDetail();
2751
2850
  } catch (error) {
2752
2851
  state.selectedMeeting = null;
2852
+ state.selectedMeetingBundle = null;
2753
2853
  state.detailError = error instanceof Error ? error.message : String(error);
2754
2854
  renderMeetingDetail();
2755
2855
  }
@@ -2891,6 +2991,59 @@ els.quickOpenButton.addEventListener("click", () => {
2891
2991
  void quickOpenMeeting();
2892
2992
  });
2893
2993
 
2994
+ els.workspaceTabs.forEach((button) => {
2995
+ button.addEventListener("click", () => {
2996
+ state.workspaceTab = button.dataset.workspaceTab || "notes";
2997
+ renderMeetingDetail();
2998
+ });
2999
+ });
3000
+
3001
+ document.addEventListener("keydown", (event) => {
3002
+ if (
3003
+ event.target instanceof HTMLInputElement ||
3004
+ event.target instanceof HTMLSelectElement ||
3005
+ event.target instanceof HTMLTextAreaElement
3006
+ ) {
3007
+ return;
3008
+ }
3009
+
3010
+ const tabs = ["notes", "transcript", "metadata", "raw"];
3011
+ if (event.key === "1") {
3012
+ state.workspaceTab = "notes";
3013
+ renderMeetingDetail();
3014
+ return;
3015
+ }
3016
+
3017
+ if (event.key === "2") {
3018
+ state.workspaceTab = "transcript";
3019
+ renderMeetingDetail();
3020
+ return;
3021
+ }
3022
+
3023
+ if (event.key === "3") {
3024
+ state.workspaceTab = "metadata";
3025
+ renderMeetingDetail();
3026
+ return;
3027
+ }
3028
+
3029
+ if (event.key === "4") {
3030
+ state.workspaceTab = "raw";
3031
+ renderMeetingDetail();
3032
+ return;
3033
+ }
3034
+
3035
+ const currentIndex = tabs.indexOf(state.workspaceTab);
3036
+ if (event.key === "]") {
3037
+ state.workspaceTab = tabs[(currentIndex + 1) % tabs.length];
3038
+ renderMeetingDetail();
3039
+ }
3040
+
3041
+ if (event.key === "[") {
3042
+ state.workspaceTab = tabs[(currentIndex + tabs.length - 1) % tabs.length];
3043
+ renderMeetingDetail();
3044
+ }
3045
+ });
3046
+
2894
3047
  const events = new EventSource("/events");
2895
3048
  events.addEventListener("state.updated", (event) => {
2896
3049
  const payload = JSON.parse(event.data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",