Skip to content
This repository was archived by the owner on Jul 3, 2026. It is now read-only.

feat(views): add custom Linear views as sub-tab bar - #56

Open
serabi wants to merge 1 commit into
mainfrom
feat/custom-views
Open

feat(views): add custom Linear views as sub-tab bar#56
serabi wants to merge 1 commit into
mainfrom
feat/custom-views

Conversation

@serabi

@serabi serabi commented Apr 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds support for browsing custom Linear views in the TUI
  • When a team has saved views, a tab bar appears below the team bar with "All Issues" + each custom view
  • Use [ and ] to cycle between views; filter/sort keys are locked while a view is active
  • Views are fetched per team and cached across team switches

Context

Marie set up custom views for DHMIG that group migrations by site plan level. The team is exploring working from Linear views instead of Zendesk queues. This brings those curated views into the TUI.

Slack thread: https://a8c.slack.com/archives/C06PZC1RR5F/p1775243219702539

Test plan

  • Switch to a team with custom views (e.g. DHMIG) — view tab bar appears
  • Press ] to cycle to a custom view — issues reload with view's filtered set
  • Press [ to cycle back to "All Issues" — normal filtered list returns
  • Press f/tab/o/p/L while on a view — guard message appears
  • Switch to a team with no views (e.g. TSCODE) — view tab bar is hidden
  • Switch teams and back — view selection is preserved

Closes #55

Summary by CodeRabbit

  • New Features

    • Added support for Linear custom views with an intuitive tab-based interface for navigation
    • Implemented keyboard shortcuts [ and ] to cycle between All Issues and custom views
    • Custom views now load automatically on startup and after team switches
  • UI/UX Changes

    • Filtering, sorting, and project/label selection actions are restricted when actively viewing a custom view; switch back to All Issues using [/] to access these features

Add support for browsing custom Linear views in the TUI. When a team
has saved views, a tab bar appears below the team bar showing "All
Issues" plus each custom view. Use [ and ] to cycle between views.

- Add GetCustomViews and GetCustomViewIssues API methods
- Fetch views per team on init and team switch, cache in teamState
- Render view tab bar conditionally (hidden when no views exist)
- Lock filter/sort/project/label keys while a view is active
- Show active view name in status bar and list title

Closes #55
@coderabbitai

coderabbitai Bot commented Apr 7, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This pull request introduces support for custom Linear views in the TUI. New Linear API methods query custom views and their issues with pagination. The model layer adds state tracking, background fetching commands, and view-switching logic. UI enhancements include a tab bar for view selection, keyboard shortcuts ([/]) to cycle views, and view-aware issue filtering that disables project/label/sort operations when in custom view mode.

Changes

Cohort / File(s) Summary
Linear API integration
linear.go
Added CustomView struct with ID, Name, Icon fields. Implemented GetCustomViews(teamID) to fetch custom views per team and GetCustomViewIssues(viewID, after) to fetch issues with cursor-based pagination support.
State & message infrastructure
model_messages.go, model_state.go
Added customViewsLoadedMsg type. Extended Model and teamState with customViews and activeViewIdx fields; implemented activeViewName() mapper; updated state persistence (saveTeamState, restoreTeamState, flushTeamState) and status/title rendering to suppress filter details in custom view mode.
Commands & initialization
model.go, model_commands.go
Integrated fetchCustomViews() into Model.Init() and teamSwitchedMsg flow. Modified fetchIssues() to detect active custom view and route to GetCustomViewIssues() instead of project/label/default query path.
Navigation & interaction
model_list_actions.go, model_update.go
Added cycleViewRight() and cycleViewLeft() methods with wrap-around logic. Bound [ and ] keys to view cycling; added guard blocks for tab, f, o, p, L that prevent filter/sort/project operations outside "All Issues" view. Adjusted list height when custom views are present.
UI rendering
model_view.go
Added renderViewTabBar() method displaying tabs for "All Issues" plus each custom view (with optional icon prefix). Integrated tab bar into viewList() with conditional rendering and updated status hints.
Test data
testdata/golden/list_demo_issues.txt, testdata/golden/list_empty_assigned.txt
Added whitespace lines to accommodate new UI element (tab bar) in golden test snapshots.

Sequence Diagram

sequenceDiagram
    participant User
    participant TUI Model
    participant LinearClient
    participant Linear API
    
    User->>TUI Model: Start/Switch Team
    TUI Model->>TUI Model: Init() or teamSwitchedMsg
    TUI Model->>TUI Model: fetchCustomViews()
    TUI Model->>LinearClient: GetCustomViews(teamID)
    LinearClient->>Linear API: Query customViews filtered by teamID
    Linear API-->>LinearClient: Custom views (id, name, icon)
    LinearClient-->>TUI Model: []CustomView
    TUI Model->>TUI Model: Store in m.customViews
    TUI Model->>TUI Model: renderViewTabBar()
    TUI Model-->>User: Display view tabs + "All Issues"
    
    User->>TUI Model: Press [ or ] (cycle views)
    TUI Model->>TUI Model: cycleViewLeft() / cycleViewRight()
    TUI Model->>TUI Model: Update activeViewIdx, set loading state
    TUI Model->>TUI Model: fetchIssues()
    alt activeViewIdx > 0 (Custom View)
        TUI Model->>LinearClient: GetCustomViewIssues(viewID, "")
        LinearClient->>Linear API: Query customView issues
        Linear API-->>LinearClient: Issues + PageInfo
    else activeViewIdx == 0 (All Issues)
        TUI Model->>LinearClient: GetIssues() with project/label/default filters
    end
    LinearClient-->>TUI Model: []Issue, PageInfo
    TUI Model->>TUI Model: Update m.issues, render list
    TUI Model-->>User: Display issues for selected view
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 Custom views now bloom in our warren so fair,
Tab through the Linear views with the greatest of care,
[ and ] guide your path, left and right,
While "All Issues" gleams like a moon in the night.
Marie's migrations shall flow like a stream! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(views): add custom Linear views as sub-tab bar' accurately describes the main change—introducing a tab bar UI for browsing custom Linear views.
Linked Issues check ✅ Passed The PR addresses all investigation requirements from #55: exposes customView API queries, returns view metadata (id, name, icon), reconstructs views via Linear API, integrates as a tab bar UI, and manages rate limits via per-team caching.
Out of Scope Changes check ✅ Passed All changes focus on implementing custom Linear views support (API methods, state management, UI rendering, navigation guards) as defined in #55; minor test data updates align with the view tab bar rendering.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/custom-views

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
model_update.go (1)

271-275: Consider displaying an error message on fetch failure and validating activeViewIdx.

Unlike other *LoadedMsg handlers (e.g., statesLoadedMsg), this silently ignores errors. Additionally, if the views list changes and becomes shorter than the current activeViewIdx, the index becomes stale.

Proposed improvement
 case customViewsLoadedMsg:
-	if msg.err == nil {
-		m.customViews = msg.views
-	}
+	if msg.err != nil {
+		m.statusMsg = fmt.Sprintf("Error loading views: %v", msg.err)
+		return m, nil
+	}
+	m.customViews = msg.views
+	// Clamp activeViewIdx if views list shrank
+	if m.activeViewIdx > len(m.customViews) {
+		m.activeViewIdx = 0
+	}
 	return m, nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_update.go` around lines 271 - 275, The handler for customViewsLoadedMsg
currently ignores fetch errors and can leave m.activeViewIdx out of range;
update the customViewsLoadedMsg case to log or surface msg.err when non-nil
(similar to statesLoadedMsg handling) and only assign m.customViews on success,
then validate m.activeViewIdx against the new slice length (if views is empty
set activeViewIdx to -1 or 0 per project convention, otherwise clamp it to
len(m.customViews)-1) to avoid stale indices; reference symbols:
customViewsLoadedMsg, msg.err, m.customViews, and m.activeViewIdx.
model_view.go (1)

160-162: Consider clarifying the shortcut hint.

The hint [/]:views might be visually parsed as /] being a single key. Consider alternatives like []:views or [ / ]:views for better readability. This is a minor UX nitpick.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_view.go` around lines 160 - 162, The hint string appended when
m.customViews is non-empty is ambiguous; update the construction of row2 (where
row2 += "  [/]:views") to use a clearer hint such as "  [ / ]:views" or " 
[]:views" (whichever fits UI spacing) so users won't parse "/]" as a single key;
modify the code that checks m.customViews and appends to row2 accordingly to
replace the current literal with the chosen clearer hint.
linear.go (1)

