diff --git a/TESTING.md b/TESTING.md
index 6d9717d..2159033 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -44,6 +44,18 @@
- [ ] Link "Promeni lozinku" vidljiv na /projects stranici
- [ ] Link "Nazad na projekte" vraća na /projects
+## Tabovi (više konzola)
+- [ ] Otvaranje /chat/arv → jedan tab "arv" prikazan
+- [ ] Klik na + → dropdown sa listom projekata
+- [ ] Izbor projekta iz dropdown-a → novi tab otvoren
+- [ ] Izbor istog projekta → novi tab sa zasebnom sesijom
+- [ ] Klik na tab → prebacivanje terminala, fokus
+- [ ] Klik na × → tab zatvoren, WS prekinut
+- [ ] Poslednji tab se ne može zatvoriti (nema × dugme)
+- [ ] Refresh stranice → prvi tab se ponovo konektuje sa replay-om
+- [ ] Svaki tab ima nezavisan terminal (kucanje u jednom ne utiče na drugi)
+- [ ] Promena teme → primenjuje se na sve tabove
+
## Sesija persistence
- [ ] Zatvori tab → otvori ponovo → sesija živa, poruke replayed
- [ ] Idle sesija se čisti posle 30 minuta
diff --git a/main.go b/main.go
index d2ce13d..ef5ad77 100644
--- a/main.go
+++ b/main.go
@@ -50,6 +50,7 @@ func main() {
mux.Handle("GET /chat/{project}", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChat)))
mux.Handle("GET /ws", AuthMiddleware(sessionMgr, wsHandler))
mux.Handle("GET /api/file", AuthMiddleware(sessionMgr, http.HandlerFunc(handleFileAPI)))
+ mux.Handle("GET /api/projects", AuthMiddleware(sessionMgr, http.HandlerFunc(handleProjectsAPI)))
mux.Handle("GET /change-password", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChangePasswordPage)))
mux.Handle("POST /change-password", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChangePassword)))
@@ -116,6 +117,13 @@ func handleCreateProject(w http.ResponseWriter, r *http.Request) {
name := r.FormValue("name")
if err := CreateProject(cfg.ProjectsPath, name); err != nil {
+ // AJAX request — return JSON error
+ if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
+ return
+ }
projects, _ := ListProjects(cfg.ProjectsPath)
data := map[string]any{
"Projects": projects,
@@ -127,6 +135,13 @@ func handleCreateProject(w http.ResponseWriter, r *http.Request) {
return
}
+ // AJAX request — return JSON success
+ if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"name": name})
+ return
+ }
+
http.Redirect(w, r, "/projects", http.StatusSeeOther)
}
@@ -145,10 +160,17 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
files = nil
}
+ projects, err := ListProjects(cfg.ProjectsPath)
+ if err != nil {
+ log.Printf("ListProjects error: %v", err)
+ projects = nil
+ }
+
data := map[string]any{
"Project": project,
"ProjectDir": projectDir,
"Files": files,
+ "Projects": projects,
}
templates.Render(w, "chat.html", data)
}
@@ -197,6 +219,27 @@ func handleChangePassword(w http.ResponseWriter, r *http.Request) {
templates.Render(w, "change-password.html", data)
}
+func handleProjectsAPI(w http.ResponseWriter, r *http.Request) {
+ projects, err := ListProjects(cfg.ProjectsPath)
+ if err != nil {
+ http.Error(w, "error listing projects", http.StatusInternalServerError)
+ return
+ }
+
+ type projectItem struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ }
+
+ items := make([]projectItem, len(projects))
+ for i, p := range projects {
+ items[i] = projectItem{Name: p.Name, Path: p.Path}
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(items)
+}
+
func handleFileAPI(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
relPath := r.URL.Query().Get("path")
diff --git a/pty_session_test.go b/pty_session_test.go
index b53f441..c599ee0 100644
--- a/pty_session_test.go
+++ b/pty_session_test.go
@@ -71,4 +71,66 @@ func TestPTYSessionManager(t *testing.T) {
t.Error("expected empty sessions")
}
})
+
+ t.Run("different session keys create separate sessions", func(t *testing.T) {
+ m := &PTYSessionManager{
+ sessions: make(map[string]*PTYSession),
+ stopCh: make(chan struct{}),
+ }
+
+ // Use /tmp as a safe projectDir for testing
+ sess1, isNew1, err := m.GetOrCreate("arv:1", "/tmp")
+ if err != nil {
+ t.Fatalf("GetOrCreate arv:1: %v", err)
+ }
+ if !isNew1 {
+ t.Error("expected arv:1 to be new")
+ }
+ defer sess1.Close()
+
+ sess2, isNew2, err := m.GetOrCreate("arv:2", "/tmp")
+ if err != nil {
+ t.Fatalf("GetOrCreate arv:2: %v", err)
+ }
+ if !isNew2 {
+ t.Error("expected arv:2 to be new")
+ }
+ defer sess2.Close()
+
+ if sess1 == sess2 {
+ t.Error("expected different session objects for different keys")
+ }
+
+ if len(m.sessions) != 2 {
+ t.Errorf("expected 2 sessions, got %d", len(m.sessions))
+ }
+ })
+
+ t.Run("same session key returns existing session", func(t *testing.T) {
+ m := &PTYSessionManager{
+ sessions: make(map[string]*PTYSession),
+ stopCh: make(chan struct{}),
+ }
+
+ sess1, isNew1, err := m.GetOrCreate("arv:1", "/tmp")
+ if err != nil {
+ t.Fatalf("GetOrCreate: %v", err)
+ }
+ if !isNew1 {
+ t.Error("expected first call to be new")
+ }
+ defer sess1.Close()
+
+ sess2, isNew2, err := m.GetOrCreate("arv:1", "/tmp")
+ if err != nil {
+ t.Fatalf("GetOrCreate second: %v", err)
+ }
+ if isNew2 {
+ t.Error("expected second call to reuse existing session")
+ }
+
+ if sess1 != sess2 {
+ t.Error("expected same session object for same key")
+ }
+ })
}
diff --git a/static/style.css b/static/style.css
index 5a3db51..8d3fe60 100644
--- a/static/style.css
+++ b/static/style.css
@@ -665,6 +665,122 @@ a:hover {
gap: 0.5rem;
}
+/* Tab bar */
+.tab-bar {
+ display: flex;
+ align-items: center;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ padding: 0 0.25rem;
+ flex-shrink: 0;
+ overflow-x: auto;
+ gap: 2px;
+ min-height: 32px;
+}
+
+.tab-bar::-webkit-scrollbar { height: 0; }
+
+.tab {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.7rem;
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ color: var(--text-muted);
+ cursor: pointer;
+ border: 1px solid transparent;
+ border-bottom: none;
+ border-radius: 4px 4px 0 0;
+ white-space: nowrap;
+ user-select: none;
+ transition: color 0.15s, background 0.15s;
+}
+
+.tab:hover {
+ color: var(--text-secondary);
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.tab.active {
+ color: var(--text-primary);
+ background: var(--bg-primary);
+ border-color: var(--border);
+}
+
+.tab-close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ font-size: 0.65rem;
+ border-radius: 3px;
+ color: var(--text-muted);
+ transition: background 0.1s, color 0.1s;
+}
+
+.tab-close:hover {
+ background: rgba(244, 67, 54, 0.3);
+ color: var(--error);
+}
+
+.tab-add {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ cursor: pointer;
+ border-radius: 4px;
+ border: none;
+ background: none;
+ font-family: inherit;
+ transition: color 0.15s, background 0.15s;
+ flex-shrink: 0;
+}
+
+.tab-add:hover {
+ color: var(--text-primary);
+ background: rgba(255, 255, 255, 0.05);
+}
+
+/* Tab dropdown for project selection */
+.tab-dropdown {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ min-width: 180px;
+ max-height: 300px;
+ overflow-y: auto;
+ z-index: 100;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+}
+
+.tab-dropdown-item {
+ display: block;
+ width: 100%;
+ padding: 0.4rem 0.75rem;
+ font-size: 0.75rem;
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ color: var(--text-secondary);
+ background: none;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ transition: background 0.1s, color 0.1s;
+}
+
+.tab-dropdown-item:hover {
+ background: rgba(233, 69, 96, 0.15);
+ color: var(--text-primary);
+}
+
/* Hidden */
.hidden {
display: none !important;
diff --git a/templates/chat.html b/templates/chat.html
index d96712f..42be5e9 100644
--- a/templates/chat.html
+++ b/templates/chat.html
@@ -118,6 +118,9 @@
.terminal-header .title { color: var(--accent); font-weight: 600; }
.terminal-header .status { font-size: 0.7rem; color: var(--text-muted); }
.terminal-header .status.connected { color: #4caf50; }
+ .terminal-header .status.done { color: #ff9800; }
+ .done-flash { animation: flash-border 0.6s ease-out; }
+ @keyframes flash-border { 0% { border-color: #ff9800; } 100% { border-color: var(--border); } }
.terminal-header .controls { display: flex; align-items: center; gap: 0.6rem; }
.terminal-header select {
background: var(--bg-primary);
@@ -143,98 +146,8 @@
}
.terminal-header button:hover { border-color: var(--accent); color: var(--text-primary); }
- /* Tab bar */
- .tab-bar {
- display: flex;
- align-items: center;
- background: var(--bg-secondary);
- border-bottom: 1px solid var(--border);
- padding: 0 0.25rem;
- flex-shrink: 0;
- overflow-x: auto;
- gap: 2px;
- min-height: 30px;
- }
- .tab-bar::-webkit-scrollbar { height: 0; }
- .tab {
- display: flex;
- align-items: center;
- gap: 0.4rem;
- padding: 0.2rem 0.5rem;
- font-size: 0.7rem;
- color: var(--text-muted);
- cursor: pointer;
- border: 1px solid transparent;
- border-bottom: none;
- border-radius: 4px 4px 0 0;
- white-space: nowrap;
- user-select: none;
- transition: color 0.15s, background 0.15s;
- }
- .tab:hover { color: var(--text-secondary); background: rgba(255,255,255,0.03); }
- .tab.active { color: var(--text-primary); background: var(--bg-primary); border-color: var(--border); }
- .tab-close {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 16px;
- height: 16px;
- font-size: 0.65rem;
- border-radius: 3px;
- color: var(--text-muted);
- transition: background 0.1s, color 0.1s;
- }
- .tab-close:hover { background: rgba(244,67,54,0.3); color: var(--error); }
- .tab-add-wrap { position: relative; flex-shrink: 0; }
- .tab-add {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- font-size: 0.85rem;
- color: var(--text-muted);
- cursor: pointer;
- border-radius: 4px;
- border: none;
- background: none;
- font-family: inherit;
- transition: color 0.15s, background 0.15s;
- }
- .tab-add:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); }
- .tab-dropdown {
- position: absolute;
- top: 100%;
- left: 0;
- background: var(--bg-secondary);
- border: 1px solid var(--border);
- border-radius: 6px;
- min-width: 180px;
- max-height: 300px;
- overflow-y: auto;
- z-index: 100;
- box-shadow: 0 4px 12px rgba(0,0,0,0.4);
- }
- .tab-dropdown.hidden { display: none; }
- .tab-dropdown-item {
- display: block;
- width: 100%;
- padding: 0.4rem 0.75rem;
- font-size: 0.75rem;
- font-family: inherit;
- color: var(--text-secondary);
- background: none;
- border: none;
- text-align: left;
- cursor: pointer;
- transition: background 0.1s, color 0.1s;
- }
- .tab-dropdown-item:hover { background: rgba(233,69,96,0.15); color: var(--text-primary); }
-
- /* Terminal containers */
- #terminals { flex: 1; overflow: hidden; position: relative; }
- .term-pane { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: none; }
- .term-pane.active { display: block; }
+ /* Terminal container */
+ #terminal-container { flex: 1; overflow: hidden; }
.xterm { height: 100%; }
/* File viewer overlay */
@@ -301,39 +214,6 @@
background: rgba(233,69,96,0.1);
font-weight: 600;
}
- .sidebar-add-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 16px;
- height: 16px;
- font-size: 0.8rem;
- color: var(--text-muted);
- cursor: pointer;
- border-radius: 3px;
- transition: color 0.15s, background 0.15s;
- line-height: 1;
- }
- .sidebar-add-btn:hover { color: var(--accent); background: rgba(233,69,96,0.15); }
-
- /* Create project modal */
- .create-project-modal {
- position: fixed; top: 0; left: 0; width: 100%; height: 100%;
- background: rgba(0,0,0,0.5); display: flex; align-items: center;
- justify-content: center; z-index: 300;
- }
- .create-project-box {
- background: var(--bg-secondary); border: 1px solid var(--border);
- border-radius: 8px; padding: 1rem; width: 260px;
- box-shadow: 0 4px 16px rgba(0,0,0,0.4);
- }
- .create-project-box h3 { color: var(--accent); font-size: 0.85rem; margin-bottom: 0.6rem; }
- .create-project-box input {
- width: 100%; padding: 0.35rem 0.5rem; background: var(--bg-primary);
- border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary);
- font-size: 0.75rem; font-family: inherit; outline: none;
- }
- .create-project-box input:focus { border-color: var(--accent); }
/* Scrollbar */
::-webkit-scrollbar { width: 5px; }
@@ -352,10 +232,7 @@
-
-
-
-
-
+
@@ -522,272 +376,154 @@
document.body.style.background = theme.background;
document.querySelector('.main').style.background = theme.background;
localStorage.setItem('terminal-theme', name);
- // Apply to all open tabs
- tabs.forEach(function(tab) {
- tab.term.options.theme = theme;
- });
+ term.options.theme = theme;
}
- // ── Tab system ──────────────────────────────────────
- var tabs = [];
- var activeTabId = null;
- var nextTabId = 1;
+ // ── Terminal ─────────────────────────────────────────
var statusEl = document.getElementById('ws-status');
- var tabBarEl = document.getElementById('tab-bar');
- var terminalsEl = document.getElementById('terminals');
- var tabAddBtn = document.getElementById('tab-add-btn');
- var tabDropdown = document.getElementById('tab-dropdown');
- var projectsCache = null;
+ var containerEl = document.getElementById('terminal-container');
+ var ws = null;
+ var reconnectTimer = null;
+ var originalTitle = document.title;
+ var idleTimer = null;
+ var isStreaming = false;
+ var titleBlinkTimer = null;
- function createTerminal() {
- var theme = THEMES[savedTheme] || THEMES.dark;
- var term = new Terminal({
- cursorBlink: true,
- cursorStyle: 'block',
- cursorInactiveStyle: 'outline',
- fontSize: 14,
- fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
- theme: theme,
- allowProposedApi: true,
- scrollback: 10000,
- convertEol: false,
- drawBoldTextInBrightColors: true,
- });
- var fitAddon = new FitAddon.FitAddon();
- term.loadAddon(fitAddon);
- term.loadAddon(new WebLinksAddon.WebLinksAddon());
- return { term: term, fitAddon: fitAddon };
- }
+ var theme = THEMES[savedTheme] || THEMES.dark;
+ var term = new Terminal({
+ cursorBlink: true,
+ cursorStyle: 'block',
+ cursorInactiveStyle: 'outline',
+ fontSize: 14,
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
+ theme: theme,
+ allowProposedApi: true,
+ scrollback: 10000,
+ convertEol: false,
+ drawBoldTextInBrightColors: true,
+ });
+ var fitAddon = new FitAddon.FitAddon();
+ term.loadAddon(fitAddon);
+ term.loadAddon(new WebLinksAddon.WebLinksAddon());
+ term.open(containerEl);
- function connectTab(tab) {
+ term.onData(function(data) {
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
+ // User typed — reset idle detection and status
+ isStreaming = false;
+ clearTimeout(idleTimer);
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ statusEl.textContent = 'Povezan';
+ statusEl.className = 'status connected';
+ }
+ });
+ term.onResize(function(size) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
+ }
+ });
+ containerEl.addEventListener('click', function() { term.focus(); });
+
+ function connect() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
- var url = proto + '//' + location.host + '/ws?project=' + encodeURIComponent(tab.project) +
- '&project_dir=' + encodeURIComponent(tab.projectDir) +
- '&tab=' + encodeURIComponent(tab.id);
- var ws = new WebSocket(url);
+ var url = proto + '//' + location.host + '/ws?project=' + encodeURIComponent(PAGE_PROJECT) +
+ '&project_dir=' + encodeURIComponent(PAGE_PROJECT_DIR);
+ ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
- tab.ws = ws;
ws.onopen = function() {
- if (activeTabId === tab.id) {
- statusEl.textContent = 'Povezan';
- statusEl.className = 'status connected';
- }
- ws.send(JSON.stringify({ type: 'resize', cols: tab.term.cols, rows: tab.term.rows }));
- if (activeTabId === tab.id) tab.term.focus();
+ statusEl.textContent = 'Povezan';
+ statusEl.className = 'status connected';
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
+ term.focus();
};
ws.onmessage = function(event) {
if (event.data instanceof ArrayBuffer) {
- tab.term.write(new Uint8Array(event.data));
+ term.write(new Uint8Array(event.data));
} else {
- tab.term.write(event.data);
+ term.write(event.data);
}
+ // Idle detection — reset timer on each output
+ isStreaming = true;
+ clearTimeout(idleTimer);
+ idleTimer = setTimeout(onOutputIdle, 3000);
};
ws.onclose = function() {
- if (activeTabId === tab.id) {
- statusEl.textContent = 'Nepovezan';
- statusEl.className = 'status';
- }
- // Reconnect only if tab still exists
- if (tabs.find(function(t) { return t.id === tab.id; })) {
- tab.reconnectTimer = setTimeout(function() { connectTab(tab); }, 2000);
- }
+ statusEl.textContent = 'Nepovezan';
+ statusEl.className = 'status';
+ reconnectTimer = setTimeout(connect, 2000);
};
ws.onerror = function() {
- if (activeTabId === tab.id) {
- statusEl.textContent = 'Greška';
- statusEl.className = 'status';
- }
+ statusEl.textContent = 'Greška';
+ statusEl.className = 'status';
};
}
- function addTab(project, projectDir) {
- var tabId = String(nextTabId++);
- var t = createTerminal();
+ // ── Idle detection & notifications ───────────────────
+ function onOutputIdle() {
+ if (!isStreaming) return;
+ isStreaming = false;
- // Create pane
- var pane = document.createElement('div');
- pane.className = 'term-pane';
- pane.id = 'pane-' + tabId;
- terminalsEl.appendChild(pane);
- t.term.open(pane);
+ // Visual indicator — always
+ statusEl.textContent = 'Završeno';
+ statusEl.className = 'status done';
+ var header = document.querySelector('.terminal-header');
+ header.classList.remove('done-flash');
+ void header.offsetWidth; // force reflow
+ header.classList.add('done-flash');
- var tab = {
- id: tabId,
- project: project,
- projectDir: projectDir,
- term: t.term,
- fitAddon: t.fitAddon,
- ws: null,
- reconnectTimer: null,
- container: pane
- };
-
- // Wire input
- t.term.onData(function(data) {
- if (tab.ws && tab.ws.readyState === WebSocket.OPEN) tab.ws.send(data);
- });
- t.term.onResize(function(size) {
- if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
- tab.ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
- }
- });
-
- // Click on pane gives focus
- pane.addEventListener('click', function() { tab.term.focus(); });
-
- tabs.push(tab);
- renderTabs();
- switchTab(tabId);
- connectTab(tab);
-
- return tab;
- }
-
- function switchTab(tabId) {
- activeTabId = tabId;
- tabs.forEach(function(tab) {
- if (tab.id === tabId) {
- tab.container.classList.add('active');
- } else {
- tab.container.classList.remove('active');
- }
- });
- renderTabs();
- var active = tabs.find(function(t) { return t.id === tabId; });
- if (active) {
- setTimeout(function() {
- active.fitAddon.fit();
- active.term.focus();
- }, 50);
- // Update status
- if (active.ws && active.ws.readyState === WebSocket.OPEN) {
- statusEl.textContent = 'Povezan';
- statusEl.className = 'status connected';
- } else {
- statusEl.textContent = 'Nepovezan';
- statusEl.className = 'status';
- }
- }
- }
-
- function closeTab(tabId) {
- if (tabs.length <= 1) return; // Don't close last tab
- var idx = tabs.findIndex(function(t) { return t.id === tabId; });
- if (idx === -1) return;
- var tab = tabs[idx];
-
- // Clean up
- clearTimeout(tab.reconnectTimer);
- if (tab.ws) {
- tab.ws.onclose = null; // Prevent reconnect
- tab.ws.close();
- }
- tab.term.dispose();
- tab.container.remove();
- tabs.splice(idx, 1);
-
- // Switch to neighbor if active was closed
- if (activeTabId === tabId) {
- var newIdx = Math.min(idx, tabs.length - 1);
- switchTab(tabs[newIdx].id);
- } else {
- renderTabs();
- }
- }
-
- function renderTabs() {
- // Remove old tab elements (keep the add button wrapper)
- var addWrap = document.querySelector('.tab-add-wrap');
- var oldTabs = tabBarEl.querySelectorAll('.tab');
- oldTabs.forEach(function(el) { el.remove(); });
-
- tabs.forEach(function(tab) {
- var el = document.createElement('div');
- el.className = 'tab' + (tab.id === activeTabId ? ' active' : '');
-
- var label = document.createElement('span');
- label.textContent = tab.project;
- label.addEventListener('click', function(e) {
- e.stopPropagation();
- switchTab(tab.id);
- });
- el.appendChild(label);
-
- if (tabs.length > 1) {
- var closeBtn = document.createElement('span');
- closeBtn.className = 'tab-close';
- closeBtn.textContent = '\u00d7';
- closeBtn.title = 'Zatvori tab';
- closeBtn.addEventListener('click', function(e) {
- e.stopPropagation();
- closeTab(tab.id);
+ // Background tab — browser notification + title blink
+ if (document.hidden) {
+ if (Notification.permission === 'granted') {
+ var n = new Notification('Claude — ' + PAGE_PROJECT, {
+ body: 'Odgovor je završen',
+ tag: 'claude-done'
});
- el.appendChild(closeBtn);
+ n.onclick = function() { window.focus(); n.close(); };
}
-
- tabBarEl.insertBefore(el, addWrap);
- });
+ startTitleBlink();
+ }
}
- // ── Add tab dropdown ────────────────────────────────
- tabAddBtn.addEventListener('click', function(e) {
- e.stopPropagation();
- if (!tabDropdown.classList.contains('hidden')) {
- tabDropdown.classList.add('hidden');
- return;
- }
- // Fetch projects and show dropdown
- if (projectsCache) {
- showDropdown(projectsCache);
- } else {
- fetch('/api/projects')
- .then(function(r) { return r.json(); })
- .then(function(data) {
- projectsCache = data;
- showDropdown(data);
- })
- .catch(function() {
- // Fallback: just add tab for current project
- addTab(PAGE_PROJECT, PAGE_PROJECT_DIR);
- });
- }
- });
-
- function showDropdown(projects) {
- tabDropdown.innerHTML = '';
- projects.forEach(function(p) {
- var btn = document.createElement('button');
- btn.className = 'tab-dropdown-item';
- btn.textContent = p.name;
- btn.addEventListener('click', function() {
- tabDropdown.classList.add('hidden');
- addTab(p.name, p.path);
- });
- tabDropdown.appendChild(btn);
- });
- tabDropdown.classList.remove('hidden');
+ // Request notification permission
+ if ('Notification' in window && Notification.permission === 'default') {
+ Notification.requestPermission();
}
- // Close dropdown when clicking outside
- document.addEventListener('click', function() {
- tabDropdown.classList.add('hidden');
+ // Title blink when tab is in background
+ function startTitleBlink() {
+ if (titleBlinkTimer) return;
+ var on = true;
+ titleBlinkTimer = setInterval(function() {
+ document.title = on ? '✔ Završeno — ' + PAGE_PROJECT : originalTitle;
+ on = !on;
+ }, 1000);
+ }
+
+ function stopTitleBlink() {
+ if (titleBlinkTimer) {
+ clearInterval(titleBlinkTimer);
+ titleBlinkTimer = null;
+ document.title = originalTitle;
+ }
+ }
+
+ // Stop blinking when tab gets focus
+ document.addEventListener('visibilitychange', function() {
+ if (!document.hidden) stopTitleBlink();
});
+ window.addEventListener('focus', stopTitleBlink);
// ── Window resize ───────────────────────────────────
- window.addEventListener('resize', function() {
- var active = tabs.find(function(t) { return t.id === activeTabId; });
- if (active) active.fitAddon.fit();
- });
+ window.addEventListener('resize', function() { fitAddon.fit(); });
- // ── Apply initial theme ─────────────────────────────
+ // ── Apply initial theme and connect ──────────────────
applyTheme(savedTheme);
-
- // ── Create first tab ────────────────────────────────
- addTab(PAGE_PROJECT, PAGE_PROJECT_DIR);
+ setTimeout(function() { fitAddon.fit(); }, 50);
+ connect();
// ── Sidebar ─────────────────────────────────────────
var sidebar = document.getElementById('sidebar');
@@ -797,10 +533,7 @@
function toggleSidebar() {
sidebar.classList.toggle('collapsed');
localStorage.setItem('sidebar-visible', !sidebar.classList.contains('collapsed'));
- setTimeout(function() {
- var active = tabs.find(function(t) { return t.id === activeTabId; });
- if (active) active.fitAddon.fit();
- }, 200);
+ setTimeout(function() { fitAddon.fit(); }, 200);
}
// ── File viewer ─────────────────────────────────────
@@ -817,56 +550,12 @@
function closeFileViewer() {
document.getElementById('file-viewer').classList.add('hidden');
- var active = tabs.find(function(t) { return t.id === activeTabId; });
- if (active) active.term.focus();
+ term.focus();
}
document.addEventListener('keydown', function(e) {
- if (e.key === 'Escape') {
- closeFileViewer();
- document.getElementById('createProjectModal').classList.add('hidden');
- }
+ if (e.key === 'Escape') closeFileViewer();
});
-
- // ── Create project ───────────────────────────────────
- function showCreateProjectModal() {
- document.getElementById('createProjectModal').classList.remove('hidden');
- setTimeout(function() {
- document.getElementById('newProjectName').focus();
- }, 50);
- }
-
- function createProject(e) {
- e.preventDefault();
- var name = document.getElementById('newProjectName').value.trim();
- var errEl = document.getElementById('createProjectError');
- if (!name) return false;
-
- var form = new FormData();
- form.append('name', name);
-
- fetch('/projects/create', {
- method: 'POST',
- body: form,
- headers: { 'X-Requested-With': 'XMLHttpRequest' }
- })
- .then(function(r) {
- return r.json().then(function(data) {
- if (r.ok) {
- projectsCache = null; // Invalidate cache
- window.location.href = '/chat/' + encodeURIComponent(name);
- } else {
- errEl.textContent = data.error || 'Greška pri kreiranju';
- errEl.style.display = 'block';
- }
- });
- })
- .catch(function() {
- errEl.textContent = 'Greška pri kreiranju';
- errEl.style.display = 'block';
- });
- return false;
- }