From 510b75c0bfcaafe4e5a4326867c1291712160ac6 Mon Sep 17 00:00:00 2001 From: djuka Date: Sat, 21 Feb 2026 04:32:34 +0000 Subject: [PATCH] =?UTF-8?q?Konzola:=20dinami=C4=8Dke=20task=20sesije=20sa?= =?UTF-8?q?=20PTY=20per=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Zamena fiksnih 2 sesija sa taskSessionManager (map po task ID) - "Pusti" pokreće interaktivni claude u PTY, šalje task prompt - "Proveri" pokreće review claude sesiju za task u review/ - WS se konektuje na postojeću PTY sesiju po task ID-u - Konzola stranica dinamički prikazuje terminale za aktivne sesije - Replay buffer za reconnect na postojeće sesije - Novi testovi za session manager, prompt buildere, review endpoint Co-Authored-By: Claude Opus 4.6 --- code/internal/server/console.go | 547 ++++++++----------- code/internal/server/pty_session.go | 11 +- code/internal/server/server.go | 27 +- code/internal/server/server_test.go | 267 +++++---- code/internal/server/submit.go | 23 +- code/internal/server/ws.go | 51 +- code/web/templates/console.html | 201 ++++--- code/web/templates/layout.html | 46 +- code/web/templates/partials/task-detail.html | 3 +- 9 files changed, 594 insertions(+), 582 deletions(-) diff --git a/code/internal/server/console.go b/code/internal/server/console.go index 198271b..e27a915 100644 --- a/code/internal/server/console.go +++ b/code/internal/server/console.go @@ -1,385 +1,161 @@ package server import ( - "encoding/json" "fmt" "log" "net/http" "os" - "os/exec" - "strconv" - "strings" + "path/filepath" "sync" "time" "github.com/gin-gonic/gin" ) -// sessionState represents the state of a console session. -type sessionState struct { - mu sync.Mutex - status string // "idle" or "running" - cmd *exec.Cmd - ptySess *consolePTYSession - execID string - taskID string // which task is being worked on (if any) - history []historyEntry - output []string - listeners map[chan string]bool +// taskSession represents a PTY session tied to a specific task. +type taskSession struct { + TaskID string + Type string // "work" or "review" + PTY *consolePTYSession + Started time.Time } -// historyEntry represents a command in the session history. -type historyEntry struct { - Command string `json:"command"` - ExecID string `json:"exec_id"` - Timestamp string `json:"timestamp"` - Status string `json:"status"` // "running", "done", "error", "killed" +// taskSessionResponse is the JSON representation of a task session. +type taskSessionResponse struct { + TaskID string `json:"task_id"` + Type string `json:"type"` + Status string `json:"status"` // "running" or "exited" + Started string `json:"started"` } -// execRequest is the JSON body for starting a command. -type execRequest struct { - Cmd string `json:"cmd"` - Session int `json:"session"` +// taskSessionManager manages dynamic PTY sessions per task. +type taskSessionManager struct { + mu sync.RWMutex + sessions map[string]*taskSession } -// execResponse is the JSON response after starting a command. -type execResponse struct { - ExecID string `json:"exec_id"` - Session int `json:"session"` -} - -// sessionStatus represents the status of a session for the API. -type sessionStatus struct { - Session int `json:"session"` - Status string `json:"status"` - TaskID string `json:"task_id,omitempty"` - ExecID string `json:"exec_id,omitempty"` -} - -// consoleManager manages the two console sessions. -type consoleManager struct { - sessions [2]*sessionState - mu sync.Mutex - counter int -} - -// newConsoleManager creates a new console manager with two idle sessions. -func newConsoleManager() *consoleManager { - return &consoleManager{ - sessions: [2]*sessionState{ - {status: "idle", listeners: make(map[chan string]bool)}, - {status: "idle", listeners: make(map[chan string]bool)}, - }, +func newTaskSessionManager() *taskSessionManager { + return &taskSessionManager{ + sessions: make(map[string]*taskSession), } } -// nextExecID generates a unique execution ID. -func (cm *consoleManager) nextExecID() string { - cm.mu.Lock() - defer cm.mu.Unlock() - cm.counter++ - return fmt.Sprintf("exec-%d-%d", time.Now().Unix(), cm.counter) +// sessionKey returns a unique key for a task session. +func sessionKey(taskID, sessionType string) string { + if sessionType == "review" { + return taskID + "-review" + } + return taskID } -// getSession returns a session by index (0 or 1). -func (cm *consoleManager) getSession(idx int) *sessionState { - if idx < 0 || idx > 1 { - return nil +// startSession spawns an interactive claude PTY and sends the task prompt. +func (sm *taskSessionManager) startSession(taskID, sessionType, projectDir, prompt string) (*taskSession, error) { + key := sessionKey(taskID, sessionType) + + sm.mu.Lock() + if _, exists := sm.sessions[key]; exists { + sm.mu.Unlock() + return nil, fmt.Errorf("sesija %s već postoji", key) } - return cm.sessions[idx] -} + sm.mu.Unlock() -// handleConsoleExec starts a command in a session. -func (s *Server) handleConsoleExec(c *gin.Context) { - var req execRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidan JSON: " + err.Error()}) - return - } - - if req.Session < 1 || req.Session > 2 { - c.JSON(http.StatusBadRequest, gin.H{"error": "sesija mora biti 1 ili 2"}) - return - } - - sessionIdx := req.Session - 1 - session := s.console.getSession(sessionIdx) - - session.mu.Lock() - if session.status == "running" { - session.mu.Unlock() - c.JSON(http.StatusConflict, gin.H{"error": "sesija je zauzeta"}) - return - } - - execID := s.console.nextExecID() - session.status = "running" - session.execID = execID - session.output = nil - session.mu.Unlock() - - // Add to history - entry := historyEntry{ - Command: req.Cmd, - ExecID: execID, - Timestamp: timeNow(), - Status: "running", - } - - session.mu.Lock() - session.history = append(session.history, entry) - if len(session.history) > 50 { - session.history = session.history[len(session.history)-50:] - } - session.mu.Unlock() - - // Start the command in background - go s.runCommand(session, req.Cmd, execID) - - c.JSON(http.StatusOK, execResponse{ - ExecID: execID, - Session: req.Session, - }) -} - -// cleanEnv returns the current environment with CLAUDECODE removed, -// so child claude processes don't inherit the parent's session. -func cleanEnv() []string { - var env []string - for _, e := range os.Environ() { - if !strings.HasPrefix(e, "CLAUDECODE=") { - env = append(env, e) - } - } - return env -} - -// runCommand spawns a PTY-backed claude CLI process and monitors it. -func (s *Server) runCommand(session *sessionState, command, execID string) { - ptySess, err := spawnConsolePTY(s.projectRoot(), command) + ptySess, err := spawnTaskPTY(projectDir) if err != nil { - log.Printf("PTY spawn error for %s: %v", execID, err) - s.finishSession(session, execID, "error") - return + return nil, err } - log.Printf("PTY spawned for %s (PID %d)", execID, ptySess.Cmd.Process.Pid) - session.mu.Lock() - session.cmd = ptySess.Cmd - session.ptySess = ptySess - session.mu.Unlock() - - // Wait for process to exit - <-ptySess.Done() - - status := "done" - if ptySess.Cmd.ProcessState != nil && !ptySess.Cmd.ProcessState.Success() { - status = "error" + sess := &taskSession{ + TaskID: taskID, + Type: sessionType, + PTY: ptySess, + Started: time.Now(), } - log.Printf("PTY finished for %s (status: %s)", execID, status) - // Note: we do NOT clear session.ptySess here — the WS handler - // needs it for replay buffer even after the process exits. - // It gets replaced when a new command starts. + sm.mu.Lock() + sm.sessions[key] = sess + sm.mu.Unlock() - s.finishSession(session, execID, status) -} + log.Printf("Session[%s]: started (PID %d)", key, ptySess.Cmd.Process.Pid) -// sendToSession sends a line to all listeners and stores in output buffer. -func (s *Server) sendToSession(session *sessionState, line string) { - session.mu.Lock() - defer session.mu.Unlock() + // Send the task prompt after claude initializes + go func() { + subID := fmt.Sprintf("init-%d", time.Now().UnixNano()) + ch := ptySess.Subscribe(subID) - session.output = append(session.output, line) - - for ch := range session.listeners { + timer := time.NewTimer(30 * time.Second) select { - case ch <- line: - default: + case <-ch: + // Claude is alive and producing output + case <-timer.C: + log.Printf("Session[%s]: timeout waiting for claude to start", key) + case <-ptySess.Done(): + log.Printf("Session[%s]: claude exited before producing output", key) + ptySess.Unsubscribe(subID) + return } - } -} + timer.Stop() + ptySess.Unsubscribe(subID) -// finishSession marks a session as idle and notifies listeners. -func (s *Server) finishSession(session *sessionState, execID, status string) { - session.mu.Lock() - defer session.mu.Unlock() + // Let claude fully render its welcome screen + time.Sleep(2 * time.Second) - session.status = "idle" - session.cmd = nil - - // Update history entry status - for i := len(session.history) - 1; i >= 0; i-- { - if session.history[i].ExecID == execID { - session.history[i].Status = status - break - } - } - - // Notify listeners that stream is done - for ch := range session.listeners { - select { - case ch <- "[DONE]": - default: - } - } -} - -// handleConsoleStream serves an SSE stream for a command execution. -func (s *Server) handleConsoleStream(c *gin.Context) { - execID := c.Param("id") - - // Find which session has this exec ID - var session *sessionState - for i := 0; i < 2; i++ { - sess := s.console.getSession(i) - sess.mu.Lock() - if sess.execID == execID { - session = sess - sess.mu.Unlock() - break - } - sess.mu.Unlock() - } - - if session == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"}) - return - } - - // Set SSE headers - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - - // Create listener channel - ch := make(chan string, 100) - - session.mu.Lock() - // Send buffered output first - for _, line := range session.output { - fmt.Fprintf(c.Writer, "data: %s\n\n", line) - } - c.Writer.Flush() - - // If already done, send done event and return - if session.status == "idle" && session.execID == execID { - session.mu.Unlock() - fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n") - c.Writer.Flush() - return - } - - session.listeners[ch] = true - session.mu.Unlock() - - // Clean up on disconnect - defer func() { - session.mu.Lock() - delete(session.listeners, ch) - session.mu.Unlock() + // Type the prompt + log.Printf("Session[%s]: sending prompt (%d bytes)", key, len(prompt)) + ptySess.WriteInput([]byte(prompt + "\n")) }() - notify := c.Request.Context().Done() + return sess, nil +} - for { +// getSessionByKey returns a session by its full key. +func (sm *taskSessionManager) getSessionByKey(key string) *taskSession { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.sessions[key] +} + +// listSessions returns all active sessions. +func (sm *taskSessionManager) listSessions() []taskSessionResponse { + sm.mu.RLock() + defer sm.mu.RUnlock() + + result := make([]taskSessionResponse, 0, len(sm.sessions)) + for _, sess := range sm.sessions { + status := "running" select { - case <-notify: - return - case line := <-ch: - if line == "[DONE]" { - fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n") - c.Writer.Flush() - return - } - fmt.Fprintf(c.Writer, "data: %s\n\n", line) - c.Writer.Flush() + case <-sess.PTY.Done(): + status = "exited" + default: } + + result = append(result, taskSessionResponse{ + TaskID: sess.TaskID, + Type: sess.Type, + Status: status, + Started: sess.Started.Format("15:04:05"), + }) } + return result } -// handleConsoleKill kills the running process in a session. -func (s *Server) handleConsoleKill(c *gin.Context) { - sessionNum, err := strconv.Atoi(c.Param("session")) - if err != nil || sessionNum < 1 || sessionNum > 2 { - c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidna sesija"}) - return +// killSession terminates a session and removes it. +func (sm *taskSessionManager) killSession(taskID, sessionType string) bool { + key := sessionKey(taskID, sessionType) + + sm.mu.Lock() + sess, exists := sm.sessions[key] + if exists { + delete(sm.sessions, key) + } + sm.mu.Unlock() + + if !exists { + return false } - session := s.console.getSession(sessionNum - 1) - - session.mu.Lock() - defer session.mu.Unlock() - - if session.status != "running" { - c.JSON(http.StatusOK, gin.H{"status": "idle", "message": "sesija nije aktivna"}) - return - } - - // Close PTY session if it exists - if session.ptySess != nil { - session.ptySess.Close() - session.ptySess = nil - } else if session.cmd != nil && session.cmd.Process != nil { - session.cmd.Process.Kill() - } - - // Update history - for i := len(session.history) - 1; i >= 0; i-- { - if session.history[i].ExecID == session.execID { - session.history[i].Status = "killed" - break - } - } - - session.status = "idle" - session.cmd = nil - - c.JSON(http.StatusOK, gin.H{"status": "killed"}) -} - -// handleConsoleSessions returns the status of both sessions. -func (s *Server) handleConsoleSessions(c *gin.Context) { - statuses := make([]sessionStatus, 2) - - for i := 0; i < 2; i++ { - sess := s.console.getSession(i) - sess.mu.Lock() - statuses[i] = sessionStatus{ - Session: i + 1, - Status: sess.status, - TaskID: sess.taskID, - ExecID: sess.execID, - } - sess.mu.Unlock() - } - - c.JSON(http.StatusOK, statuses) -} - -// handleConsoleHistory returns command history for a session. -func (s *Server) handleConsoleHistory(c *gin.Context) { - sessionNum, err := strconv.Atoi(c.Param("session")) - if err != nil || sessionNum < 1 || sessionNum > 2 { - c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidna sesija"}) - return - } - - session := s.console.getSession(sessionNum - 1) - - session.mu.Lock() - history := make([]historyEntry, len(session.history)) - copy(history, session.history) - session.mu.Unlock() - - data, _ := json.Marshal(history) - c.Header("Content-Type", "application/json") - c.String(http.StatusOK, string(data)) -} - -// timeNow returns the current time formatted as HH:MM:SS. -func timeNow() string { - return time.Now().Format("15:04:05") + log.Printf("Session[%s]: killed", key) + sess.PTY.Close() + return true } // handleConsolePage serves the console HTML page. @@ -387,3 +163,106 @@ func (s *Server) handleConsolePage(c *gin.Context) { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, renderConsolePage()) } + +// handleConsoleSessions returns all active task sessions as JSON. +func (s *Server) handleConsoleSessions(c *gin.Context) { + sessions := s.console.listSessions() + c.JSON(http.StatusOK, sessions) +} + +// handleConsoleKill kills a task session. +func (s *Server) handleConsoleKill(c *gin.Context) { + taskID := c.Param("taskID") + sessionType := c.DefaultQuery("type", "work") + + if s.console.killSession(taskID, sessionType) { + c.JSON(http.StatusOK, gin.H{"status": "killed", "task": taskID}) + } else { + c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"}) + } +} + +// buildWorkPrompt builds the prompt for a work session. +func buildWorkPrompt(taskID string, taskContent []byte) string { + return fmt.Sprintf(`Radiš na tasku %s. Evo sadržaj taska: + +%s + +PRAVILA: +1. Pročitaj agents/coder/CLAUDE.md za pravila kodiranja +2. Kod piši u code/ folderu +3. Svi testovi moraju proći: go test ./... -count=1 +4. Build mora proći: go build ./... +5. go vet ./... mora proći +6. Commituj sa porukom: %s: Opis na srpskom +7. Napiši izveštaj u TASKS/reports/%s-report.md +8. Premesti task fajl: mv TASKS/active/%s.md TASKS/review/%s.md +9. Kada sve završiš, reci "GOTOVO"`, taskID, string(taskContent), taskID, taskID, taskID, taskID) +} + +// buildReviewPrompt builds the prompt for a review session. +func buildReviewPrompt(taskID string, taskContent, reportContent []byte) string { + prompt := fmt.Sprintf(`Pregledaj task %s. Evo sadržaj taska: + +%s +`, taskID, string(taskContent)) + + if len(reportContent) > 0 { + prompt += fmt.Sprintf(` +Izveštaj agenta: + +%s +`, string(reportContent)) + } + + prompt += fmt.Sprintf(` +KORACI PREGLEDA: +1. Pročitaj agents/checker/CLAUDE.md za pravila +2. Proveri da li je kod napisan prema zadatku +3. Pokreni testove: go test ./... -count=1 +4. Pokreni build: go build ./... +5. Pokreni vet: go vet ./... +6. Ako je SVE u redu: + - Premesti task: mv TASKS/review/%s.md TASKS/done/%s.md + - Reci "ODOBRENO" +7. Ako NIJE u redu: + - Zapiši šta treba popraviti u task fajl + - Premesti task: mv TASKS/review/%s.md TASKS/active/%s.md + - Reci "VRAĆENO NA DORADU"`, taskID, taskID, taskID, taskID) + + return prompt +} + +// handleReviewTask launches a review claude session for a task. +func (s *Server) handleReviewTask(c *gin.Context) { + id := c.Param("id") + + // Read task content + taskPath := filepath.Join(s.Config.TasksDir, "review", id+".md") + taskContent, err := os.ReadFile(taskPath) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "task nije pronađen u review/"}) + return + } + + // Read report if exists + reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md") + reportContent, _ := os.ReadFile(reportPath) + + // Build review prompt + prompt := buildReviewPrompt(id, taskContent, reportContent) + + // Start review session + _, err = s.console.startSession(id, "review", s.projectRoot(), prompt) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "started", "task": id, "type": "review"}) +} + +// timeNow returns the current time formatted as HH:MM:SS. +func timeNow() string { + return time.Now().Format("15:04:05") +} diff --git a/code/internal/server/pty_session.go b/code/internal/server/pty_session.go index 6e05530..0e421a0 100644 --- a/code/internal/server/pty_session.go +++ b/code/internal/server/pty_session.go @@ -79,19 +79,20 @@ type consolePTYSession struct { lastActive time.Time } -// spawnConsolePTY starts a new claude CLI in a PTY for the console. -func spawnConsolePTY(projectDir, prompt string) (*consolePTYSession, error) { - cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt) +// spawnTaskPTY starts an interactive claude CLI with auto-permissions in a PTY. +// Used for task work and review sessions. The prompt is sent after startup. +func spawnTaskPTY(projectDir string) (*consolePTYSession, error) { + cmd := exec.Command("claude", "--permission-mode", "dontAsk") cmd.Dir = projectDir cmd.Env = cleanEnvForPTY() - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 120}) + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 50, Cols: 180}) if err != nil { return nil, fmt.Errorf("start pty: %w", err) } sess := &consolePTYSession{ - ID: fmt.Sprintf("pty-%d", time.Now().UnixNano()), + ID: fmt.Sprintf("task-%d", time.Now().UnixNano()), Ptmx: ptmx, Cmd: cmd, buffer: NewRingBuffer(outputBufferSize), diff --git a/code/internal/server/server.go b/code/internal/server/server.go index e3b7c27..b569f1f 100644 --- a/code/internal/server/server.go +++ b/code/internal/server/server.go @@ -4,6 +4,7 @@ package server import ( "fmt" "io/fs" + "log" "net/http" "os" "path/filepath" @@ -22,7 +23,7 @@ import ( type Server struct { Config *config.Config Router *gin.Engine - console *consoleManager + console *taskSessionManager events *eventBroker chatMu sync.RWMutex chats map[string]*chatState @@ -73,7 +74,7 @@ func New(cfg *config.Config) *Server { s := &Server{ Config: cfg, Router: router, - console: newConsoleManager(), + console: newTaskSessionManager(), events: newEventBroker(cfg.TasksDir), chats: make(map[string]*chatState), } @@ -106,6 +107,7 @@ func (s *Server) setupRoutes() { s.Router.GET("/task/:id", s.handleTaskDetail) s.Router.POST("/task/:id/move", s.handleMoveTask) s.Router.POST("/task/:id/run", s.handleRunTask) + s.Router.POST("/task/:id/review", s.handleReviewTask) s.Router.GET("/report/:id", s.handleReport) // SSE events @@ -116,12 +118,9 @@ func (s *Server) setupRoutes() { // Console routes s.Router.GET("/console", s.handleConsolePage) - s.Router.POST("/console/exec", s.handleConsoleExec) - s.Router.GET("/console/stream/:id", s.handleConsoleStream) - s.Router.POST("/console/kill/:session", s.handleConsoleKill) s.Router.GET("/console/sessions", s.handleConsoleSessions) - s.Router.GET("/console/history/:session", s.handleConsoleHistory) - s.Router.GET("/console/ws/:session", s.handleConsoleWS) + s.Router.POST("/console/kill/:taskID", s.handleConsoleKill) + s.Router.GET("/console/ws/:key", s.handleConsoleWS) // Logs route s.Router.GET("/api/logs/tail", s.handleLogsTail) @@ -359,9 +358,19 @@ func (s *Server) handleRunTask(c *gin.Context) { taskPath := filepath.Join(s.Config.TasksDir, "active", id+".md") appendTimestamp(taskPath, "Pokrenut (→active)") + // Read task content and spawn a claude work session + taskContent, _ := os.ReadFile(taskPath) + prompt := buildWorkPrompt(id, taskContent) + + _, err = s.console.startSession(id, "work", s.projectRoot(), prompt) + if err != nil { + log.Printf("Warning: session start failed for %s: %v", id, err) + } + c.JSON(http.StatusOK, gin.H{ - "status": "started", - "task": id, + "status": "started", + "task": id, + "session": id, }) } diff --git a/code/internal/server/server_test.go b/code/internal/server/server_test.go index 07615bb..9bfa33b 100644 --- a/code/internal/server/server_test.go +++ b/code/internal/server/server_test.go @@ -1098,15 +1098,15 @@ func TestConsolePage(t *testing.T) { } body := w.Body.String() - if !containsStr(body, "Sesija 1") { - t.Error("expected 'Sesija 1' in console page") + if !containsStr(body, "KAOS") { + t.Error("expected 'KAOS' in console page") } - if !containsStr(body, "Sesija 2") { - t.Error("expected 'Sesija 2' in console page") + if !containsStr(body, "Konzola") { + t.Error("expected 'Konzola' in console page") } } -func TestConsoleSessions(t *testing.T) { +func TestConsoleSessions_Empty(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/console/sessions", nil) @@ -1117,115 +1117,25 @@ func TestConsoleSessions(t *testing.T) { t.Fatalf("expected 200, got %d", w.Code) } - var statuses []sessionStatus - if err := json.Unmarshal(w.Body.Bytes(), &statuses); err != nil { + var sessions []taskSessionResponse + if err := json.Unmarshal(w.Body.Bytes(), &sessions); err != nil { t.Fatalf("invalid JSON: %v", err) } - if len(statuses) != 2 { - t.Fatalf("expected 2 sessions, got %d", len(statuses)) - } - if statuses[0].Status != "idle" || statuses[1].Status != "idle" { - t.Error("expected both sessions idle") + if len(sessions) != 0 { + t.Fatalf("expected 0 sessions, got %d", len(sessions)) } } -func TestConsoleExec_InvalidSession(t *testing.T) { +func TestConsoleKill_NotFound(t *testing.T) { srv := setupTestServer(t) - body := `{"cmd":"status","session":3}` - req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") + req := httptest.NewRequest(http.MethodPost, "/console/kill/T99", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for invalid session, got %d", w.Code) - } -} - -func TestConsoleExec_ValidRequest(t *testing.T) { - srv := setupTestServer(t) - - body := `{"cmd":"echo test","session":1}` - req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp execResponse - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - - if resp.ExecID == "" { - t.Error("expected non-empty exec ID") - } - if resp.Session != 1 { - t.Errorf("expected session 1, got %d", resp.Session) - } -} - -func TestConsoleKill_IdleSession(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodPost, "/console/kill/1", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } -} - -func TestConsoleHistory_Empty(t *testing.T) { - srv := setupTestServer(t) - - req := httptest.NewRequest(http.MethodGet, "/console/history/1", nil) - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var history []historyEntry - if err := json.Unmarshal(w.Body.Bytes(), &history); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - - if len(history) != 0 { - t.Errorf("expected empty history, got %d entries", len(history)) - } -} - -func TestConsoleHistory_AfterExec(t *testing.T) { - srv := setupTestServer(t) - - // Execute a command first - body := `{"cmd":"test command","session":2}` - req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.Router.ServeHTTP(w, req) - - // Check history - req2 := httptest.NewRequest(http.MethodGet, "/console/history/2", nil) - w2 := httptest.NewRecorder() - srv.Router.ServeHTTP(w2, req2) - - var history []historyEntry - json.Unmarshal(w2.Body.Bytes(), &history) - - if len(history) != 1 { - t.Fatalf("expected 1 history entry, got %d", len(history)) - } - if history[0].Command != "test command" { - t.Errorf("expected 'test command', got %s", history[0].Command) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) } } @@ -1635,7 +1545,7 @@ func TestConsolePage_ToolbarAbovePanels(t *testing.T) { } } -func TestConsolePage_HasSessionToggle(t *testing.T) { +func TestConsolePage_HasDynamicSessions(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/console", nil) @@ -1643,11 +1553,11 @@ func TestConsolePage_HasSessionToggle(t *testing.T) { srv.Router.ServeHTTP(w, req) body := w.Body.String() - if !containsStr(body, "togglePanel2") { - t.Error("expected togglePanel2 button in console page") + if !containsStr(body, "refreshSessions") { + t.Error("expected refreshSessions function in console page") } - if !containsStr(body, `+ Sesija 2`) { - t.Error("expected '+ Sesija 2' toggle button") + if !containsStr(body, "/console/sessions") { + t.Error("expected /console/sessions API call") } } @@ -2070,15 +1980,15 @@ func TestConsolePage_HasWebSocket(t *testing.T) { srv.Router.ServeHTTP(w, req) body := w.Body.String() - if !containsStr(body, "/console/ws/") { - t.Error("expected WebSocket URL /console/ws/ in console page") + if !containsStr(body, "console/ws/") { + t.Error("expected WebSocket URL console/ws/ in console page") } if !containsStr(body, "new WebSocket") { t.Error("expected WebSocket constructor in console page") } } -func TestConsolePage_HasTerminalContainers(t *testing.T) { +func TestConsolePage_HasEmptyState(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/console", nil) @@ -2086,14 +1996,14 @@ func TestConsolePage_HasTerminalContainers(t *testing.T) { srv.Router.ServeHTTP(w, req) body := w.Body.String() - if !containsStr(body, `id="terminal-1"`) { - t.Error("expected terminal-1 container") + if !containsStr(body, "empty-state") { + t.Error("expected empty-state element") } - if !containsStr(body, `id="terminal-2"`) { - t.Error("expected terminal-2 container") + if !containsStr(body, "Pusti") { + t.Error("expected 'Pusti' instruction in empty state") } if !containsStr(body, "console-terminal") { - t.Error("expected console-terminal class") + t.Error("expected console-terminal class in JS code") } } @@ -2128,3 +2038,128 @@ func TestConsolePage_HasResizeHandler(t *testing.T) { t.Error("expected resize message type in WebSocket handler") } } + +// ── Task session manager tests ────────────────────── + +func TestSessionKey(t *testing.T) { + if got := sessionKey("T01", "work"); got != "T01" { + t.Errorf("expected T01, got %s", got) + } + if got := sessionKey("T01", "review"); got != "T01-review" { + t.Errorf("expected T01-review, got %s", got) + } +} + +func TestTaskSessionManager_ListEmpty(t *testing.T) { + sm := newTaskSessionManager() + sessions := sm.listSessions() + if len(sessions) != 0 { + t.Errorf("expected 0 sessions, got %d", len(sessions)) + } +} + +func TestTaskSessionManager_KillNotFound(t *testing.T) { + sm := newTaskSessionManager() + if sm.killSession("T99", "work") { + t.Error("expected false for non-existent session") + } +} + +func TestTaskSessionManager_GetNotFound(t *testing.T) { + sm := newTaskSessionManager() + if sm.getSessionByKey("T99") != nil { + t.Error("expected nil for non-existent session") + } +} + +func TestBuildWorkPrompt(t *testing.T) { + prompt := buildWorkPrompt("T08", []byte("# T08: Test task\n\nOpis.")) + + if !containsStr(prompt, "T08") { + t.Error("expected task ID in prompt") + } + if !containsStr(prompt, "Test task") { + t.Error("expected task content in prompt") + } + if !containsStr(prompt, "agents/coder/CLAUDE.md") { + t.Error("expected coder CLAUDE.md reference") + } + if !containsStr(prompt, "go test") { + t.Error("expected test instruction") + } + if !containsStr(prompt, "report") { + t.Error("expected report instruction") + } +} + +func TestBuildReviewPrompt(t *testing.T) { + prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), []byte("# Report\nSve ok.")) + + if !containsStr(prompt, "T08") { + t.Error("expected task ID in review prompt") + } + if !containsStr(prompt, "Sve ok") { + t.Error("expected report content in review prompt") + } + if !containsStr(prompt, "agents/checker/CLAUDE.md") { + t.Error("expected checker CLAUDE.md reference") + } +} + +func TestBuildReviewPrompt_NoReport(t *testing.T) { + prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), nil) + + if !containsStr(prompt, "T08") { + t.Error("expected task ID") + } + if containsStr(prompt, "Izveštaj agenta") { + t.Error("should not include report section when no report") + } +} + +func TestReviewTask_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/task/T99/review", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestConsolePage_HasKillButton(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "killSession") { + t.Error("expected killSession function in console page") + } +} + +func TestTaskDetail_HasProveriButton(t *testing.T) { + srv := setupTestServer(t) + + // Put T08 in review + os.Rename( + filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), + filepath.Join(srv.Config.TasksDir, "review", "T08.md"), + ) + + req := httptest.NewRequest(http.MethodGet, "/task/T08", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "Proveri") { + t.Error("expected 'Proveri' button for review task") + } + if !containsStr(body, "/review") { + t.Error("expected /review endpoint in Proveri button") + } +} diff --git a/code/internal/server/submit.go b/code/internal/server/submit.go index da39119..89ab0c4 100644 --- a/code/internal/server/submit.go +++ b/code/internal/server/submit.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/gin-gonic/gin" @@ -19,6 +20,26 @@ import ( "github.com/dal/kaos/internal/supervisor" ) +// chatCounter generates unique chat IDs. +var chatCounter atomic.Int64 + +func nextChatID() string { + chatCounter.Add(1) + return fmt.Sprintf("chat-%d-%d", time.Now().Unix(), chatCounter.Load()) +} + +// cleanEnv returns the current environment with CLAUDECODE removed, +// so child claude processes don't inherit the parent's session. +func cleanEnv() []string { + var env []string + for _, e := range os.Environ() { + if !strings.HasPrefix(e, "CLAUDECODE=") { + env = append(env, e) + } + } + return env +} + // chatState manages an operator chat session backed by a claude CLI process. type chatState struct { mu sync.Mutex @@ -136,7 +157,7 @@ func (s *Server) handleChatSubmit(c *gin.Context) { string(claudeMD), context, req.Message) // Create chat session - chatID := s.console.nextExecID() + chatID := nextChatID() chat := &chatState{ id: chatID, listeners: make(map[chan string]bool), diff --git a/code/internal/server/ws.go b/code/internal/server/ws.go index 43393cc..6887cd2 100644 --- a/code/internal/server/ws.go +++ b/code/internal/server/ws.go @@ -2,10 +2,8 @@ package server import ( "encoding/json" - "fmt" "log" "net/http" - "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" @@ -24,37 +22,40 @@ type wsResizeMsg struct { Rows uint16 `json:"rows"` } -// handleConsoleWS handles WebSocket connections for console terminals. -// Each connection gets its own independent shell PTY session. +// handleConsoleWS handles WebSocket connections for task console terminals. +// Each connection attaches to an existing PTY session by task key. func (s *Server) handleConsoleWS(c *gin.Context) { - sessionNum := c.Param("session") - if sessionNum != "1" && sessionNum != "2" { - c.JSON(http.StatusBadRequest, gin.H{"error": "sesija mora biti 1 ili 2"}) + key := c.Param("key") // e.g., "T08" or "T08-review" + + sess := s.console.getSessionByKey(key) + if sess == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"}) return } conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { - log.Printf("WebSocket upgrade error: %v", err) + log.Printf("WS[%s]: upgrade error: %v", key, err) return } defer conn.Close() - log.Printf("WS[%s]: connected, spawning shell", sessionNum) + log.Printf("WS[%s]: connected", key) - // Spawn a fresh interactive shell for this connection - ptySess, err := spawnShellPTY(s.projectRoot()) - if err != nil { - log.Printf("WS[%s]: shell spawn error: %v", sessionNum, err) - conn.WriteMessage(websocket.TextMessage, - []byte(fmt.Sprintf("\r\n\033[31m[Greška: %v]\033[0m\r\n", err))) - return + ptySess := sess.PTY + + // Send replay buffer so the user sees existing output + replay := ptySess.GetBuffer() + if len(replay) > 0 { + if err := conn.WriteMessage(websocket.BinaryMessage, replay); err != nil { + log.Printf("WS[%s]: replay write error: %v", key, err) + return + } + log.Printf("WS[%s]: replayed %d bytes", key, len(replay)) } - defer ptySess.Close() - log.Printf("WS[%s]: shell started (PID %d)", sessionNum, ptySess.Cmd.Process.Pid) - - subID := fmt.Sprintf("ws-%d", time.Now().UnixNano()) + // Subscribe to new PTY output + subID := key + "-ws" outputCh := ptySess.Subscribe(subID) defer ptySess.Unsubscribe(subID) @@ -87,7 +88,7 @@ func (s *Server) handleConsoleWS(c *gin.Context) { go func() { select { case <-ptySess.Done(): - log.Printf("WS[%s]: shell exited", sessionNum) + log.Printf("WS[%s]: process exited", key) conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done")) case <-stopCh: @@ -99,7 +100,7 @@ func (s *Server) handleConsoleWS(c *gin.Context) { _, msg, err := conn.ReadMessage() if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { - log.Printf("WS[%s]: read error: %v", sessionNum, err) + log.Printf("WS[%s]: read error: %v", key, err) } break } @@ -108,14 +109,14 @@ func (s *Server) handleConsoleWS(c *gin.Context) { var resize wsResizeMsg if json.Unmarshal(msg, &resize) == nil && resize.Type == "resize" && resize.Cols > 0 && resize.Rows > 0 { if err := ptySess.Resize(resize.Rows, resize.Cols); err != nil { - log.Printf("WS[%s]: resize error: %v", sessionNum, err) + log.Printf("WS[%s]: resize error: %v", key, err) } continue } // Regular keyboard input → PTY if _, err := ptySess.WriteInput(msg); err != nil { - log.Printf("WS[%s]: write error: %v", sessionNum, err) + log.Printf("WS[%s]: write error: %v", key, err) break } } @@ -123,5 +124,5 @@ func (s *Server) handleConsoleWS(c *gin.Context) { close(stopCh) close(writeCh) <-writeDone - log.Printf("WS[%s]: disconnected", sessionNum) + log.Printf("WS[%s]: disconnected", key) } diff --git a/code/web/templates/console.html b/code/web/templates/console.html index 79cc649..7aa886b 100644 --- a/code/web/templates/console.html +++ b/code/web/templates/console.html @@ -13,7 +13,7 @@
-

