From 54ad67a2f880dec309282bbf2fcd4ced45a9b4ea Mon Sep 17 00:00:00 2001 From: makoto-soracom Date: Sat, 7 Mar 2026 14:42:46 +0900 Subject: [PATCH 1/2] Update session copy icons and inline code padding --- internal/render/templates/session.html | 86 +++++++----- internal/render/templates/style.html | 173 +++++++++++++++++++++++-- 2 files changed, 213 insertions(+), 46 deletions(-) diff --git a/internal/render/templates/session.html b/internal/render/templates/session.html index cc8755c..cb351b5 100644 --- a/internal/render/templates/session.html +++ b/internal/render/templates/session.html @@ -9,13 +9,12 @@
+ {{ if .SelectedCwd }} +
+ + +

+
    +
    + {{ end }} + {{ if .FallbackDate }} +
    +

    No sessions found for this directory today.

    +
    +
    +

    --- Previous Day ---

    +
    +

    Filter by directory (click to filter sessions below)

    + +
    + {{ if or .HasPrev .HasNext (gt .TotalPages 1) }} + + {{ end }} +
    + {{ if .FallbackSessions }} + + {{ else }} +

    No sessions found for this directory.

    + {{ end }} +
    + {{ else }} {{ if .Dirs }} -
    +

    Filter by directory (click to filter sessions below)

    {{ end }} -
    + {{ if or .HasPrev .HasNext (gt .TotalPages 1) }} + + {{ end }} +
    {{ if .Sessions }} @@ -48,7 +169,238 @@

    Sessions on {{ .Date.Label }}{{ if .SelectedCwdLabel }}

    No sessions found{{ if .SelectedCwdLabel }} for this directory{{ end }}.

    {{ end }}

    + {{ end }}
    + {{ end }} diff --git a/internal/render/templates/dir.html b/internal/render/templates/dir.html index 90ee7b4..24d3162 100644 --- a/internal/render/templates/dir.html +++ b/internal/render/templates/dir.html @@ -10,19 +10,61 @@

    All directories

    -

    Dates for {{ .Dir.Label }}

    +

    Sessions for {{ .Dir.Label }}

    {{ .Dir.Count }} session{{ if ne .Dir.Count 1 }}s{{ end }}

    +
    + + + +
    + {{ if or .HasPrev .HasNext (gt .TotalPages 1) }} + + {{ end }}
    - {{ if .Dates }} + {{ if .Sessions }} @@ -31,6 +73,17 @@

    Dates for {{ .Dir.Label }}

    {{ end }}
    + {{ end }} diff --git a/internal/render/templates/index.html b/internal/render/templates/index.html index 4ddfd7b..4c2e8fe 100644 --- a/internal/render/templates/index.html +++ b/internal/render/templates/index.html @@ -136,11 +136,15 @@

    {{ if eq .View "dir" }}Available Directories{{ else }}Ava setStatus(data.results.length + " result" + (data.results.length === 1 ? "" : "s") + "."); data.results.forEach(function (item) { var li = document.createElement("li"); - li.className = "search-result"; + li.className = "session-list-item search-result"; var link = document.createElement("a"); - link.className = "search-result-link"; - link.href = "/" + item.path + "/" + item.file + "#line-" + item.line; + link.className = "link-item-link search-result-link"; + var targetLine = item.line; + if (item.role === "assistant" && item.prevUserLine) { + targetLine = item.prevUserLine; + } + link.href = "/" + item.path + "/" + item.file + "#line-" + targetLine; link.textContent = item.file; var meta = document.createElement("span"); @@ -163,11 +167,57 @@

    {{ if eq .View "dir" }}Available Directories{{ else }}Ava li.appendChild(link); li.appendChild(meta); - if (item.preview) { - var snippet = document.createElement("div"); - snippet.className = "search-result-snippet"; - highlightText(snippet, item.preview, query); - li.appendChild(snippet); + var thread = document.createElement("div"); + thread.className = "session-snippet session-snippet-thread"; + var hasUserSnippet = false; + + function addSnippet(role, text, highlight, line) { + if (!text) return; + var snippetLink = document.createElement("a"); + snippetLink.className = "snippet-link"; + var resolvedLine = line && line > 0 ? line : targetLine; + snippetLink.href = "/" + item.path + "/" + item.file + "#line-" + resolvedLine; + + var itemBox = document.createElement("div"); + itemBox.className = "session-item role-" + role + " snippet-item"; + + var content = document.createElement("div"); + content.className = "session-content"; + if (highlight) { + highlightText(content, text, query); + } else { + content.textContent = text; + } + + itemBox.appendChild(content); + snippetLink.appendChild(itemBox); + thread.appendChild(snippetLink); + } + + if (item.role === "user") { + hasUserSnippet = !!item.preview; + addSnippet("user", item.preview, true, item.line); + if (item.nextAssistant) { + addSnippet("assistant", item.nextAssistant, false, item.nextAssistantLine); + } + } else if (item.role === "assistant") { + if (item.prevUser) { + hasUserSnippet = true; + addSnippet("user", item.prevUser, false, item.prevUserLine); + } + addSnippet("assistant", item.preview, true, item.line); + } else if (item.preview) { + addSnippet(item.role || "unknown", item.preview, true, item.line); + } + + if (thread.childNodes.length > 0) { + if (hasUserSnippet) { + var divider = document.createElement("div"); + divider.className = "snippet-divider"; + divider.textContent = ":"; + thread.insertBefore(divider, thread.firstChild); + } + li.appendChild(thread); } results.appendChild(li); diff --git a/internal/search/index.go b/internal/search/index.go index ab80635..e76da89 100644 --- a/internal/search/index.go +++ b/internal/search/index.go @@ -5,6 +5,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "codex-manager/internal/sessions" ) @@ -14,18 +15,23 @@ const ( maxLimit = 200 snippetRadius = 60 snippetMax = 180 + contextMax = 140 ) // Result describes a single search match. type Result struct { - Date string `json:"date"` - Timestamp string `json:"timestamp"` - Cwd string `json:"cwd"` - Path string `json:"path"` - File string `json:"file"` - Line int `json:"line"` - Role string `json:"role"` - Preview string `json:"preview"` + Date string `json:"date"` + Timestamp string `json:"timestamp"` + Cwd string `json:"cwd"` + Path string `json:"path"` + File string `json:"file"` + Line int `json:"line"` + Role string `json:"role"` + Preview string `json:"preview"` + PrevUser string `json:"prevUser"` + NextAssistant string `json:"nextAssistant"` + PrevUserLine int `json:"prevUserLine"` + NextAssistantLine int `json:"nextAssistantLine"` sortTime time.Time } @@ -41,6 +47,22 @@ type entry struct { role string content string lower string + prevUser string + nextAsst string + prevLine int + nextLine int +} + +type threadPairKey struct { + path string + file string + userLine int + assistantLine int +} + +type threadPairState struct { + hasUserHit bool + hasAssistantHit bool } type fileIndex struct { @@ -118,6 +140,12 @@ func (idx *Index) RefreshFrom(sessionsIdx *sessions.Index) error { // Search returns the first N matches for the query. func (idx *Index) Search(query string, limit int) []Result { + return idx.SearchWithCwd(query, limit, "") +} + +// SearchWithCwd returns the first N matches for the query filtered by cwd. +// If cwdFilter is empty, it behaves like Search. +func (idx *Index) SearchWithCwd(query string, limit int, cwdFilter string) []Result { q := strings.TrimSpace(query) if q == "" { return nil @@ -128,40 +156,142 @@ func (idx *Index) Search(query string, limit int) []Result { if limit > maxLimit { limit = maxLimit } + cwdFilter = normalizeCwdFilter(cwdFilter) lower := strings.ToLower(q) idx.mu.RLock() defer idx.mu.RUnlock() - results := make([]Result, 0, limit) + matches := make([]entry, 0, limit) + pairStates := make(map[threadPairKey]threadPairState) for _, item := range idx.ordered { - matchIndex := strings.Index(item.lower, lower) - if matchIndex == -1 { + if !matchesCwdFilter(item.cwd, cwdFilter) { continue } - preview := makePreview(item.content, matchIndex, len(q)) - results = append(results, Result{ - Date: item.date, - Timestamp: item.timestamp, - Cwd: item.cwd, - Path: item.path, - File: item.file, - Line: item.line, - Role: item.role, - Preview: preview, - sortTime: item.sortTime, - }) + if strings.Index(item.lower, lower) == -1 { + continue + } + matches = append(matches, item) + if key, ok := pairKeyForEntry(item); ok { + state := pairStates[key] + switch item.role { + case "user": + state.hasUserHit = true + case "assistant": + state.hasAssistantHit = true + } + pairStates[key] = state + } } - sort.SliceStable(results, func(i, j int) bool { - return results[i].sortTime.After(results[j].sortTime) + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].sortTime.After(matches[j].sortTime) }) - if len(results) > limit { - results = results[:limit] + + results := make([]Result, 0, limit) + for _, item := range matches { + if shouldSkipAssistantDuplicate(item, pairStates) { + continue + } + preview := makePreview(item.content, q) + results = append(results, Result{ + Date: item.date, + Timestamp: item.timestamp, + Cwd: item.cwd, + Path: item.path, + File: item.file, + Line: item.line, + Role: item.role, + Preview: preview, + PrevUser: item.prevUser, + NextAssistant: item.nextAsst, + PrevUserLine: item.prevLine, + NextAssistantLine: item.nextLine, + sortTime: item.sortTime, + }) + if len(results) >= limit { + break + } } + return results } +func pairKeyForEntry(item entry) (threadPairKey, bool) { + switch item.role { + case "user": + if item.nextLine <= 0 { + return threadPairKey{}, false + } + return threadPairKey{ + path: item.path, + file: item.file, + userLine: item.line, + assistantLine: item.nextLine, + }, true + case "assistant": + if item.prevLine <= 0 { + return threadPairKey{}, false + } + return threadPairKey{ + path: item.path, + file: item.file, + userLine: item.prevLine, + assistantLine: item.line, + }, true + default: + return threadPairKey{}, false + } +} + +func shouldSkipAssistantDuplicate(item entry, pairStates map[threadPairKey]threadPairState) bool { + if item.role != "assistant" { + return false + } + key, ok := pairKeyForEntry(item) + if !ok { + return false + } + state, ok := pairStates[key] + if !ok { + return false + } + return state.hasUserHit && state.hasAssistantHit +} + +func normalizeCwdFilter(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if value != "/" && strings.HasSuffix(value, "/") { + value = strings.TrimRight(value, "/") + } + if value != "\\" && strings.HasSuffix(value, "\\") { + value = strings.TrimRight(value, "\\") + } + return value +} + +func matchesCwdFilter(itemCwd string, cwdFilter string) bool { + if cwdFilter == "" { + return true + } + if itemCwd == "" { + return false + } + if itemCwd == cwdFilter { + return true + } + if cwdFilter == "/" { + return strings.HasPrefix(itemCwd, "/") + } + if cwdFilter == "\\" { + return strings.HasPrefix(itemCwd, "\\") + } + return strings.HasPrefix(itemCwd, cwdFilter+"/") || strings.HasPrefix(itemCwd, cwdFilter+"\\") +} + func buildEntries(file sessions.SessionFile) ([]entry, error) { session, err := sessions.ParseSession(file.Path) if err != nil { @@ -178,7 +308,42 @@ func buildEntries(file sessions.SessionFile) ([]entry, error) { cwd = file.Meta.Cwd } cwd = sessions.NormalizeCwd(cwd) - for _, item := range session.Items { + + prevUser := make([]string, len(session.Items)) + prevUserLine := make([]int, len(session.Items)) + lastUser := "" + lastUserLine := 0 + for i, item := range session.Items { + prevUser[i] = lastUser + prevUserLine[i] = lastUserLine + content := strings.TrimSpace(item.Content) + if content == "" { + continue + } + if item.Role == "user" && !sessions.IsAutoContextUserMessage(item.Content) { + lastUser = makeContextSnippet(content) + lastUserLine = item.Line + } + } + + nextAssistant := make([]string, len(session.Items)) + nextAssistantLine := make([]int, len(session.Items)) + nextAsst := "" + nextAsstLine := 0 + for i := len(session.Items) - 1; i >= 0; i-- { + nextAssistant[i] = nextAsst + nextAssistantLine[i] = nextAsstLine + content := strings.TrimSpace(session.Items[i].Content) + if content == "" { + continue + } + if session.Items[i].Role == "assistant" { + nextAsst = makeContextSnippet(content) + nextAsstLine = session.Items[i].Line + } + } + + for i, item := range session.Items { content := strings.TrimSpace(item.Content) if content == "" { continue @@ -195,47 +360,90 @@ func buildEntries(file sessions.SessionFile) ([]entry, error) { role: item.Role, content: content, lower: strings.ToLower(content), + prevUser: prevUser[i], + nextAsst: nextAssistant[i], + prevLine: prevUserLine[i], + nextLine: nextAssistantLine[i], }) } return entries, nil } -func makePreview(content string, matchIndex int, queryLen int) string { +func makePreview(content string, query string) string { cleaned := strings.ReplaceAll(content, "\r", " ") cleaned = strings.ReplaceAll(cleaned, "\n", " ") cleaned = strings.TrimSpace(cleaned) if cleaned == "" { return "" } - if matchIndex < 0 || matchIndex >= len(cleaned) || queryLen <= 0 { - return truncate(cleaned, snippetMax) + query = strings.TrimSpace(query) + if query == "" { + return truncateRunes(cleaned, snippetMax) + } + + lowerCleaned := strings.ToLower(cleaned) + lowerQuery := strings.ToLower(query) + matchIndex := strings.Index(lowerCleaned, lowerQuery) + if matchIndex == -1 { + return truncateRunes(cleaned, snippetMax) + } + + matchRuneIndex := runeOffsetForByteIndex(lowerCleaned, matchIndex) + queryRuneLen := utf8.RuneCountInString(lowerQuery) + if queryRuneLen <= 0 { + return truncateRunes(cleaned, snippetMax) } - start := matchIndex - snippetRadius + + runes := []rune(cleaned) + start := matchRuneIndex - snippetRadius if start < 0 { start = 0 } - end := matchIndex + queryLen + snippetRadius - if end > len(cleaned) { - end = len(cleaned) + end := matchRuneIndex + queryRuneLen + snippetRadius + if end > len(runes) { + end = len(runes) } - snippet := strings.TrimSpace(cleaned[start:end]) + snippet := strings.TrimSpace(string(runes[start:end])) if start > 0 { snippet = "..." + snippet } - if end < len(cleaned) { + if end < len(runes) { snippet = snippet + "..." } return snippet } -func truncate(value string, max int) string { - if len(value) <= max { +func makeContextSnippet(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + value = strings.Join(strings.Fields(value), " ") + return truncateRunes(value, contextMax) +} + +func truncateRunes(value string, max int) string { + if max <= 0 { + return value + } + runes := []rune(value) + if len(runes) <= max { return value } - if max <= 3 { - return value[:max] + if max > 3 { + return string(runes[:max-3]) + "..." + } + return string(runes[:max]) +} + +func runeOffsetForByteIndex(value string, byteIndex int) int { + if byteIndex <= 0 { + return 0 + } + if byteIndex >= len(value) { + return utf8.RuneCountInString(value) } - return value[:max-3] + "..." + return utf8.RuneCountInString(value[:byteIndex]) } func parseTimestamp(value string, fallback time.Time) time.Time { diff --git a/internal/search/index_test.go b/internal/search/index_test.go index 0b699eb..ef5fbd9 100644 --- a/internal/search/index_test.go +++ b/internal/search/index_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "unicode/utf8" "codex-manager/internal/sessions" ) @@ -63,6 +64,85 @@ func TestIndexSearch(t *testing.T) { } } +func TestSearchDeduplicatesConsecutiveUserAssistantHits(t *testing.T) { + baseDir := t.TempDir() + writeSessionFile(t, baseDir, "2024/01/02/consecutive.jsonl", []string{ + `{"timestamp":"2024-01-02T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"text","text":"キーワード を含む質問"}]}}`, + `{"timestamp":"2024-01-02T00:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"text","text":"キーワード を含む回答"}]}}`, + }) + + idx := sessions.NewIndex(baseDir) + if err := idx.Refresh(); err != nil { + t.Fatalf("refresh: %v", err) + } + + searchIdx := NewIndex() + if err := searchIdx.RefreshFrom(idx); err != nil { + t.Fatalf("search refresh: %v", err) + } + + results := searchIdx.Search("キーワード", 10) + if len(results) != 1 { + t.Fatalf("expected 1 deduplicated result, got %d", len(results)) + } + if results[0].Role != "user" { + t.Fatalf("expected user result to be kept, got %q", results[0].Role) + } + if results[0].Line != 1 { + t.Fatalf("expected user line 1, got %d", results[0].Line) + } + if results[0].NextAssistantLine != 2 { + t.Fatalf("expected next assistant line 2, got %d", results[0].NextAssistantLine) + } +} + +func TestSearchKeepsAssistantOnlyHit(t *testing.T) { + baseDir := t.TempDir() + writeSessionFile(t, baseDir, "2024/01/02/assistant-only.jsonl", []string{ + `{"timestamp":"2024-01-02T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"text","text":"質問だけ"}]}}`, + `{"timestamp":"2024-01-02T00:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"text","text":"キーワード を含む回答"}]}}`, + }) + + idx := sessions.NewIndex(baseDir) + if err := idx.Refresh(); err != nil { + t.Fatalf("refresh: %v", err) + } + + searchIdx := NewIndex() + if err := searchIdx.RefreshFrom(idx); err != nil { + t.Fatalf("search refresh: %v", err) + } + + results := searchIdx.Search("キーワード", 10) + if len(results) != 1 { + t.Fatalf("expected 1 assistant-only result, got %d", len(results)) + } + if results[0].Role != "assistant" { + t.Fatalf("expected assistant result, got %q", results[0].Role) + } + if results[0].Line != 2 { + t.Fatalf("expected assistant line 2, got %d", results[0].Line) + } +} + +func TestMakePreviewMultibyteBoundarySafe(t *testing.T) { + content := strings.Repeat("あ", 120) + "キーワード" + strings.Repeat("い", 120) + preview := makePreview(content, "キーワード") + + if preview == "" { + t.Fatalf("expected preview, got empty") + } + if !utf8.ValidString(preview) { + t.Fatalf("expected valid UTF-8 preview, got %q", preview) + } + if strings.ContainsRune(preview, '\uFFFD') { + t.Fatalf("expected preview without replacement rune, got %q", preview) + } + if !strings.Contains(preview, "キーワード") { + t.Fatalf("expected preview to contain query, got %q", preview) + } +} + func writeSessionFile(t *testing.T, baseDir, relPath string, lines []string) { t.Helper() fullPath := filepath.Join(baseDir, filepath.FromSlash(relPath)) diff --git a/internal/sessions/parser.go b/internal/sessions/parser.go index 2628c09..05c3397 100644 --- a/internal/sessions/parser.go +++ b/internal/sessions/parser.go @@ -180,6 +180,11 @@ func parseResponseItem(env envelope, lineText string, lineNum int, session *Sess item.Content = prettyJSON(string(env.Payload)) } item.Class = roleClass(payload.Role) + if payload.Role == "user" && isToolWarningUserMessage(item.Content) { + item.Role = "assistant" + item.Title = "Agent" + item.Class = roleClass("assistant") + } case "reasoning": item.Role = "assistant" item.Class = roleClass("assistant") @@ -222,6 +227,11 @@ func parseDirectMessage(lineText string, lineNum int, session *Session) *RenderI item.Content = prettyJSON(lineText) } item.Class = roleClass(payload.Role) + if payload.Role == "user" && isToolWarningUserMessage(item.Content) { + item.Role = "assistant" + item.Title = "Agent" + item.Class = roleClass("assistant") + } return &item } @@ -590,3 +600,32 @@ func isLegacyCwdOnly(text string) bool { } return true } + +func isToolWarningUserMessage(content string) bool { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return false + } + lines := strings.Split(trimmed, "\n") + nonEmpty := 0 + lastNonEmpty := "" + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + nonEmpty++ + lastNonEmpty = line + if nonEmpty > 1 { + return false + } + } + trimmed = lastNonEmpty + if !strings.HasPrefix(trimmed, "Warning: apply_patch was requested via ") { + return false + } + if !strings.HasSuffix(trimmed, "Use the apply_patch tool instead of exec_command.") { + return false + } + return true +} diff --git a/internal/sessions/parser_test.go b/internal/sessions/parser_test.go index 28c2fd7..a60d3bd 100644 --- a/internal/sessions/parser_test.go +++ b/internal/sessions/parser_test.go @@ -80,3 +80,61 @@ func TestParseSessionDirectFormat(t *testing.T) { t.Fatalf("unexpected reasoning content: %q", session.Items[2].Content) } } + +func TestReclassifyToolWarning(t *testing.T) { + base := t.TempDir() + filePath := filepath.Join(base, "session.jsonl") + data := "" + + "{\"timestamp\":\"2026-01-09T01:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Warning: apply_patch was requested via exec_command. Use the apply_patch tool instead of exec_command.\"}]}}\n" + + if err := os.WriteFile(filePath, []byte(data), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + session, err := ParseSession(filePath) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(session.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(session.Items)) + } + item := session.Items[0] + if item.Role != "assistant" { + t.Fatalf("expected assistant role, got %q", item.Role) + } + if item.Class != "role-assistant" { + t.Fatalf("expected role-assistant class, got %q", item.Class) + } + if item.Title != "Agent" { + t.Fatalf("expected Agent title, got %q", item.Title) + } +} + +func TestToolWarningWithExtraTextStaysUser(t *testing.T) { + base := t.TempDir() + filePath := filepath.Join(base, "session.jsonl") + data := "" + + "{\"timestamp\":\"2026-01-09T01:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Warning: apply_patch was requested via exec_command. Use the apply_patch tool instead of exec_command.\\n\\nExtra note\"}]}}\n" + + if err := os.WriteFile(filePath, []byte(data), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + session, err := ParseSession(filePath) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(session.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(session.Items)) + } + item := session.Items[0] + if item.Role != "user" { + t.Fatalf("expected user role, got %q", item.Role) + } + if item.Class != "role-user" { + t.Fatalf("expected role-user class, got %q", item.Class) + } + if item.Title != "User" { + t.Fatalf("expected User title, got %q", item.Title) + } +} diff --git a/internal/sessions/turn_aborted.go b/internal/sessions/turn_aborted.go new file mode 100644 index 0000000..f2316f3 --- /dev/null +++ b/internal/sessions/turn_aborted.go @@ -0,0 +1,30 @@ +package sessions + +import "strings" + +const ( + turnAbortedOpenTag = "" + turnAbortedCloseTag = "" +) + +// ExtractTurnAbortedMessage returns the message inside a block. +// It only matches when the content is a single turn_aborted block. +func ExtractTurnAbortedMessage(content string) (string, bool) { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return "", false + } + if !strings.HasPrefix(trimmed, turnAbortedOpenTag) { + return "", false + } + closeIdx := strings.Index(trimmed, turnAbortedCloseTag) + if closeIdx == -1 { + return "", false + } + rest := strings.TrimSpace(trimmed[closeIdx+len(turnAbortedCloseTag):]) + if rest != "" { + return "", false + } + body := strings.TrimSpace(trimmed[len(turnAbortedOpenTag):closeIdx]) + return body, true +} diff --git a/internal/web/server.go b/internal/web/server.go index e319422..27b4f24 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -111,11 +111,27 @@ type dirView struct { } type sessionView struct { - Name string - Size string - ModTime string - ResumeCommand string - Cwd string + Name string + Size string + ModTime string + ModTimeOnly string + ResumeCommand string + Cwd string + DateLabel string + DatePath string + LastUserSnippet string + LastAssistantSnippet string +} + +type sessionNavLink struct { + Path string + Title string +} + +type sessionNavView struct { + CwdLabel string + Prev *sessionNavLink + Next *sessionNavLink } type indexView struct { @@ -134,6 +150,16 @@ type dayView struct { Dirs []dirView SelectedCwd string SelectedCwdLabel string + FallbackDate *dateView + FallbackSessions []sessionView + FallbackDirs []dirView + Page int + TotalPages int + HasPrev bool + HasNext bool + PrevPage int + NextPage int + ShowAll bool View string ThemeClass string } @@ -141,6 +167,14 @@ type dayView struct { type dirPageView struct { Dir dirView Dates []dateView + Sessions []sessionView + Page int + TotalPages int + HasPrev bool + HasNext bool + PrevPage int + NextPage int + ShowAll bool ThemeClass string } @@ -154,20 +188,25 @@ type sessionPageView struct { ThemeClass string IsJSONL bool LastUserLine int + LastAgentLine int + LastItemLine int + CwdNav *sessionNavView } type itemView struct { - Line int - Timestamp string - Type string - Subtype string - Role string - Title string - Content string - Class string - AutoCtx bool - Markdown string - HTML template.HTML + Line int + Timestamp string + Type string + Subtype string + Role string + Title string + Content string + Class string + AutoCtx bool + IsTurnAborted bool + TurnAbortedMessage string + Markdown string + HTML template.HTML } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { @@ -200,6 +239,20 @@ func (s *Server) handleDir(w http.ResponseWriter, r *http.Request) { for _, file := range files { counts[file.Date]++ } + sort.Slice(files, func(i, j int) bool { + if files[i].ModTime.Equal(files[j].ModTime) { + dateI := files[i].Date.String() + dateJ := files[j].Date.String() + if dateI != dateJ { + return dateI > dateJ + } + return files[i].Name > files[j].Name + } + return files[i].ModTime.After(files[j].ModTime) + }) + page := parsePageParam(r) + showAll := parseBoolParam(r, "all") + sessionsView, pager := buildSessionViewsPage(files, page, 10, showAll) dates := s.idx.Dates() dateViews := make([]dateView, 0, len(counts)) @@ -222,6 +275,14 @@ func (s *Server) handleDir(w http.ResponseWriter, r *http.Request) { view := dirPageView{ Dir: dir, Dates: dateViews, + Sessions: sessionsView, + Page: pager.Page, + TotalPages: pager.TotalPages, + HasPrev: pager.HasPrev, + HasNext: pager.HasNext, + PrevPage: pager.PrevPage, + NextPage: pager.NextPage, + ShowAll: showAll, ThemeClass: s.themeClass, } @@ -241,33 +302,37 @@ func (s *Server) handleDay(w http.ResponseWriter, r *http.Request, parts []strin viewMode = "sessions" } - files := s.idx.SessionsByDate(date) - dirViews := buildDirViewsFromFiles(files) - - filtered := files - if selectedCwd != "" { - filtered = make([]sessions.SessionFile, 0, len(files)) - for _, file := range files { - if sessions.CwdForFile(file) == selectedCwd { - filtered = append(filtered, file) + requestedFiles := s.idx.SessionsByDate(date) + filtered := filterSessionFilesByCwd(requestedFiles, selectedCwd) + requestedViews := []sessionView{} + dirViews := buildDirViewsFromFiles(requestedFiles) + page := parsePageParam(r) + showAll := parseBoolParam(r, "all") + pager := paginationInfo{Page: page} + var fallbackDate *dateView + var fallbackSessions []sessionView + var fallbackDirs []dirView + if selectedCwd != "" && len(filtered) == 0 { + if prevDate, ok := previousDateKey(date); ok { + prevFiles := s.idx.SessionsByDate(prevDate) + prevFiltered := filterSessionFilesByCwd(prevFiles, selectedCwd) + if len(prevFiltered) > 0 { + fallbackDate = &dateView{ + Label: prevDate.String(), + Path: prevDate.Path(), + Count: len(prevFiles), + } + pageSessions, pageInfo := buildSessionViewsPage(prevFiltered, page, 10, showAll) + pager = pageInfo + fallbackSessions = pageSessions + fallbackDirs = buildDirViewsFromFiles(prevFiles) } } } - - views := make([]sessionView, 0, len(filtered)) - for _, file := range filtered { - resumeCommand := buildResumeCommand(file.Meta) - cwd := sessions.CwdForFile(file) - if cwd == sessions.UnknownCwd { - cwd = "" - } - views = append(views, sessionView{ - Name: file.Name, - Size: formatBytes(file.Size), - ModTime: formatTime(file.ModTime), - ResumeCommand: resumeCommand, - Cwd: cwd, - }) + if fallbackDate == nil { + pageSessions, pageInfo := buildSessionViewsPage(filtered, page, 10, showAll) + pager = pageInfo + requestedViews = pageSessions } selectedLabel := "" @@ -279,12 +344,22 @@ func (s *Server) handleDay(w http.ResponseWriter, r *http.Request, parts []strin Date: dateView{ Label: date.String(), Path: date.Path(), - Count: len(files), + Count: len(requestedFiles), }, - Sessions: views, + Sessions: requestedViews, Dirs: dirViews, SelectedCwd: selectedCwd, SelectedCwdLabel: selectedLabel, + FallbackDate: fallbackDate, + FallbackSessions: fallbackSessions, + FallbackDirs: fallbackDirs, + Page: pager.Page, + TotalPages: pager.TotalPages, + HasPrev: pager.HasPrev, + HasNext: pager.HasNext, + PrevPage: pager.PrevPage, + NextPage: pager.NextPage, + ShowAll: showAll, View: viewMode, ThemeClass: s.themeClass, } @@ -320,6 +395,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { } query := strings.TrimSpace(r.URL.Query().Get("query")) + cwdFilter := normalizeSearchCwdFilter(r.URL.Query().Get("cwd")) limit := 50 if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" { if parsed, err := strconv.Atoi(rawLimit); err == nil && parsed > 0 { @@ -332,7 +408,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { var results []search.Result if len(query) >= 2 { - results = s.search.Search(query, limit) + results = s.search.SearchWithCwd(query, limit, cwdFilter) } else { results = []search.Result{} } @@ -430,6 +506,13 @@ func formatTime(t time.Time) string { return t.Format("2006-01-02 15:04:05") } +func formatTimeOnly(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("15:04:05") +} + func formatScanTime(t time.Time) string { if t.IsZero() { return "never" @@ -437,6 +520,87 @@ func formatScanTime(t time.Time) string { return t.Format(time.RFC3339) } +type paginationInfo struct { + Page int + TotalPages int + HasPrev bool + HasNext bool + PrevPage int + NextPage int +} + +func parsePageParam(r *http.Request) int { + page := 1 + if rawPage := r.URL.Query().Get("page"); rawPage != "" { + if parsed, err := strconv.Atoi(rawPage); err == nil { + page = parsed + } + } + if page < 1 { + page = 1 + } + return page +} + +func parseBoolParam(r *http.Request, key string) bool { + value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get(key))) + switch value { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func paginateSessionFiles(files []sessions.SessionFile, page int, perPage int) ([]sessions.SessionFile, paginationInfo) { + info := paginationInfo{Page: page} + total := len(files) + if perPage <= 0 { + info.TotalPages = 1 + info.HasPrev = false + info.HasNext = false + info.PrevPage = 1 + info.NextPage = 1 + return files, info + } + if total == 0 { + info.Page = 1 + info.TotalPages = 0 + info.PrevPage = 1 + info.NextPage = 1 + return nil, info + } + totalPages := (total + perPage - 1) / perPage + if page > totalPages { + page = totalPages + } + if page < 1 { + page = 1 + } + start := (page - 1) * perPage + end := start + perPage + if start < 0 { + start = 0 + } + if end > total { + end = total + } + pageFiles := files[start:end] + info.Page = page + info.TotalPages = totalPages + info.HasPrev = page > 1 + info.HasNext = page < totalPages + info.PrevPage = 1 + info.NextPage = totalPages + if info.HasPrev { + info.PrevPage = page - 1 + } + if info.HasNext { + info.NextPage = page + 1 + } + return pageFiles, info +} + func (s *Server) buildIndexView(view string, heatMode string) indexView { heatMode = parseHeatMode(heatMode) dates := s.idx.Dates() @@ -483,6 +647,169 @@ func (s *Server) buildIndexView(view string, heatMode string) indexView { } } +func filterSessionFilesByCwd(files []sessions.SessionFile, cwd string) []sessions.SessionFile { + if cwd == "" { + return files + } + filtered := make([]sessions.SessionFile, 0, len(files)) + for _, file := range files { + if sessions.CwdForFile(file) == cwd { + filtered = append(filtered, file) + } + } + return filtered +} + +func buildSessionViews(files []sessions.SessionFile) []sessionView { + views := make([]sessionView, 0, len(files)) + for _, file := range files { + views = append(views, buildSessionView(file)) + } + return views +} + +func buildSessionView(file sessions.SessionFile) sessionView { + resumeCommand := buildResumeCommand(file.Meta) + cwd := sessions.CwdForFile(file) + if cwd == sessions.UnknownCwd { + cwd = "" + } + return sessionView{ + Name: file.Name, + Size: formatBytes(file.Size), + ModTime: formatTime(file.ModTime), + ResumeCommand: resumeCommand, + Cwd: cwd, + DateLabel: file.Date.String(), + DatePath: file.Date.Path(), + } +} + +func buildSessionViewsWithSnippets(files []sessions.SessionFile) []sessionView { + views := buildSessionViews(files) + for i, file := range files { + userSnippet, assistantSnippet, hasUser := extractLastSnippets(file) + if hasUser && userSnippet == "" { + userSnippet = "(empty)" + } + views[i].LastUserSnippet = userSnippet + views[i].LastAssistantSnippet = assistantSnippet + } + return views +} + +func buildSessionViewsPage(files []sessions.SessionFile, page int, perPage int, includeAll bool) ([]sessionView, paginationInfo) { + if includeAll { + pageFiles, pager := paginateSessionFiles(files, page, perPage) + return buildSessionViewsWithSnippets(pageFiles), pager + } + return buildSessionViewsPageFiltered(files, page, perPage) +} + +func buildSessionViewsPageFiltered(files []sessions.SessionFile, page int, perPage int) ([]sessionView, paginationInfo) { + if page < 1 { + page = 1 + } + if perPage <= 0 { + perPage = 10 + } + start := (page - 1) * perPage + end := start + perPage + total := 0 + foundNext := false + scannedAll := true + views := make([]sessionView, 0, perPage) + for _, file := range files { + userSnippet, assistantSnippet, hasUser := extractLastSnippets(file) + if !hasUser { + continue + } + if total >= start && total < end { + view := buildSessionView(file) + if userSnippet == "" { + userSnippet = "(empty)" + } + view.LastUserSnippet = userSnippet + view.LastAssistantSnippet = assistantSnippet + views = append(views, view) + } + if total >= end { + foundNext = true + scannedAll = false + break + } + total++ + } + totalPages := 0 + if scannedAll && total > 0 { + totalPages = (total + perPage - 1) / perPage + if page > totalPages { + return buildSessionViewsPageFiltered(files, totalPages, perPage) + } + } + info := paginationInfo{ + Page: page, + TotalPages: totalPages, + HasPrev: page > 1 && total > start, + HasNext: foundNext || (totalPages > 0 && page < totalPages), + PrevPage: 1, + NextPage: totalPages, + } + if info.HasPrev { + info.PrevPage = page - 1 + } + if info.HasNext { + info.NextPage = page + 1 + } else if totalPages > 0 { + info.NextPage = totalPages + } + return views, info +} + +func extractLastSnippets(file sessions.SessionFile) (string, string, bool) { + session, err := sessions.ParseSession(file.Path) + if err != nil { + return "", "", false + } + lastUser := "" + lastAssistant := "" + hasUser := false + for _, item := range session.Items { + switch item.Role { + case "user": + if sessions.IsAutoContextUserMessage(item.Content) { + continue + } + hasUser = true + lastUser = item.Content + case "assistant": + lastAssistant = item.Content + } + } + userSnippet := snippetFromContent(lastUser, 180) + assistantSnippet := snippetFromContent(lastAssistant, 180) + return userSnippet, assistantSnippet, hasUser +} + +func snippetFromContent(value string, max int) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + value = strings.Join(strings.Fields(value), " ") + if max <= 0 { + return value + } + runes := []rune(value) + if len(runes) <= max { + return value + } + if max > 3 { + return string(runes[:max-3]) + "..." + } + return string(runes[:max]) +} + func buildDirViewsFromFiles(files []sessions.SessionFile) []dirView { counts := make(map[string]int, len(files)) for _, file := range files { @@ -523,6 +850,31 @@ func buildDirViewsFromCounts(counts map[string]int, recentCounts map[string]int, return views } +func previousDateKey(date sessions.DateKey) (sessions.DateKey, bool) { + year, err := strconv.Atoi(date.Year) + if err != nil { + return sessions.DateKey{}, false + } + month, err := strconv.Atoi(date.Month) + if err != nil { + return sessions.DateKey{}, false + } + day, err := strconv.Atoi(date.Day) + if err != nil { + return sessions.DateKey{}, false + } + current := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.Local) + if current.Year() != year || int(current.Month()) != month || current.Day() != day { + return sessions.DateKey{}, false + } + prev := current.AddDate(0, 0, -1) + return sessions.DateKey{ + Year: fmt.Sprintf("%04d", prev.Year()), + Month: fmt.Sprintf("%02d", int(prev.Month())), + Day: fmt.Sprintf("%02d", prev.Day()), + }, true +} + func dirLabel(cwd string) string { if sessions.NormalizeCwd(cwd) == sessions.UnknownCwd { return "Unknown (no CWD)" @@ -626,6 +978,20 @@ func normalizeCwdParam(value string) string { return value } +func normalizeSearchCwdFilter(value string) string { + value = normalizeCwdParam(value) + if value == "" { + return "" + } + if value != "/" && strings.HasSuffix(value, "/") { + value = strings.TrimRight(value, "/") + } + if value != "\\" && strings.HasSuffix(value, "\\") { + value = strings.TrimRight(value, "\\") + } + return value +} + func buildResumeCommand(meta *sessions.SessionMeta) string { if meta == nil || meta.ID == "" { return "" @@ -666,10 +1032,19 @@ func (s *Server) buildSessionView(parts []string) (sessionPageView, error) { items := make([]itemView, 0, len(session.Items)) lastUserLine := 0 lastAnyUserLine := 0 + lastAgentLine := 0 + lastItemLine := 0 for _, item := range session.Items { autoCtx := item.Role == "user" && sessions.IsAutoContextUserMessage(item.Content) - renderText := item.Content + turnAbortedMessage, isTurnAborted := "", false if autoCtx { + if msg, ok := sessions.ExtractTurnAbortedMessage(item.Content); ok { + turnAbortedMessage = msg + isTurnAborted = true + } + } + renderText := item.Content + if autoCtx && !isTurnAborted { renderText = escapeAutoContextTags(renderText) } view := itemView{ @@ -688,18 +1063,28 @@ func (s *Server) buildSessionView(parts []string) (sessionPageView, error) { view.AutoCtx = true view.Class = strings.TrimSpace(view.Class + " auto-context") } + if isTurnAborted { + view.IsTurnAborted = true + view.TurnAbortedMessage = turnAbortedMessage + } if item.Role == "user" { lastAnyUserLine = item.Line if !autoCtx { lastUserLine = item.Line } } + if item.Role == "assistant" { + lastAgentLine = item.Line + } + lastItemLine = item.Line items = append(items, view) } if lastUserLine == 0 { lastUserLine = lastAnyUserLine } + cwdNav := buildSessionNav(s.idx, file) + view := sessionPageView{ Date: dateView{ Label: date.String(), @@ -707,22 +1092,83 @@ func (s *Server) buildSessionView(parts []string) (sessionPageView, error) { Count: 0, }, File: sessionView{ - Name: file.Name, - Size: formatBytes(file.Size), - ModTime: formatTime(file.ModTime), - Cwd: displayCwd(sessions.CwdForFile(file)), + Name: file.Name, + Size: formatBytes(file.Size), + ModTime: formatTime(file.ModTime), + ModTimeOnly: formatTimeOnly(file.ModTime), + Cwd: displayCwd(sessions.CwdForFile(file)), + DateLabel: date.String(), + DatePath: date.Path(), }, Meta: session.Meta, Items: items, AllMarkdown: renderSessionMarkdown(session.Items), ResumeCommand: buildResumeCommand(session.Meta), - ThemeClass: s.themeClass, - IsJSONL: strings.HasSuffix(strings.ToLower(file.Name), ".jsonl"), - LastUserLine: lastUserLine, + ThemeClass: s.themeClass, + IsJSONL: strings.HasSuffix(strings.ToLower(file.Name), ".jsonl"), + LastUserLine: lastUserLine, + LastAgentLine: lastAgentLine, + LastItemLine: lastItemLine, + CwdNav: cwdNav, } return view, nil } +func buildSessionNav(idx *sessions.Index, current sessions.SessionFile) *sessionNavView { + cwd := sessions.CwdForFile(current) + if sessions.NormalizeCwd(cwd) == sessions.UnknownCwd { + return nil + } + files := idx.SessionsByCwd(cwd) + if len(files) < 2 { + return nil + } + sort.Slice(files, func(i, j int) bool { + if files[i].ModTime.Equal(files[j].ModTime) { + dateI := files[i].Date.String() + dateJ := files[j].Date.String() + if dateI != dateJ { + return dateI < dateJ + } + return files[i].Name < files[j].Name + } + return files[i].ModTime.Before(files[j].ModTime) + }) + currentIndex := -1 + for i := range files { + if files[i].Path == current.Path { + currentIndex = i + break + } + } + if currentIndex == -1 { + return nil + } + nav := &sessionNavView{CwdLabel: dirLabel(cwd)} + if currentIndex > 0 { + nav.Prev = buildSessionNavLink(files[currentIndex-1]) + } + if currentIndex+1 < len(files) { + nav.Next = buildSessionNavLink(files[currentIndex+1]) + } + if nav.Prev == nil && nav.Next == nil { + return nil + } + return nav +} + +func buildSessionNavLink(file sessions.SessionFile) *sessionNavLink { + title := fmt.Sprintf("%s / %s", file.Date.String(), file.Name) + mod := formatTime(file.ModTime) + if mod != "" { + title = title + " (" + mod + ")" + } + return &sessionNavLink{ + Path: "/" + file.Date.Path() + "/" + file.Name + "#last-user", + Title: title, + } +} + func themeClass(theme int) string { switch theme { case 1: diff --git a/internal/web/share.go b/internal/web/share.go index 535c7fc..3129c4a 100644 --- a/internal/web/share.go +++ b/internal/web/share.go @@ -1,6 +1,7 @@ package web import ( + "fmt" "net/http" "os" "path/filepath" @@ -16,7 +17,15 @@ func NewShareServer(shareDir string) http.Handler { } path := strings.TrimPrefix(r.URL.Path, "/") - if path == "" || strings.Contains(path, "/") || strings.Contains(path, "\\") || strings.Contains(path, "..") { + if path == "" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodGet { + _, _ = fmt.Fprint(w, "Codex Manager Share Server

    Codex Manager Share Server

    This server only serves exact share filenames. Use the Share button in the main UI and open the generated URL, for example:

    http://localhost:8081/<uuid>.html

    ") + } + return + } + if strings.Contains(path, "/") || strings.Contains(path, "\\") || strings.Contains(path, "..") { http.NotFound(w, r) return }