Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions src/commands/history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import React, { useEffect, useState } from 'react'
import zod from 'zod'
import { Box, Text, useApp, useStdout } from 'ink'
import { Spinner } from '../components/Spinner.js'
import { HistoryBrowser } from '../components/HistoryBrowser.js'
import type { HistoryItem } from '../components/HistoryBrowser.js'
import { gql } from '../lib/client.js'
import { getFeedRender, getFeedWidth } from '../lib/config.js'
import { TweetCard, FeedTable } from '../components/TweetCard.js'
import type { FeedTweet } from '../components/TweetCard.js'

export const description = 'Browse previously triaged suggestions'

export const options = zod.object({
status: zod
.string()
.optional()
.describe('Filter by status: archived|saved|read|skipped|replied (default: all non-inbox)'),
Comment on lines +14 to +18
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

🧩 Analysis chain

🏁 Script executed:

cat -n src/commands/history.tsx | head -80

Repository: 1a35e1/sonar-cli

Length of output: 3073


Constrain --status with Zod enum to match the advertised values.

The status option currently accepts any string value via .string().optional(). Invalid values like --status foo are accepted locally, then resolveStatus() uppercases them to FOO, and the request fails later when GraphQL rejects the unknown enum value. Since the help text advertises a fixed set of values (archived|saved|read|skipped|replied), constrain this option with zod.enum() to fail fast at the CLI layer.

The resolveStatus() function at lines 74-78 should also be updated to work only with the constrained enum values from the Zod schema.

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

In `@src/commands/history.tsx` around lines 14 - 18, Constrain the CLI `status`
option to the advertised values by replacing the loose zod.string().optional()
in the exported `options` schema with a
zod.enum(['archived','saved','read','skipped','replied']).optional(), and update
the `resolveStatus()` function to accept only that enum type (or undefined) and
map it to the GraphQL enum (e.g. uppercase or the exact enum name expected by
the API) rather than uppercasing arbitrary strings; ensure `resolveStatus()`
handles undefined/omitted status and returns the correct mapped value for each
allowed enum member.

limit: zod.number().optional().describe('Result limit per page (default: 20)'),
render: zod.string().optional().describe('Output layout: card|table'),
width: zod.number().optional().describe('Card width in columns'),
json: zod.boolean().default(false).describe('Raw JSON output'),
interactive: zod
.boolean()
.default(true)
.describe('Interactive browser mode (default: on, use --no-interactive to disable)'),
})

type Props = { options: zod.infer<typeof options> }

// Matches the shape returned by the suggestions GraphQL query
interface SuggestionResponse {
suggestionId: string
score: number
status: string
tweet: HistoryItem['tweet']
}

const HISTORY_QUERY = `
query History($status: SuggestionStatus, $limit: Int, $offset: Int) {
suggestions(status: $status, limit: $limit, offset: $offset) {
suggestionId score status
tweet {
id xid text createdAt likeCount retweetCount replyCount
user { displayName username followersCount followingCount }
}
}
suggestionCounts { archived later read skipped replied total inbox }
}
`

const STATUS_MAP: Record<string, string> = {
archived: 'ARCHIVED',
saved: 'LATER',
later: 'LATER',
read: 'READ',
skipped: 'SKIPPED',
replied: 'REPLIED',
}

function resolveStatus(input?: string): string | undefined {
if (!input) return undefined
const key = input.toLowerCase()
return STATUS_MAP[key] ?? input.toUpperCase()
}

function countForStatus(
counts: { archived: number; later: number; read: number; skipped: number; replied: number; total: number; inbox: number },
status?: string,
): number {
if (!status) return counts.total - counts.inbox
switch (status) {
case 'ARCHIVED': return counts.archived
case 'LATER': return counts.later
case 'READ': return counts.read
case 'SKIPPED': return counts.skipped
case 'REPLIED': return counts.replied
default: return counts.total - counts.inbox
}
}