🔧 KAOS Dashboard

+

KAOS Dashboard

@@ -31,24 +31,11 @@
- + Sesije: 0
- -
-
-
- 🔧 Sesija 1 - connected -
-
-
- - @@ -83,13 +70,38 @@ function getTermTheme() { } // ── Session state ──────────────────────────────────── -var sessions = [{}, {}]; +var terminals = {}; + +function sessionKey(sess) { + return sess.type === 'review' ? sess.task_id + '-review' : sess.task_id; +} + +function createTerminal(sess) { + var key = sessionKey(sess); + if (terminals[key]) return; + + document.getElementById('empty-state').style.display = 'none'; + + var panel = document.createElement('div'); + panel.className = 'console-panel'; + panel.id = 'panel-' + key; + + var header = document.createElement('div'); + header.className = 'console-panel-header'; + var label = sess.task_id + (sess.type === 'review' ? ' [pregled]' : ' [rad]'); + header.innerHTML = '' + label + '' + + '' + sess.status + '' + + ''; + + var termDiv = document.createElement('div'); + termDiv.className = 'console-terminal'; + termDiv.id = 'terminal-' + key; + + panel.appendChild(header); + panel.appendChild(termDiv); + document.getElementById('panels').appendChild(panel); -function initTerminal(idx) { - var num = idx + 1; - var containerEl = document.getElementById('terminal-' + num); var theme = getTermTheme(); - var term = new Terminal({ cursorBlink: true, cursorStyle: 'block', @@ -106,41 +118,22 @@ function initTerminal(idx) { var fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); term.loadAddon(new WebLinksAddon.WebLinksAddon()); - term.open(containerEl); + term.open(termDiv); - // Keyboard input → WebSocket - term.onData(function(data) { - var ws = sessions[idx].ws; - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(data); - } - }); + terminals[key] = { term: term, fitAddon: fitAddon, ws: null }; - // Resize → WebSocket - term.onResize(function(size) { - var ws = sessions[idx].ws; - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows })); - } - }); - - containerEl.addEventListener('click', function() { term.focus(); }); - - sessions[idx].term = term; - sessions[idx].fitAddon = fitAddon; - sessions[idx].ws = null; + termDiv.addEventListener('click', function() { term.focus(); }); setTimeout(function() { fitAddon.fit(); - // Auto-connect WebSocket immediately - connectWS(idx); + connectWS(key, term); }, 100); } // ── WebSocket connection ───────────────────────────── -function connectWS(idx) { - var num = idx + 1; - var sess = sessions[idx]; +function connectWS(key, term) { + var sess = terminals[key]; + if (!sess) return; if (sess.ws) { sess.ws.close(); @@ -148,62 +141,95 @@ function connectWS(idx) { } var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - var url = proto + '//' + location.host + '/console/ws/' + num; + var url = proto + '//' + location.host + '/console/ws/' + key; var ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; ws.onopen = function() { - setSessionUI(num, 'connected'); - var term = sess.term; + var el = document.getElementById('status-' + key); + if (el) { el.textContent = 'connected'; el.className = 'session-status session-running'; } ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); term.focus(); }; ws.onmessage = function(event) { if (event.data instanceof ArrayBuffer) { - sess.term.write(new Uint8Array(event.data)); + term.write(new Uint8Array(event.data)); } else { - sess.term.write(event.data); + term.write(event.data); } }; ws.onclose = function() { sess.ws = null; - setSessionUI(num, 'disconnected'); + var el = document.getElementById('status-' + key); + if (el) { el.textContent = 'disconnected'; el.className = 'session-status'; } }; ws.onerror = function() { sess.ws = null; - setSessionUI(num, 'error'); }; + // Keyboard input → WebSocket + term.onData(function(data) { + if (sess.ws && sess.ws.readyState === WebSocket.OPEN) { + sess.ws.send(data); + } + }); + + // Resize → WebSocket + term.onResize(function(size) { + if (sess.ws && sess.ws.readyState === WebSocket.OPEN) { + sess.ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows })); + } + }); + sess.ws = ws; } -// ── UI helpers ─────────────────────────────────────── -function setSessionUI(session, status) { - var el = document.getElementById('status-' + session); - el.textContent = status; - el.className = 'session-status'; - if (status === 'connected') el.className += ' session-running'; +// ── Session management ─────────────────────────────── +function killSession(taskID, type) { + fetch('/console/kill/' + taskID + '?type=' + type, { method: 'POST' }) + .then(function(resp) { return resp.json(); }) + .then(function() { refreshSessions(); }); } -function togglePanel2() { - var panel = document.getElementById('panel-2'); - var btn = document.getElementById('toggle-panel'); - if (panel.style.display === 'none') { - panel.style.display = 'flex'; - btn.textContent = '- Sesija 2'; - if (!sessions[1].term) { - initTerminal(1); - } else { - sessions[1].fitAddon.fit(); - if (!sessions[1].ws) connectWS(1); - } - } else { - panel.style.display = 'none'; - btn.textContent = '+ Sesija 2'; - } +function refreshSessions() { + fetch('/console/sessions') + .then(function(resp) { return resp.json(); }) + .then(function(sessions) { + var info = document.getElementById('session-info'); + info.textContent = 'Sesije: ' + sessions.length; + + if (sessions.length === 0) { + document.getElementById('empty-state').style.display = 'block'; + } else { + document.getElementById('empty-state').style.display = 'none'; + } + + var currentKeys = {}; + sessions.forEach(function(sess) { + var key = sessionKey(sess); + currentKeys[key] = true; + createTerminal(sess); + + var el = document.getElementById('status-' + key); + if (el && sess.status === 'exited') { + el.textContent = 'finished'; + el.className = 'session-status session-done'; + } + }); + + // Remove panels for sessions that no longer exist + Object.keys(terminals).forEach(function(key) { + if (!currentKeys[key]) { + var panel = document.getElementById('panel-' + key); + if (panel) panel.remove(); + if (terminals[key].ws) terminals[key].ws.close(); + delete terminals[key]; + } + }); + }); } // ── Theme sync ─────────────────────────────────────── @@ -212,25 +238,26 @@ window.setTheme = function(mode) { if (origSetTheme) origSetTheme(mode); setTimeout(function() { var theme = getTermTheme(); - for (var i = 0; i < 2; i++) { - if (sessions[i].term) { - sessions[i].term.options.theme = theme; + Object.keys(terminals).forEach(function(key) { + if (terminals[key].term) { + terminals[key].term.options.theme = theme; } - } + }); }, 50); }; // ── Window resize ──────────────────────────────────── window.addEventListener('resize', function() { - for (var i = 0; i < 2; i++) { - if (sessions[i].fitAddon) { - sessions[i].fitAddon.fit(); + Object.keys(terminals).forEach(function(key) { + if (terminals[key].fitAddon) { + terminals[key].fitAddon.fit(); } - } + }); }); // ── Initialize ─────────────────────────────────────── -initTerminal(0); +refreshSessions(); +setInterval(refreshSessions, 5000); diff --git a/code/web/templates/layout.html b/code/web/templates/layout.html index d2acebe..4d72e54 100644 --- a/code/web/templates/layout.html +++ b/code/web/templates/layout.html @@ -34,6 +34,7 @@ Dokumenti Konzola Prijava + Logovi
@@ -70,6 +71,22 @@ document.getElementById('task-detail').addEventListener('click', function(e) { } }); +function showLogs() { + fetch('/api/logs/tail') + .then(function(resp) { return resp.text(); }) + .then(function(text) { + var el = document.getElementById('task-detail'); + el.innerHTML = '

Poslednji logovi

' + escapeHtml(text) + '
'; + el.classList.add('active'); + }); +} + +function escapeHtml(text) { + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + function showToast(msg, type) { var toast = document.getElementById('toast'); toast.textContent = msg; @@ -79,18 +96,39 @@ function showToast(msg, type) { }, 2000); } +// Handle "Proveri" button response +document.body.addEventListener('htmx:afterRequest', function(e) { + if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/review/)) { + var xhr = e.detail.xhr; + if (xhr.status === 200) { + showToast('Pregled pokrenut', 'success'); + setTimeout(function() { window.location.href = '/console'; }, 800); + } else { + try { + var data = JSON.parse(xhr.responseText); + showToast(data.error || 'Greška', 'error'); + } catch(ex) { + showToast('Greška', 'error'); + } + } + } +}); + // Handle "Pusti" button response document.body.addEventListener('htmx:afterRequest', function(e) { if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/run/)) { var xhr = e.detail.xhr; if (xhr.status === 200) { var data = JSON.parse(xhr.responseText); - showToast(data.exec_id ? 'Pokrenuto u sesiji ' + data.session : 'Pokrenuto', 'success'); - // Redirect to console after short delay so user can see output + showToast('Pokrenuto: ' + (data.task || ''), 'success'); setTimeout(function() { window.location.href = '/console'; }, 800); } else { - var data = JSON.parse(xhr.responseText); - showToast(data.error || 'Greška', 'error'); + try { + var data = JSON.parse(xhr.responseText); + showToast(data.error || 'Greška', 'error'); + } catch(ex) { + showToast('Greška', 'error'); + } } htmx.ajax('GET', '/', {target: '#board', swap: 'outerHTML', select: '#board'}); } diff --git a/code/web/templates/partials/task-detail.html b/code/web/templates/partials/task-detail.html index b0db5cd..8584be0 100644 --- a/code/web/templates/partials/task-detail.html +++ b/code/web/templates/partials/task-detail.html @@ -10,7 +10,7 @@
{{if .HasReport}}
- Izvestaj +
{{end}}
@@ -21,6 +21,7 @@ {{end}} {{if eq .Task.Status "review"}} + {{end}}