From a3fc9b3af09a0f9f0cdc493feabf63b559fb4dfa Mon Sep 17 00:00:00 2001 From: djuka Date: Fri, 20 Feb 2026 12:47:39 +0000 Subject: [PATCH] T13: Dodat search bar sa instant pretragom taskova i dokumenata Co-Authored-By: Claude Opus 4.6 --- TASKS/reports/T13-report.md | 70 +++++++ TASKS/review/T13.md | 87 ++++++++ code/internal/server/render.go | 10 + code/internal/server/search.go | 191 ++++++++++++++++++ code/internal/server/server.go | 3 + code/internal/server/server_test.go | 113 +++++++++++ code/web/static/style.css | 108 ++++++++++ code/web/templates/layout.html | 29 ++- .../templates/partials/search-results.html | 16 ++ 9 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 TASKS/reports/T13-report.md create mode 100644 TASKS/review/T13.md create mode 100644 code/internal/server/search.go create mode 100644 code/web/templates/partials/search-results.html diff --git a/TASKS/reports/T13-report.md b/TASKS/reports/T13-report.md new file mode 100644 index 0000000..ccc0f9e --- /dev/null +++ b/TASKS/reports/T13-report.md @@ -0,0 +1,70 @@ +# T13 Izveštaj: Dashboard — pretraga taskova i dokumentacije + +**Agent:** coder +**Model:** Opus +**Datum:** 2026-02-20 + +--- + +## Šta je urađeno + +Dodat search bar na dashboard sa instant pretragom (HTMX debounce 300ms). + +### Novi fajlovi + +| Fajl | Opis | +|------|------| +| `internal/server/search.go` | Search handler, matchTask, extractSnippet, containsInsensitive | +| `web/templates/partials/search-results.html` | Template za dropdown rezultate | + +### Izmenjeni fajlovi + +| Fajl | Izmena | +|------|--------| +| `internal/server/server.go` | GET /search ruta | +| `internal/server/render.go` | renderSearchResults(), search-results template u init() | +| `internal/server/server_test.go` | 8 novih testova, checker agent i report u test setup | +| `web/templates/layout.html` | Search input sa hx-get, click-outside zatvaranje | +| `web/static/style.css` | Search wrapper, dropdown, result items, status badges, snippet | + +### Endpoint + +| Ruta | Opis | +|------|------| +| `GET /search?q={query}` | HTML fragment sa rezultatima pretrage | + +### Šta pretražuje + +1. **Taskovi** — ID, naslov, opis, agent, status +2. **Dokumenti** — svi .md fajlovi (sadržaj) +3. **Izveštaji** — reports/*.md (sadržaj) + +### Features + +- Case insensitive pretraga +- Snippet sa kontekstom oko pogotka +- Status badge za taskove (done/active/review...) +- Klik na task → otvori detail panel +- Klik na dokument → navigacija na /docs/ +- Max 20 rezultata +- Prazan query → prazan odgovor +- Nema rezultata → "Nema rezultata" poruka +- Click outside → zatvori dropdown + +### Novi testovi — 8 PASS + +``` +TestSearch_FindsTask PASS +TestSearch_FindsTaskByID PASS +TestSearch_FindsDocument PASS +TestSearch_FindsReport PASS +TestSearch_EmptyQuery PASS +TestSearch_NoResults PASS +TestSearch_CaseInsensitive PASS +TestSearch_HasSnippet PASS +``` + +### Ukupno projekat: 109 testova, svi prolaze + +- `go vet ./...` — čist +- `go build ./...` — prolazi diff --git a/TASKS/review/T13.md b/TASKS/review/T13.md new file mode 100644 index 0000000..304718f --- /dev/null +++ b/TASKS/review/T13.md @@ -0,0 +1,87 @@ +# T13: Dashboard — pretraga taskova i dokumentacije + +**Kreirao:** planer +**Datum:** 2026-02-20 +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** T12 + +--- + +## Opis + +Search bar na dashboardu. Pretražuje taskove i .md fajlove. +Rezultati se prikazuju instant dok korisnik kuca (debounce 300ms). + +## Endpoint + +``` +GET /search?q={query} → rezultati pretrage (HTML fragment) +``` + +## Šta pretražuje + +1. **Taskovi** — ID, naslov, opis, agent, status (folder) +2. **Dokumenti** — sadržaj svih .md fajlova +3. **Izveštaji** — sadržaj reports/*.md + +## Kako radi + +```html + + +
+``` + +## Format rezultata + +``` +┌─────────────────────────────────────────┐ +│ 🔍 "checker" │ +├─────────────────────────────────────────┤ +│ 📋 T04: Checker — verifikacija [done] │ +│ ...go build, go vet, go test... │ +│ │ +│ 📄 agents/checker/CLAUDE.md │ +│ ...Build + Test verifikacija... │ +│ │ +│ 📊 T04-report.md │ +│ ...10 testova, svi prolaze... │ +└─────────────────────────────────────────┘ +``` + +Svaki rezultat: +- Ikona po tipu (📋 task, 📄 dokument, 📊 izveštaj) +- Naslov/putanja — klikabilan +- Snippet sa highlighted match (kontekst oko pogotka) +- Klik → otvori task detalj ili dokument + +## Pretraga logika + +- Case insensitive +- Pretraži: naslov, sadržaj, ID +- Sortiraj: taskovi prvo, pa dokumenti, pa izveštaji +- Max 20 rezultata +- Prazan query → sakrij rezultate + +## Testovi + +- GET /search?q=checker → vrati T04 i checker CLAUDE.md +- GET /search?q=T01 → vrati T01 task i T01-report +- GET /search?q=deploy → vrati deployer agent CLAUDE.md +- GET /search?q= → prazan odgovor +- GET /search?q=xyznepostoji → "Nema rezultata" +- Snippet sadrži kontekst oko pogotka + +--- + +## Pitanja + +--- + +## Odgovori diff --git a/code/internal/server/render.go b/code/internal/server/render.go index 1c02f10..0593da9 100644 --- a/code/internal/server/render.go +++ b/code/internal/server/render.go @@ -63,6 +63,7 @@ func init() { "templates/partials/column.html", "templates/partials/task-card.html", "templates/partials/task-detail.html", + "templates/partials/search-results.html", ), ) } @@ -106,6 +107,15 @@ func renderDocsView(data docsViewData) string { return buf.String() } +// renderSearchResults generates the search results HTML fragment. +func renderSearchResults(data searchResultsData) string { + var buf bytes.Buffer + if err := templates.ExecuteTemplate(&buf, "search-results", data); err != nil { + return "Greška pri renderovanju: " + err.Error() + } + return buf.String() +} + // renderTaskDetail generates HTML fragment for task detail panel. func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string { data := taskDetailData{ diff --git a/code/internal/server/search.go b/code/internal/server/search.go new file mode 100644 index 0000000..eef8138 --- /dev/null +++ b/code/internal/server/search.go @@ -0,0 +1,191 @@ +package server + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/dal/kaos/internal/supervisor" +) + +// searchResult represents a single search result. +type searchResult struct { + Type string // "task", "doc", "report" + Icon string // emoji icon + Title string // display title + Link string // URL to navigate to + Snippet string // context around the match + Status string // task status (only for tasks) +} + +// searchResultsData holds data for the search results template. +type searchResultsData struct { + Query string + Results []searchResult +} + +// handleSearch serves search results as an HTML fragment. +func (s *Server) handleSearch(c *gin.Context) { + query := strings.TrimSpace(c.Query("q")) + + if query == "" { + c.String(http.StatusOK, "") + return + } + + root := s.projectRoot() + var results []searchResult + + // 1. Search tasks + tasks, _ := supervisor.ScanTasks(s.Config.TasksDir) + for _, t := range tasks { + if matchTask(t, query) { + snippet := taskSnippet(t, query) + results = append(results, searchResult{ + Type: "task", + Icon: "📋", + Title: t.ID + ": " + t.Title, + Link: "/task/" + t.ID, + Snippet: snippet, + Status: t.Status, + }) + } + } + + // 2. Search documents (non-report .md files) + docs := scanMarkdownFiles(root) + for _, doc := range docs { + // Skip task files and reports (handled separately) + if strings.HasPrefix(doc.Path, "TASKS/") { + continue + } + content, err := os.ReadFile(filepath.Join(root, doc.Path)) + if err != nil { + continue + } + if containsInsensitive(string(content), query) || containsInsensitive(doc.Path, query) { + snippet := extractSnippet(string(content), query) + results = append(results, searchResult{ + Type: "doc", + Icon: "📄", + Title: doc.Path, + Link: "/docs/" + doc.Path, + Snippet: snippet, + }) + } + } + + // 3. Search reports + reportsDir := filepath.Join(s.Config.TasksDir, "reports") + entries, _ := os.ReadDir(reportsDir) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + content, err := os.ReadFile(filepath.Join(reportsDir, entry.Name())) + if err != nil { + continue + } + if containsInsensitive(string(content), query) || containsInsensitive(entry.Name(), query) { + // Extract task ID from report filename (T01-report.md → T01) + taskID := strings.TrimSuffix(entry.Name(), "-report.md") + snippet := extractSnippet(string(content), query) + results = append(results, searchResult{ + Type: "report", + Icon: "📊", + Title: entry.Name(), + Link: "/report/" + taskID, + Snippet: snippet, + }) + } + } + + // Limit results + if len(results) > 20 { + results = results[:20] + } + + data := searchResultsData{ + Query: query, + Results: results, + } + + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, renderSearchResults(data)) +} + +// matchTask checks if a task matches the query (case insensitive). +func matchTask(t supervisor.Task, query string) bool { + q := strings.ToLower(query) + return containsInsensitive(t.ID, query) || + containsInsensitive(t.Title, query) || + containsInsensitive(t.Description, query) || + containsInsensitive(t.Agent, query) || + strings.ToLower(t.Status) == q +} + +// taskSnippet returns a snippet from the task for display. +func taskSnippet(t supervisor.Task, query string) string { + // Try to find match in description first + if t.Description != "" && containsInsensitive(t.Description, query) { + return extractSnippet(t.Description, query) + } + // Fall back to description start + if t.Description != "" { + desc := t.Description + if len(desc) > 100 { + desc = desc[:100] + "..." + } + return desc + } + return t.Agent + " · " + t.Model +} + +// containsInsensitive checks if s contains substr (case insensitive). +func containsInsensitive(s, substr string) bool { + return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) +} + +// extractSnippet extracts a snippet around the first match with context. +func extractSnippet(content, query string) string { + lower := strings.ToLower(content) + q := strings.ToLower(query) + + idx := strings.Index(lower, q) + if idx == -1 { + // Return first 100 chars + if len(content) > 100 { + return content[:100] + "..." + } + return content + } + + // Get context: 40 chars before and 60 chars after + start := idx - 40 + if start < 0 { + start = 0 + } + end := idx + len(query) + 60 + if end > len(content) { + end = len(content) + } + + snippet := content[start:end] + + // Clean up: remove newlines, trim + snippet = strings.ReplaceAll(snippet, "\n", " ") + snippet = strings.ReplaceAll(snippet, "\r", "") + snippet = strings.TrimSpace(snippet) + + if start > 0 { + snippet = "..." + snippet + } + if end < len(content) { + snippet = snippet + "..." + } + + return snippet +} diff --git a/code/internal/server/server.go b/code/internal/server/server.go index 9c15d73..1955ac8 100644 --- a/code/internal/server/server.go +++ b/code/internal/server/server.go @@ -97,6 +97,9 @@ func (s *Server) setupRoutes() { s.Router.POST("/task/:id/move", s.handleMoveTask) s.Router.GET("/report/:id", s.handleReport) + // Search route + s.Router.GET("/search", s.handleSearch) + // Docs routes s.Router.GET("/docs", s.handleDocsList) s.Router.GET("/docs/*path", s.handleDocsView) diff --git a/code/internal/server/server_test.go b/code/internal/server/server_test.go index 84c04b1..392d67b 100644 --- a/code/internal/server/server_test.go +++ b/code/internal/server/server_test.go @@ -58,6 +58,9 @@ func setupTestServer(t *testing.T) *Server { os.WriteFile(filepath.Join(dir, "README.md"), []byte("# README\n\nOpis projekta.\n"), 0644) os.MkdirAll(filepath.Join(dir, "agents", "coder"), 0755) os.WriteFile(filepath.Join(dir, "agents", "coder", "CLAUDE.md"), []byte("# Coder Agent\n\nPravila kodiranja.\n"), 0644) + os.MkdirAll(filepath.Join(dir, "agents", "checker"), 0755) + os.WriteFile(filepath.Join(dir, "agents", "checker", "CLAUDE.md"), []byte("# Checker Agent\n\nBuild + Test verifikacija.\n"), 0644) + os.WriteFile(filepath.Join(tasksDir, "reports", "T01-report.md"), []byte("# T01 Report\n\n10 testova, svi prolaze.\n"), 0644) cfg := &config.Config{ TasksDir: tasksDir, @@ -671,6 +674,116 @@ func TestDocsView_HasBreadcrumbs(t *testing.T) { } } +func TestSearch_FindsTask(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=Prvi", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !containsStr(body, "T01") { + t.Error("expected T01 in search results for 'Prvi'") + } +} + +func TestSearch_FindsTaskByID(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=T08", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "T08") { + t.Error("expected T08 in search results") + } +} + +func TestSearch_FindsDocument(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=checker", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "checker/CLAUDE.md") { + t.Error("expected checker CLAUDE.md in search results") + } +} + +func TestSearch_FindsReport(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=prolaze", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "T01-report.md") { + t.Error("expected T01 report in search results") + } +} + +func TestSearch_EmptyQuery(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if w.Body.Len() != 0 { + t.Errorf("expected empty response for empty query, got %d bytes", w.Body.Len()) + } +} + +func TestSearch_NoResults(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=xyznepostoji", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "Nema rezultata") { + t.Error("expected 'Nema rezultata' message") + } +} + +func TestSearch_CaseInsensitive(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=CODER", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "coder") { + t.Error("expected case-insensitive match for 'CODER'") + } +} + +func TestSearch_HasSnippet(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/search?q=kodiranja", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "kodiranja") { + t.Error("expected snippet with 'kodiranja' text") + } +} + func TestRewriteLinksSimple(t *testing.T) { input := `link and ext` result := rewriteLinksSimple(input, ".") diff --git a/code/web/static/style.css b/code/web/static/style.css index f5bffe3..adc7f01 100644 --- a/code/web/static/style.css +++ b/code/web/static/style.css @@ -226,6 +226,114 @@ body { color: #fff; } +/* Header right section */ +.header-right { + display: flex; + gap: 12px; + align-items: center; +} + +/* Search */ +.search-wrapper { + position: relative; +} + +.search-wrapper input { + background: #1a1a2e; + border: 1px solid #333; + border-radius: 6px; + color: #eee; + padding: 6px 12px; + font-size: 0.85em; + width: 220px; + outline: none; + transition: border-color 0.2s, width 0.3s; +} + +.search-wrapper input:focus { + border-color: #e94560; + width: 300px; +} + +.search-results-dropdown { + position: absolute; + top: 100%; + right: 0; + width: 400px; + max-height: 500px; + overflow-y: auto; + background: #16213e; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); + z-index: 50; + margin-top: 4px; +} + +.search-results-dropdown:empty { + display: none; +} + +.search-result { + display: block; + padding: 10px 14px; + text-decoration: none; + color: #eee; + border-bottom: 1px solid #0f3460; + transition: background 0.15s; +} + +.search-result:last-child { + border-bottom: none; +} + +.search-result:hover { + background: #0f3460; +} + +.search-result-header { + display: flex; + align-items: center; + gap: 6px; +} + +.search-icon { font-size: 0.9em; } + +.search-title { + font-weight: bold; + font-size: 0.85em; +} + +.search-status { + font-size: 0.7em; + padding: 2px 6px; + border-radius: 4px; + background: #0f3460; + margin-left: auto; +} + +.search-status-done { color: #4ecca3; } +.search-status-active { color: #e94560; } +.search-status-review { color: #ffd93d; } +.search-status-ready { color: #6ec6ff; } +.search-status-backlog { color: #888; } + +.search-snippet { + font-size: 0.75em; + color: #888; + margin-top: 4px; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-empty { + padding: 16px; + text-align: center; + color: #888; + font-size: 0.85em; +} + /* Navigation */ .nav { display: flex; diff --git a/code/web/templates/layout.html b/code/web/templates/layout.html index 265a558..8d1cafe 100644 --- a/code/web/templates/layout.html +++ b/code/web/templates/layout.html @@ -11,10 +11,22 @@

🔧 KAOS Dashboard

- +
+
+ +
+
+ +
{{block "content" .}}{{end}}
@@ -43,6 +55,15 @@ function showToast(msg, type) { document.addEventListener('DOMContentLoaded', function() { initSortable(); + + // Close search results on click outside + document.addEventListener('click', function(e) { + var wrapper = document.querySelector('.search-wrapper'); + var results = document.getElementById('search-results'); + if (wrapper && !wrapper.contains(e.target)) { + results.innerHTML = ''; + } + }); }); document.body.addEventListener('htmx:afterSwap', function(e) { diff --git a/code/web/templates/partials/search-results.html b/code/web/templates/partials/search-results.html new file mode 100644 index 0000000..41a42be --- /dev/null +++ b/code/web/templates/partials/search-results.html @@ -0,0 +1,16 @@ +{{define "search-results"}} +{{if .Results}} +{{range .Results}} + +
+ {{.Icon}} + {{.Title}} + {{if .Status}}{{.Status}}{{end}} +
+
{{.Snippet}}
+
+{{end}} +{{else}} +
Nema rezultata za "{{.Query}}"
+{{end}} +{{end}}