export default function History({ options: flags }: Props) {
const { exit } = useApp()
const [items, setItems] = useState<HistoryItem[] | null>(null)
const [total, setTotal] = useState(0)
const [error, setError] = useState<string | null>(null)

const { stdout } = useStdout()
const termWidth = stdout.columns ?? 100
const cardWidth = getFeedWidth(flags.width)
const render = getFeedRender(flags.render)
const statusFilter = resolveStatus(flags.status)

useEffect(() => {
async function load() {
try {
const limit = flags.limit ?? 20
const vars: Record<string, unknown> = { limit, offset: 0 }
if (statusFilter) vars.status = statusFilter

const res = await gql<{
suggestions: SuggestionResponse[]
suggestionCounts: {
archived: number; later: number; read: number
skipped: number; replied: number; total: number; inbox: number
}
}>(HISTORY_QUERY, vars)

// When no status filter, exclude INBOX items from results
const filtered = statusFilter
? res.suggestions
: res.suggestions.filter(s => s.status !== 'INBOX')

const historyTotal = countForStatus(res.suggestionCounts, statusFilter)

const mapped: HistoryItem[] = filtered.map(s => ({
key: s.tweet.xid,
score: s.score,
suggestionId: s.suggestionId,
status: s.status,
matchedKeywords: [],
tweet: s.tweet,
}))

if (flags.json) {
process.stdout.write(JSON.stringify(mapped, null, 2) + '\n')
exit()
return
}

setItems(mapped)
setTotal(historyTotal)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
load()
}, [flags.status, flags.limit, flags.json])

if (error) return <Text color="red">Error: {error}</Text>
if (!items) return <Spinner label="Loading history..." />

if (items.length === 0) {
return (
<Box flexDirection="column" gap={1}>
<Text color="yellow">No history yet.</Text>
<Text dimColor>Triage some suggestions first with <Text color="cyan">sonar</Text></Text>
</Box>
)
}

if (flags.interactive) {
const pageSize = flags.limit ?? 20
const fetchMore = async (offset: number): Promise<HistoryItem[]> => {
const vars: Record<string, unknown> = { limit: pageSize, offset }
if (statusFilter) vars.status = statusFilter

const res = await gql<{ suggestions: SuggestionResponse[] }>(HISTORY_QUERY, vars)
const filtered = statusFilter
? res.suggestions
: res.suggestions.filter(s => s.status !== 'INBOX')

return filtered.map(s => ({
key: s.tweet.xid,
score: s.score,
suggestionId: s.suggestionId,
status: s.status,
matchedKeywords: [],
tweet: s.tweet,
}))
}
return <HistoryBrowser items={items} total={total} fetchMore={fetchMore} />
}

// Non-interactive: render all items
const statusLabel = flags.status ? flags.status : 'all'

if (render === 'table') {
const tableData: FeedTweet[] = items.map(i => ({
score: i.score,
matchedKeywords: i.matchedKeywords,
tweet: i.tweet,
}))
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold color="white">History</Text>
<Text dimColor> · {statusLabel} ({items.length})</Text>
</Box>
<FeedTable data={tableData} />
</Box>
)
}

return (
<Box flexDirection="column">
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text bold color="white">History</Text>
<Text dimColor> · {statusLabel}</Text>
<Text dimColor> ({items.length})</Text>
</Box>
<Text dimColor>{'─'.repeat(Math.min(termWidth - 2, 72))}</Text>
</Box>

<Box flexDirection="column">
{items.map((item, i) => (
<Box key={item.key} flexDirection="column">
<TweetCard
item={{ score: item.score, matchedKeywords: item.matchedKeywords, tweet: item.tweet }}
termWidth={termWidth}
cardWidth={cardWidth}
isLast={i === items.length - 1}
/>
<Box marginLeft={2} marginBottom={i === items.length - 1 ? 0 : 1}>
<Text dimColor>
status: <Text color={item.status === 'ARCHIVED' ? 'gray' : item.status === 'LATER' ? 'yellow' : item.status === 'SKIPPED' ? 'red' : 'blue'}>{item.status.toLowerCase()}</Text>
</Text>
Comment on lines +215 to +218
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 | 🟡 Minor

REPLIED items render with the wrong color here.

This ternary never handles REPLIED, so replied history is shown as blue instead of green and diverges from src/components/HistoryBrowser.tsx. Reuse a shared status-to-color mapping to keep both render paths consistent.

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

In `@src/commands/history.tsx` around lines 226 - 229, The status color logic in
the JSX rendering inside the Box/Text (using item.status ternary) omits the
REPLIED case so REPLIED items render as blue; replace this inline ternary with a
shared status-to-color mapping (the same mapping used by
src/components/HistoryBrowser.tsx) and use that map to compute the color for
item.status (e.g., lookup by item.status or fallback to a default) so both
History.tsx and HistoryBrowser.tsx stay consistent; update the Text color prop
to use the shared mapping function or constant (referencing item.status, Text,
and the existing mapping in HistoryBrowser) and remove the long ternary.

</Box>
</Box>
))}
</Box>
</Box>
)
}
115 changes: 115 additions & 0 deletions src/components/HistoryBrowser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React, { useState, useEffect } from 'react'
import { Box, Text, useApp, useInput, useStdout } from 'ink'
import { TweetCard } from './TweetCard.js'
import { getFeedWidth } from '../lib/config.js'
import { openUrl } from '../lib/open.js'
import type { TriageItem } from './InteractiveSession.js'

export interface HistoryItem extends TriageItem {
status: string
}

interface HistoryBrowserProps {
items: HistoryItem[]
total: number
fetchMore?: (offset: number) => Promise<HistoryItem[]>
}

const STATUS_COLORS: Record<string, string> = {
ARCHIVED: 'gray',
LATER: 'yellow',
READ: 'blue',
SKIPPED: 'red',
REPLIED: 'green',
}

function statusLabel(status: string): string {
return status.toLowerCase()
}

function Divider({ width }: { width: number }) {
return <Text dimColor>{'─'.repeat(Math.min(width - 2, 72))}</Text>
}

export function HistoryBrowser({ items: initialItems, total: initialTotal, fetchMore }: HistoryBrowserProps) {
const { exit } = useApp()
const { stdout } = useStdout()
const termWidth = stdout.columns ?? 100
const cardWidth = getFeedWidth()
Comment on lines +34 to +38
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

Interactive mode drops the --width override.

src/commands/history.tsx computes getFeedWidth(flags.width), but this component recomputes getFeedWidth() with no override, so the default interactive path ignores the documented --width flag. Pass cardWidth in via props instead of reading config again here.

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

In `@src/components/HistoryBrowser.tsx` around lines 53 - 56, HistoryBrowser
currently recomputes getFeedWidth() and ignores the CLI --width override; update
the component to accept cardWidth from props instead of calling getFeedWidth().
Modify the HistoryBrowser signature to include cardWidth (update
HistoryBrowserProps accordingly), remove or replace the line "const cardWidth =
getFeedWidth()" and use the passed-in cardWidth throughout the component,
ensuring any place that referenced getFeedWidth() or the local cardWidth now
uses the prop value so the interactive path respects the flags.width override.


const [items, setItems] = useState(initialItems)
const [total, setTotal] = useState(initialTotal)
const [index, setIndex] = useState(0)
const [loading, setLoading] = useState(false)

// Fetch next page when 3 items from the end
useEffect(() => {
if (!fetchMore || loading) return
if (index >= items.length - 3 && items.length < total) {
setLoading(true)
fetchMore(items.length)
.then(more => {
if (more.length > 0) {
setItems(prev => [...prev, ...more])
}
})
.catch(() => {})
.finally(() => setLoading(false))
}
}, [index, items.length, total, loading])
Comment on lines +45 to +59
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

The pagination cursor is based on filtered item count.

fetchMore(items.length) only works if every fetched row becomes a rendered row. In this PR, src/commands/history.tsx filters INBOX items after the GraphQL call, so a page can consume more backend rows than it appends here. Once that happens, the next request reuses a stale raw offset and can duplicate/skip history entries or refetch the same page. Track a raw cursor/offset separately from the displayed item count.

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

In `@src/components/HistoryBrowser.tsx` around lines 63 - 77, Pagination currently
uses the displayed count (items.length) as the backend cursor which breaks when
rows are filtered client-side; add a separate raw offset/cursor state (e.g.,
rawOffset) and use that when calling fetchMore instead of items.length,
increment rawOffset by the number of rows returned by fetchMore (use the raw
count returned, not more.length if that is filtered), update the useEffect to
depend on rawOffset/index/loading/total as needed, and ensure setItems still
appends only the filtered/displayed rows while rawOffset tracks the true
server-side pagination progress for subsequent fetchMore calls.


const current = items[index]

useInput((input, key) => {
if (input === 'q') {
exit()
} else if (input === 'n' || key.return || input === ' ' || key.downArrow || key.rightArrow) {
if (index < items.length - 1 || items.length < total) {
setIndex(i => Math.min(i + 1, items.length - 1))
}
} else if (input === 'b' || key.upArrow || key.leftArrow) {
setIndex(i => Math.max(0, i - 1))
} else if (input === 'o' && current) {
const handle = current.tweet.user.username ?? current.tweet.user.displayName
const url = `https://x.com/${handle}/status/${current.tweet.id}`
openUrl(url)
Comment on lines +73 to +75
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

Handle missing usernames when building X URLs.

Line 73 falls back to displayName, which may contain spaces/symbols and produce invalid profile-path URLs. Use username when present; otherwise open the status-only route.

🔧 Proposed fix
-      const handle = current.tweet.user.username ?? current.tweet.user.displayName
-      const url = `https://x.com/${handle}/status/${current.tweet.id}`
+      const username = current.tweet.user.username?.trim()
+      const tweetId = encodeURIComponent(current.tweet.id)
+      const url = username
+        ? `https://x.com/${encodeURIComponent(username)}/status/${tweetId}`
+        : `https://x.com/i/web/status/${tweetId}`
       openUrl(url)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/HistoryBrowser.tsx` around lines 73 - 75, When building the X
URL in HistoryBrowser (variables: handle, current.tweet.user.username,
current.tweet.user.displayName, url, openUrl), only use the username when
present and valid; if username is missing, construct and open the status-only
route (for example use the /i/web/status/{id} form) instead of falling back to
displayName which may include spaces/symbols; implement a conditional that sets
url = `https://x.com/${username}/status/${id}` when username exists, otherwise
url = `https://x.com/i/web/status/${id}`, then call openUrl(url).

}
})

if (!current) {
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text color="yellow">No history items found.</Text>
<Text dimColor>Triage some suggestions first with <Text color="cyan">sonar</Text></Text>
</Box>
)
}

const statusColor = STATUS_COLORS[current.status] ?? 'white'

return (
<Box flexDirection="column">
<Box marginBottom={1} gap={3}>
<Text dimColor>{index + 1} / {total}</Text>
<Text color={statusColor}>{statusLabel(current.status)}</Text>
</Box>

<TweetCard
item={{ score: current.score, matchedKeywords: current.matchedKeywords, tweet: current.tweet }}
termWidth={termWidth}
cardWidth={cardWidth}
isLast={true}
/>

<Box flexDirection="column" marginTop={1}>
<Divider width={termWidth} />
<Box marginTop={1} gap={3}>
<Text dimColor><Text color="white">b</Text> back</Text>
<Text dimColor><Text color="white">n</Text> next</Text>
<Text dimColor><Text color="white">o</Text> open</Text>
<Text dimColor><Text color="white">q</Text> quit</Text>
</Box>
</Box>
</Box>
)
}
13 changes: 13 additions & 0 deletions src/lib/open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { execSync } from 'child_process'
import { platform } from 'os'

export function openUrl(url: string): void {
const cmd =
platform() === 'darwin' ? 'open'
: platform() === 'win32' ? 'start'
: 'xdg-open'

try {
execSync(`${cmd} "${url}"`, { stdio: 'ignore' })
} catch {}
}
Loading