Skip to content
Merged
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
8 changes: 7 additions & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,13 @@ func printItems(items []extract.Item, kind string) error {
if len(cat) < 5 {
cat += strings.Repeat(" ", 5-len(cat))
}
fmt.Fprintf(os.Stdout, "%s\t%s\t%s\n", cat, item.Label, item.URL)
// Items arrive newest-first; append the timestamp as a trailing tab
// column so existing 3-column parsers keep working.
if !item.Timestamp.IsZero() {
fmt.Fprintf(os.Stdout, "%s\t%s\t%s\t%s\n", cat, item.Label, item.URL, item.Timestamp.Format("2006-01-02 15:04:05"))
} else {
fmt.Fprintf(os.Stdout, "%s\t%s\t%s\n", cat, item.Label, item.URL)
}
}
return nil
}
Expand Down
33 changes: 33 additions & 0 deletions internal/cli/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ func extractURLsWithContext(entries []session.Entry, sessID string) []PickerItem
items[idx].Refs = append(items[idx].Refs, ref)
} else {
index[item.URL] = len(items)
item.Timestamp = e.Timestamp
items = append(items, PickerItem{
Item: item,
SessionID: sessID,
Expand All @@ -359,6 +360,7 @@ func extractURLsWithContext(entries []session.Entry, sessID string) []PickerItem
}
}
}
sortAndStampItems(items)
return items
}

Expand All @@ -378,6 +380,7 @@ func extractFilesWithContext(entries []session.Entry, sessID string) []PickerIte
items[idx].Refs = append(items[idx].Refs, ref)
} else {
index[item.URL] = len(items)
item.Timestamp = e.Timestamp
items = append(items, PickerItem{
Item: item,
SessionID: sessID,
Expand All @@ -386,9 +389,39 @@ func extractFilesWithContext(entries []session.Entry, sessID string) []PickerIte
}
}
}
sortAndStampItems(items)
return items
}

// sortAndStampItems orders URL/file picker items most-recent first (by the
// timestamp of the entry each last appeared in) and appends a short timeAgo
// suffix to each label, matching the changes picker's newest-first layout.
func sortAndStampItems(items []PickerItem) {
// Keep each item stamped with its latest occurrence, not its first, so the
// ordering reflects the most recent mention.
for i := range items {
latest := items[i].Item.Timestamp
for _, r := range items[i].Refs {
if r.Timestamp.After(latest) {
latest = r.Timestamp
}
}
items[i].Item.Timestamp = latest
}
sort.SliceStable(items, func(i, j int) bool {
ti, tj := items[i].Item.Timestamp, items[j].Item.Timestamp
if !ti.Equal(tj) {
return ti.After(tj)
}
return items[i].Item.URL < items[j].Item.URL
})
for i := range items {
if !items[i].Item.Timestamp.IsZero() {
items[i].Item.Label += " " + shortTimeAgo(items[i].Item.Timestamp)
}
}
}

func extractChangesWithContext(entries []session.Entry, sessID string) []PickerItem {
index := make(map[string]int)
var items []PickerItem
Expand Down
21 changes: 17 additions & 4 deletions internal/extract/changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@ func EntryChanges(entries []session.Entry) []ChangeItem {
seen[filePath] = len(items)
items = append(items, ChangeItem{
Item: Item{
URL: filePath,
Label: ShortenPath(filePath),
Category: block.ToolName,
URL: filePath,
Label: ShortenPath(filePath),
Category: block.ToolName,
Timestamp: entry.Timestamp,
},
ToolNames: []string{block.ToolName},
ToolInputs: []string{block.ToolInput},
Expand All @@ -112,10 +113,22 @@ func EntryChanges(entries []session.Entry) []ChangeItem {
})
}
}
sort.Slice(items, func(i, j int) bool { return items[i].Item.URL < items[j].Item.URL })
sortChangesByTime(items)
return items
}

// sortChangesByTime orders change items most-recent first, falling back to path
// order when timestamps are equal or absent so the list stays stable.
func sortChangesByTime(items []ChangeItem) {
sort.SliceStable(items, func(i, j int) bool {
ti, tj := items[i].Timestamp, items[j].Timestamp
if !ti.Equal(tj) {
return ti.After(tj)
}
return items[i].Item.URL < items[j].Item.URL
})
}