340-359: Custom views query doesn't paginate.

The query uses first: 50 without pagination. Teams with more than 50 custom views won't see all of them. This is likely acceptable given that 50+ saved views per team is rare, but documenting or adding pagination would make it robust.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@linear.go` around lines 340 - 359, GetCustomViews currently hardcodes "first:
50" in the GraphQL query so teams with >50 CustomView nodes will be truncated;
update GetCustomViews to paginate through results by using cursor-based
pagination (read pageInfo.endCursor and pageInfo.hasNextPage) or accept a
configurable page size parameter, loop calling lc.queryWithVars until
hasNextPage is false, and append nodes to the returned []CustomView; reference
the GetCustomViews method, the GraphQL query string (replace first: 50), and the
CustomView/CustomViews result struct when implementing the pagination loop.
model_state.go (1)

285-290: Bounds check is good, but consider resetting activeViewIdx when views are reloaded.

The bounds check m.activeViewIdx-1 < len(m.customViews) prevents crashes, but if customViews is reloaded with fewer items than before (e.g., views deleted on server), activeViewIdx could point to a non-existent view. The user would silently fall back to "All Issues" behavior without explicit reset.

This is a minor edge case since view changes require server-side modifications, but for consistency you may want to clamp activeViewIdx in the customViewsLoadedMsg handler.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_state.go` around lines 285 - 290, When handling refreshed views in the
customViewsLoadedMsg handler, clamp or reset Model.activeViewIdx to a valid
range so activeViewName can't point to a deleted view; e.g., after updating
Model.customViews, if activeViewIdx < 1 or activeViewIdx-1 >= len(m.customViews)
set activeViewIdx to 0 (or to len(m.customViews) if you prefer selecting the
last view) so activeViewName and other logic always see a consistent index.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@model_commands.go`:
- Around line 24-28: The current block that loads custom view issues calls
client.GetCustomViewIssues(viewID, "") once and ignores PageInfo, truncating
results at the first page; modify the logic in the handler that uses
m.activeViewIdx and m.customViews to iterate pagination: call
GetCustomViewIssues(viewID, cursor) in a loop, append each page's issues to a
single slice, use PageInfo.endCursor to advance and PageInfo.hasNextPage to
stop, and then return a single issuesLoadedMsg{issues: allIssues, err: err}
(preserving error handling) so users receive the full issue set.

