Pojednostavljen chat na jedan terminal, dodata notifikacija kad Claude završi
Some checks failed
Tests / unit-tests (push) Failing after 22s

Uklonjen multi-tab sistem — sada jedna PTY sesija po stranici.
Dodat idle detection: status "Završeno", flash animacija, browser
notifikacija i treptanje naslova kad je tab u pozadini.
CSS premešten iz inline stilova u style.css.
Dodat /api/projects endpoint i testovi za PTY sesije.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-18 07:20:55 +00:00
parent e60e574287
commit 3122c5cba9
6 changed files with 356 additions and 433 deletions

View File

@ -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

43
main.go
View File

@ -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")

View File

@ -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")
}
})
}

View File

@ -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;

View File

@ -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 @@
<!-- Projekti -->
<div class="sidebar-section">
<div class="sidebar-section-title" style="display:flex;justify-content:space-between;align-items:center;">
Projekti
<span class="sidebar-add-btn" onclick="showCreateProjectModal()" title="Novi projekat">+</span>
</div>
<div class="sidebar-section-title">Projekti</div>
</div>
<div class="sidebar-files sidebar-projects-list">
{{range .Projects}}
@ -379,22 +256,6 @@
</div>
</div>
<!-- Modal za kreiranje projekta -->
<div id="createProjectModal" class="create-project-modal hidden" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="create-project-box">
<h3>Novi projekat</h3>
<form id="createProjectForm" onsubmit="return createProject(event)">
<input type="text" id="newProjectName" placeholder="ime-projekta" required>
<p style="color:var(--text-muted);font-size:0.65rem;margin:0.3rem 0;">Slova, brojevi, - i _</p>
<div style="display:flex;gap:0.4rem;justify-content:flex-end;">
<button type="button" onclick="document.getElementById('createProjectModal').classList.add('hidden')" style="background:var(--bg-primary);color:var(--text-secondary);border:1px solid var(--border);border-radius:4px;padding:0.3rem 0.6rem;font-size:0.7rem;cursor:pointer;font-family:inherit;">Otkaži</button>
<button type="submit" style="background:var(--accent);color:#fff;border:none;border-radius:4px;padding:0.3rem 0.6rem;font-size:0.7rem;cursor:pointer;font-family:inherit;">Kreiraj</button>
</div>
</form>
<div id="createProjectError" style="color:var(--error);font-size:0.65rem;margin-top:0.3rem;display:none;"></div>
</div>
</div>
<!-- Main terminal area -->
<div class="main">
<div class="terminal-header">
@ -416,14 +277,7 @@
</select>
</div>
</div>
<div class="tab-bar" id="tab-bar">
<!-- Tabs rendered by JS -->
<div class="tab-add-wrap">
<button class="tab-add" id="tab-add-btn" title="Novi tab">+</button>
<div class="tab-dropdown hidden" id="tab-dropdown"></div>
</div>
</div>
<div id="terminals"></div>
<div id="terminal-container"></div>
</div>
<!-- File viewer overlay -->
@ -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;
}
</script>
</body>
</html>

3
ws.go
View File

@ -39,6 +39,7 @@ func (h *TerminalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "missing project params", http.StatusBadRequest)
return
}
sessionKey := project
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
@ -47,7 +48,7 @@ func (h *TerminalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
defer conn.Close()
sess, isNew, err := h.ptyMgr.GetOrCreate(project, projectDir)
sess, isNew, err := h.ptyMgr.GetOrCreate(sessionKey, projectDir)
if err != nil {
log.Printf("PTY session error: %v", err)
conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("\r\nGreška: %v\r\n", err)))