func SessionChanges(filePath string) []ChangeItem {
entries, err := session.LoadMessages(filePath)
if err != nil {
Expand Down
56 changes: 41 additions & 15 deletions internal/extract/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@ import (
"sort"
"strings"
"sync"
"time"

"github.com/sendbird/ccx/internal/session"
)

// Item represents a URL or file path extracted from a session.
type Item struct {
URL string
Label string // short display label
Category string // github, jira, slack, pr, other
URL string
Label string // short display label
Category string // github, jira, slack, pr, other
Timestamp time.Time // latest entry timestamp this item appeared in (zero if unknown)
}

// urlRegex matches http/https URLs in text.
Expand Down Expand Up @@ -49,40 +51,52 @@ func SessionURLs(filePath string) []Item {
return EntryURLs(entries)
}

// EntryURLs extracts unique URLs from a set of entries.
// EntryURLs extracts unique URLs from a set of entries, stamping each with the
// timestamp of the entry it last appeared in and ordering most-recent first.
func EntryURLs(entries []session.Entry) []Item {
seen := make(map[string]bool)
seen := make(map[string]int)
var items []Item
for _, entry := range entries {
extractURLsFromBlocks(entry.Content, seen, &items)
extractURLsFromBlocks(entry.Content, entry.Timestamp, seen, &items)
}
sortItems(items)
sortItemsByTime(items)
return items
}

// BlockURLs extracts unique URLs from content blocks.
// BlockURLs extracts unique URLs from content blocks (single-message scope, so
// no timestamp is available). Ordered by category.
func BlockURLs(blocks []session.ContentBlock) []Item {
seen := make(map[string]bool)
seen := make(map[string]int)
var items []Item
extractURLsFromBlocks(blocks, seen, &items)
extractURLsFromBlocks(blocks, time.Time{}, seen, &items)
sortItems(items)
return items
}

// extractURLsFromBlocks appends unique URLs from blocks to items.
func extractURLsFromBlocks(blocks []session.ContentBlock, seen map[string]bool, items *[]Item) {
// extractURLsFromBlocks appends unique URLs from blocks to items. seen maps a
// URL to its index in items so a later, more-recent occurrence can refresh the
// stored timestamp (keep latest).
func extractURLsFromBlocks(blocks []session.ContentBlock, ts time.Time, seen map[string]int, items *[]Item) {
for _, block := range blocks {
for _, text := range [2]string{block.Text, block.ToolInput} {
if text == "" {
continue
}
for _, raw := range urlRegex.FindAllString(text, -1) {
u := CleanURL(raw)
if u == "" || seen[u] {
if u == "" {
continue
}
if idx, ok := seen[u]; ok {
if !ts.IsZero() {
(*items)[idx].Timestamp = ts // keep latest occurrence
}
continue
}
seen[u] = true
*items = append(*items, CategorizeURL(u))
seen[u] = len(*items)
item := CategorizeURL(u)
item.Timestamp = ts
*items = append(*items, item)
}
}
}
Expand All @@ -94,6 +108,18 @@ func sortItems(items []Item) {
})
}

// sortItemsByTime orders items most-recent first, falling back to category order
// when timestamps are equal or absent so the list stays stable and grouped.
func sortItemsByTime(items []Item) {
sort.SliceStable(items, func(i, j int) bool {
ti, tj := items[i].Timestamp, items[j].Timestamp
if !ti.Equal(tj) {
return ti.After(tj)
}
return categoryOrder[items[i].Category] < categoryOrder[items[j].Category]
})
}

// isURLEndChar returns true if r is likely intentional at the end of a URL.
// Characters like ., ), *, ;, ', ! are valid in RFC 3986 but almost never
// end a real URL — they leak from surrounding prose or markdown.
Expand Down
40 changes: 28 additions & 12 deletions internal/extract/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package extract

import (
"strings"
"time"

"github.com/sendbird/ccx/internal/session"
)
Expand Down Expand Up @@ -41,9 +42,15 @@ func BlockModifiedFiles(blocks []session.ContentBlock) []Item {
}

func extractFilePaths(blocks []session.ContentBlock, tools map[string]string, skipImages bool) []Item {
seen := make(map[string]bool)
seen := make(map[string]int)
var items []Item
extractFilePathsInto(blocks, time.Time{}, tools, skipImages, seen, &items)
return items
}

// extractFilePathsInto appends unique file paths from blocks to items, stamping
// each with ts (keep latest occurrence). seen maps a path to its index in items.
func extractFilePathsInto(blocks []session.ContentBlock, ts time.Time, tools map[string]string, skipImages bool, seen map[string]int, items *[]Item) {
for _, block := range blocks {
if block.Type != "tool_use" || block.ToolInput == "" {
continue
Expand All @@ -53,7 +60,13 @@ func extractFilePaths(blocks []session.ContentBlock, tools map[string]string, sk
continue
}
path := JSONField(block.ToolInput, field)
if path == "" || seen[path] {
if path == "" {
continue
}
if idx, ok := seen[path]; ok {
if !ts.IsZero() {
(*items)[idx].Timestamp = ts // keep latest occurrence
}
continue
}
if skipImages {
Expand All @@ -69,25 +82,28 @@ func extractFilePaths(blocks []session.ContentBlock, tools map[string]string, sk
continue
}
}
seen[path] = true
items = append(items, Item{
URL: path,
Label: ShortenPath(path),
Category: block.ToolName,
seen[path] = len(*items)
*items = append(*items, Item{
URL: path,
Label: ShortenPath(path),
Category: block.ToolName,
Timestamp: ts,
})
}
return items
}

// SessionFilePaths loads messages and extracts file paths.
// SessionFilePaths loads messages and extracts file paths, stamping each with
// the timestamp of the entry it last appeared in and ordering most-recent first.
func SessionFilePaths(filePath string) []Item {
entries, err := session.LoadMessages(filePath)
if err != nil {
return nil
}
var blocks []session.ContentBlock
seen := make(map[string]int)
var items []Item
for _, entry := range entries {
blocks = append(blocks, entry.Content...)
extractFilePathsInto(entry.Content, entry.Timestamp, FilePathTools, false, seen, &items)
}
return BlockFilePaths(blocks)
sortItemsByTime(items)
return items
}
79 changes: 79 additions & 0 deletions internal/extract/timestamp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package extract

import (
"testing"
"time"

"github.com/sendbird/ccx/internal/session"
)

// TestEntryURLsRecentFirst verifies URLs are stamped with the latest entry they
// appear in and ordered most-recent first.
func TestEntryURLsRecentFirst(t *testing.T) {
t1 := time.Date(2026, 4, 11, 10, 0, 0, 0, time.UTC)
t2 := time.Date(2026, 4, 11, 10, 5, 0, 0, time.UTC)
t3 := time.Date(2026, 4, 11, 10, 9, 0, 0, time.UTC)
entries := []session.Entry{
{Timestamp: t1, Content: []session.ContentBlock{{Text: "see https://github.com/sendbird/ccx/pull/1"}}},
{Timestamp: t2, Content: []session.ContentBlock{{Text: "and https://github.com/sendbird/ccx/pull/2"}}},
// pull/1 reappears later — its stored timestamp should refresh to t3,
// bumping it back to the front.
{Timestamp: t3, Content: []session.ContentBlock{{Text: "again https://github.com/sendbird/ccx/pull/1"}}},
}
items := EntryURLs(entries)
if len(items) != 2 {
t.Fatalf("expected 2 unique URLs, got %d: %#v", len(items), items)
}
if items[0].URL != "https://github.com/sendbird/ccx/pull/1" {
t.Errorf("expected pull/1 first (refreshed to t3), got %s", items[0].URL)
}
if !items[0].Timestamp.Equal(t3) {
t.Errorf("expected pull/1 timestamp %v, got %v", t3, items[0].Timestamp)
}
if !items[1].Timestamp.Equal(t2) {
t.Errorf("expected pull/2 timestamp %v, got %v", t2, items[1].Timestamp)
}
}

// TestSessionFilePathsBlockScopeNoTimestamp guards that single-message BlockURLs
// / BlockFilePaths leave the timestamp zero (no entry context available).
func TestBlockScopeNoTimestamp(t *testing.T) {
urls := BlockURLs([]session.ContentBlock{{Text: "https://example.com/x"}})
if len(urls) != 1 || !urls[0].Timestamp.IsZero() {
t.Errorf("block-scope URL should have zero timestamp, got %#v", urls)
}
files := BlockFilePaths([]session.ContentBlock{
{Type: "tool_use", ToolName: "Edit", ToolInput: `{"file_path":"/tmp/a.go"}`},
})
if len(files) != 1 || !files[0].Timestamp.IsZero() {
t.Errorf("block-scope file should have zero timestamp, got %#v", files)
}
}

// TestSessionFilePathsRecentFirst verifies file paths carry the latest entry
// timestamp and are ordered most-recent first via the shared sort helper.
func TestFilePathsRecentFirstStamp(t *testing.T) {
t1 := time.Date(2026, 4, 11, 10, 0, 0, 0, time.UTC)
t2 := time.Date(2026, 4, 11, 10, 5, 0, 0, time.UTC)
seen := make(map[string]int)
var items []Item
extractFilePathsInto([]session.ContentBlock{
{Type: "tool_use", ToolName: "Edit", ToolInput: `{"file_path":"/tmp/a.go"}`},
}, t1, FilePathTools, false, seen, &items)
extractFilePathsInto([]session.ContentBlock{
{Type: "tool_use", ToolName: "Write", ToolInput: `{"file_path":"/tmp/b.go"}`},
}, t2, FilePathTools, false, seen, &items)
// a.go reappears at t2 → its timestamp refreshes to the latest.
extractFilePathsInto([]session.ContentBlock{
{Type: "tool_use", ToolName: "Edit", ToolInput: `{"file_path":"/tmp/a.go"}`},
}, t2, FilePathTools, false, seen, &items)
sortItemsByTime(items)
if len(items) != 2 {
t.Fatalf("expected 2 files, got %d", len(items))
}
for _, it := range items {
if !it.Timestamp.Equal(t2) {
t.Errorf("%s: expected timestamp %v, got %v", it.URL, t2, it.Timestamp)
}
}
}
Loading
Loading