---

Nitpick comments:
In `@linear.go`:
- Around line 340-359: GetCustomViews currently hardcodes "first: 50" in the
GraphQL query so teams with >50 CustomView nodes will be truncated; update
GetCustomViews to paginate through results by using cursor-based pagination
(read pageInfo.endCursor and pageInfo.hasNextPage) or accept a configurable page
size parameter, loop calling lc.queryWithVars until hasNextPage is false, and
append nodes to the returned []CustomView; reference the GetCustomViews method,
the GraphQL query string (replace first: 50), and the CustomView/CustomViews
result struct when implementing the pagination loop.

In `@model_state.go`:
- Around line 285-290: When handling refreshed views in the customViewsLoadedMsg
handler, clamp or reset Model.activeViewIdx to a valid range so activeViewName
can't point to a deleted view; e.g., after updating Model.customViews, if
activeViewIdx < 1 or activeViewIdx-1 >= len(m.customViews) set activeViewIdx to
0 (or to len(m.customViews) if you prefer selecting the last view) so
activeViewName and other logic always see a consistent index.

In `@model_update.go`:
- Around line 271-275: The handler for customViewsLoadedMsg currently ignores
fetch errors and can leave m.activeViewIdx out of range; update the
customViewsLoadedMsg case to log or surface msg.err when non-nil (similar to
statesLoadedMsg handling) and only assign m.customViews on success, then
validate m.activeViewIdx against the new slice length (if views is empty set
activeViewIdx to -1 or 0 per project convention, otherwise clamp it to
len(m.customViews)-1) to avoid stale indices; reference symbols:
customViewsLoadedMsg, msg.err, m.customViews, and m.activeViewIdx.

