feat(views): add custom Linear views as sub-tab bar - #56
Conversation
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
📝 WalkthroughWalkthroughThis 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
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
model_update.go (1)
271-275: Consider displaying an error message on fetch failure and validatingactiveViewIdx.Unlike other
*LoadedMsghandlers (e.g.,statesLoadedMsg), this silently ignores errors. Additionally, if the views list changes and becomes shorter than the currentactiveViewIdx, 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
[/]:viewsmight be visually parsed as/]being a single key. Consider alternatives like[]:viewsor[ / ]:viewsfor 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: 50without 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 resettingactiveViewIdxwhen views are reloaded.The bounds check
m.activeViewIdx-1 < len(m.customViews)prevents crashes, but ifcustomViewsis reloaded with fewer items than before (e.g., views deleted on server),activeViewIdxcould 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
activeViewIdxin thecustomViewsLoadedMsghandler.🤖 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
📒 Files selected for processing (10)
linear.gomodel.gomodel_commands.gomodel_list_actions.gomodel_messages.gomodel_state.gomodel_update.gomodel_view.gotestdata/golden/list_demo_issues.txttestdata/golden/list_empty_assigned.txt
| 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} | ||
| } |
There was a problem hiding this comment.
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.
| 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.
Summary
[and]to cycle between views; filter/sort keys are locked while a view is activeContext
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
]to cycle to a custom view — issues reload with view's filtered set[to cycle back to "All Issues" — normal filtered list returnsf/tab/o/p/Lwhile on a view — guard message appearsCloses #55
Summary by CodeRabbit
New Features
[and]to cycle between All Issues and custom viewsUI/UX Changes
[/]to access these features