In `@model_view.go`:
- Around line 160-162: The hint string appended when m.customViews is non-empty
is ambiguous; update the construction of row2 (where row2 += "  [/]:views") to
use a clearer hint such as "  [ / ]:views" or "  []:views" (whichever fits UI
spacing) so users won't parse "/]" as a single key; modify the code that checks
m.customViews and appends to row2 accordingly to replace the current literal
with the chosen clearer hint.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3b827119-fe08-484a-b6da-a2ea71803d8a

📥 Commits

Reviewing files that changed from the base of the PR and between da79137 and 5fa030c.

📒 Files selected for processing (10)
  • linear.go
  • model.go
  • model_commands.go
  • model_list_actions.go
  • model_messages.go
  • model_state.go
  • model_update.go
  • model_view.go
  • testdata/golden/list_demo_issues.txt
  • testdata/golden/list_empty_assigned.txt

Comment thread model_commands.go
Comment on lines +24 to +28
if m.activeViewIdx > 0 && m.activeViewIdx-1 < len(m.customViews) {
viewID := m.customViews[m.activeViewIdx-1].ID
issues, _, err := client.GetCustomViewIssues(viewID, "")
return issuesLoadedMsg{issues: issues, err: err}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Custom view issues are truncated at 50 items due to missing pagination.

GetCustomViewIssues returns PageInfo with hasNextPage and endCursor, but this code discards the pagination info and never fetches subsequent pages. Users with more than 50 issues in a custom view will see an incomplete list.

The existing issue fetching methods also lack pagination, so this may be an accepted limitation. However, custom views often contain curated issue sets that could exceed 50 items.

Consider adding pagination loop or documenting the limitation

If pagination is needed:

 		if m.activeViewIdx > 0 && m.activeViewIdx-1 < len(m.customViews) {
 			viewID := m.customViews[m.activeViewIdx-1].ID
-			issues, _, err := client.GetCustomViewIssues(viewID, "")
-			return issuesLoadedMsg{issues: issues, err: err}
+			var allIssues []Issue
+			var after string
+			for {
+				issues, pageInfo, err := client.GetCustomViewIssues(viewID, after)
+				if err != nil {
+					return issuesLoadedMsg{err: err}
+				}
+				allIssues = append(allIssues, issues...)
+				if !pageInfo.HasNextPage {
+					break
+				}
+				after = pageInfo.EndCursor
+			}
+			return issuesLoadedMsg{issues: allIssues}
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if m.activeViewIdx > 0 && m.activeViewIdx-1 < len(m.customViews) {
viewID := m.customViews[m.activeViewIdx-1].ID
issues, _, err := client.GetCustomViewIssues(viewID, "")
return issuesLoadedMsg{issues: issues, err: err}
}
if m.activeViewIdx > 0 && m.activeViewIdx-1 < len(m.customViews) {
viewID := m.customViews[m.activeViewIdx-1].ID
var allIssues []Issue
var after string
for {
issues, pageInfo, err := client.GetCustomViewIssues(viewID, after)
if err != nil {
return issuesLoadedMsg{err: err}
}
allIssues = append(allIssues, issues...)
if !pageInfo.HasNextPage {
break
}
after = pageInfo.EndCursor
}
return issuesLoadedMsg{issues: allIssues}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_commands.go` around lines 24 - 28, The current block that loads custom
view issues calls client.GetCustomViewIssues(viewID, "") once and ignores
PageInfo, truncating results at the first page; modify the logic in the handler
that uses m.activeViewIdx and m.customViews to iterate pagination: call
GetCustomViewIssues(viewID, cursor) in a loop, append each page's issues to a
single slice, use PageInfo.endCursor to advance and PageInfo.hasNextPage to
stop, and then return a single issuesLoadedMsg{issues: allIssues, err: err}
(preserving error handling) so users receive the full issue set.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scope: support custom Linear views in TUI

1 participant