Skip to content
82 changes: 82 additions & 0 deletions public/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,88 @@ body {
.card-effort.medium, .card-effort.standard { background: rgba(59, 130, 246, 0.12); color: #93c5fd; }
.card-effort.low { background: rgba(82, 82, 91, 0.12); color: var(--text-dim); }

/* Lock button */
.card-lock-btn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
opacity: 0.3;
transition: opacity var(--transition);
flex-shrink: 0;
}
.card-lock-btn:hover { opacity: 0.8; }
.card-lock-btn.locked { opacity: 0.7; color: var(--status-waiting); }

/* Activity preview */
.card-preview {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
margin-bottom: 6px;
font-size: 11px;
color: var(--text-dim);
font-family: var(--font-mono);
cursor: pointer;
overflow: hidden;
}
.card-preview:hover { color: var(--text-secondary); }

.preview-chevron {
font-size: 10px;
color: var(--text-faint);
transition: transform 0.15s ease;
flex-shrink: 0;
}

.preview-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}

.card-preview-expanded {
margin-bottom: 8px;
padding: 6px 8px;
background: var(--surface-2);
border-radius: var(--radius);
border-left: 2px solid var(--accent);
font-size: 11px;
max-height: 200px;
overflow-y: auto;
}

.preview-msg {
padding: 3px 0;
color: var(--text-secondary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.preview-msg + .preview-msg {
border-top: 1px solid var(--border);
}

.preview-role {
display: inline-block;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
border-radius: 3px;
font-size: 9px;
font-weight: 700;
margin-right: 4px;
}
.preview-role.user { background: rgba(59, 130, 246, 0.15); color: #93c5fd; }
.preview-role.assistant { background: rgba(124, 58, 237, 0.15); color: #c4b5fd; }
.preview-role.tool_result { background: rgba(245, 158, 11, 0.15); color: #fcd34d; }

.card-task {
font-size: 12px;
color: var(--text-secondary);
Expand Down
59 changes: 59 additions & 0 deletions public/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,24 @@ const Dashboard = {
const displayTitle = s.display_name || s.project_name || s.id;
const projectPath = s.project_path ? s.project_path.replace(/^\/Users\/[^/]+\//, '~/') : '';

const isLocked = s.display_name_locked;
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? 'Title locked (click to unlock)' : 'Title auto-updates (click to lock)';

const previewText = s.last_activity_preview || '';
const previewLine = previewText
? `<div class="card-preview" onclick="event.stopPropagation(); Dashboard.togglePreview('${this._escapeHTML(s.id)}', this)">
<span class="preview-chevron">&#9656;</span>
<span class="preview-text">${this._escapeHTML(previewText)}</span>
</div>
<div class="card-preview-expanded" id="preview-${s.id}" style="display:none"></div>`
: '';

return `
<div class="card-header">
<span class="status-dot ${status}"></span>
<span class="card-project" onclick="event.stopPropagation(); Dashboard.editDisplayName('${this._escapeHTML(s.id)}', '${this._escapeHTML(displayTitle)}')" title="Click to rename">${this._escapeHTML(displayTitle)}</span>
<button class="card-lock-btn ${isLocked ? 'locked' : ''}" onclick="event.stopPropagation(); Dashboard.toggleLock('${this._escapeHTML(s.id)}', ${!isLocked})" title="${lockTitle}">${lockIcon}</button>
<span class="card-status-label ${status}">${status}</span>
</div>
${sessionName}
Expand All @@ -123,6 +137,7 @@ const Dashboard = {
<span class="card-meta-item">${this._escapeHTML(modelShort)}</span>
${effortBadge}
</div>
${previewLine}
${s.task_description ? `<div class="card-task">${this._escapeHTML(s.task_description)}</div>` : ''}
<div class="context-bar">
<div class="context-bar-label">
Expand Down Expand Up @@ -234,6 +249,50 @@ const Dashboard = {
});
},

toggleLock(sessionId, locked) {
fetch(`/api/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name_locked: locked }),
})
.then(r => r.json())
.then(data => {
if (data.session) {
App.sessions[data.session.id] = data.session;
this.updateCard(data.session);
}
})
.catch(e => console.error('Failed to toggle lock:', e));
},

async togglePreview(sessionId, headerEl) {
const expanded = document.getElementById(`preview-${sessionId}`);
if (!expanded) return;
const isOpen = expanded.style.display !== 'none';
const chevron = headerEl.querySelector('.preview-chevron');

if (isOpen) {
expanded.style.display = 'none';
if (chevron) chevron.style.transform = '';
return;
}

try {
const resp = await fetch(`/api/sessions/${sessionId}/transcript?limit=3`);
const data = await resp.json();
const msgs = data.transcripts || [];
expanded.innerHTML = msgs.map(t => {
const roleLabel = t.role === 'user' ? 'U' : t.role === 'assistant' ? 'A' : 'T';
const text = (t.content || '').substring(0, 200);
return `<div class="preview-msg"><span class="preview-role ${t.role}">${roleLabel}</span> ${this._escapeHTML(text)}</div>`;
}).join('');
expanded.style.display = 'block';
if (chevron) chevron.style.transform = 'rotate(90deg)';
} catch (e) {
console.error('Preview fetch failed:', e);
}
},

_escapeHTML(str) {
if (!str) return '';
const div = document.createElement('div');
Expand Down
2 changes: 1 addition & 1 deletion public/js/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ const SessionViewer = {

async _fetchTranscript(sessionId) {
try {
const resp = await fetch(`/api/sessions/${sessionId}/transcript?limit=200`);
const resp = await fetch(`/api/sessions/${sessionId}/transcript?limit=1000`);
const data = await resp.json();
const transcripts = data.transcripts || [];

Expand Down
2 changes: 1 addition & 1 deletion server/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ async def _create_tables(db: aiosqlite.Connection):
""")

# Add columns that may not exist in older databases
for col, col_type in [("session_name", "TEXT"), ("effort_level", "TEXT"), ("ticket_id", "TEXT"), ("display_name", "TEXT")]:
for col, col_type in [("session_name", "TEXT"), ("effort_level", "TEXT"), ("ticket_id", "TEXT"), ("display_name", "TEXT"), ("display_name_locked", "INTEGER DEFAULT 0"), ("last_activity_preview", "TEXT")]:
try:
await db.execute(f"ALTER TABLE sessions ADD COLUMN {col} {col_type}")
except Exception:
Expand Down
3 changes: 0 additions & 3 deletions server/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,6 @@ async def process_hook_event(event_data: dict) -> dict | None:

elif event_type == "Notification":
base_updates["status"] = "waiting"
message = event_data.get("message", "")
if message:
base_updates["task_description"] = message
session = await db.update_session(session_id, **base_updates)

elif event_type == "SessionEnd":
Expand Down
3 changes: 3 additions & 0 deletions server/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class SessionPatch(BaseModel):
model_config = {"extra": "forbid"}
ticket_id: str | None = _UNSET
display_name: str | None = _UNSET
display_name_locked: bool | None = _UNSET


@router.get("/health")
Expand Down Expand Up @@ -161,6 +162,8 @@ async def patch_session(session_id: str, req: SessionPatch):
updates["ticket_id"] = req.ticket_id or None
if req.display_name is not _UNSET:
updates["display_name"] = req.display_name or None
if req.display_name_locked is not _UNSET:
updates["display_name_locked"] = 1 if req.display_name_locked else 0
if not updates:
return {"session": session}
updated = await db.update_session(session_id, **updates)
Expand Down
124 changes: 123 additions & 1 deletion server/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,24 @@

CLAUDE_PROJECTS_DIR = os.path.expanduser("~/.claude/projects")

# Track last read position per file
# Track last read position per file (persisted to DB across restarts)
_file_positions: dict[str, int] = {}


async def _load_file_positions():
"""Restore file positions from DB on startup."""
raw = await db.get_setting("file_positions")
if raw:
try:
_file_positions.update(json.loads(raw))
except (json.JSONDecodeError, TypeError):
pass


async def _save_file_positions():
"""Persist file positions to DB."""
await db.set_setting("file_positions", json.dumps(_file_positions))

# Debounce tracking
_debounce_tasks: dict[str, asyncio.Task] = {}
DEBOUNCE_SECONDS = 1.0
Expand Down Expand Up @@ -250,6 +265,86 @@ def _infer_context_max(model: str | None) -> int:
return 200000


def _generate_auto_title(task_description: str, git_branch: str | None = None) -> str | None:
"""Generate a short auto-title from the task description and git branch.

Prefers the git branch name (cleaned up) since it's usually more descriptive
than a truncated user message. Falls back to first ~5 words of task description.
"""
# Try to derive a title from git branch (e.g., "feature/PROJ-42-fix-auth" → "Fix Auth")
if git_branch:
# Strip common prefixes
branch = git_branch
for prefix in ("feature/", "feat/", "fix/", "bugfix/", "hotfix/", "chore/", "refactor/"):
if branch.lower().startswith(prefix):
branch = branch[len(prefix):]
break
# Strip ticket IDs (e.g., "PROJ-42-" or "CIT-357-")
branch = re.sub(r'^[A-Z]+-\d+-', '', branch)
if branch:
# Convert kebab-case to title case
words = branch.replace("-", " ").replace("_", " ").split()
if len(words) >= 2:
return " ".join(w.capitalize() for w in words[:6])

# Fall back to truncated task description
if not task_description or len(task_description.strip()) < 6:
return None
words = task_description.strip().split()
if len(words) <= 5:
return task_description.strip()
return " ".join(words[:5]) + "..."


_TOOL_VERBS = {
"edit": "Editing",
"write": "Writing",
"read": "Reading",
"bash": "Running",
"grep": "Searching",
"glob": "Searching",
"agent": "Running agent",
}


def _extract_activity_preview(entries: list[dict]) -> str | None:
"""Derive a single-line activity preview from the most recent parsed entries.

Priority: tool call > assistant text > None.
"""
for entry in reversed(entries):
content = entry.get("content", "") or ""

# Check for tool calls: [Tool: Name]\nsummary
if "[Tool: " in content:
# Extract the last tool call in the content
tool_matches = re.findall(r"\[Tool: (\w+)\]\n?(.*?)(?=\[Tool: |\Z)", content, re.DOTALL)
if tool_matches:
name, summary = tool_matches[-1]
verb = _TOOL_VERBS.get(name.lower(), f"Using {name}")
# Extract file path or command from summary
first_line = summary.strip().split("\n")[0].strip() if summary.strip() else ""
if first_line:
# Shorten file paths
if "/" in first_line:
parts = first_line.split("/")
first_line = "/".join(parts[-2:]) if len(parts) > 2 else first_line
# For Bash, strip the $ prefix
if name.lower() == "bash" and first_line.startswith("$ "):
first_line = first_line[2:]
return f"{verb}: {first_line}"[:100]
return verb

# Plain assistant text — take first sentence
if entry.get("role") == "assistant" and content and "[Tool: " not in content:
# First sentence or first 80 chars
sentence = re.split(r'[.!?\n]', content)[0].strip()
if sentence:
return sentence[:80] + ("..." if len(sentence) > 80 else "")

return None


def _session_id_from_path(file_path: str) -> str | None:
"""Extract a session identifier from a JSONL file path."""
p = Path(file_path)
Expand Down Expand Up @@ -315,6 +410,7 @@ async def _process_file_changes(file_path: str):
slug = None
effort_level = None
first_user_message = None
parsed_entries = []

for line in new_lines:
line = line.strip()
Expand Down Expand Up @@ -347,6 +443,8 @@ async def _process_file_changes(file_path: str):
latest_output = entry["usage"]["output_tokens"]
latest_cache = entry["usage"]["cache_tokens"]

parsed_entries.append(entry)

await db.add_transcript(
session_id=session_id,
role=entry["role"],
Expand Down Expand Up @@ -405,14 +503,36 @@ async def _process_file_changes(file_path: str):
session_updates["context_max"] = max_ctx
session_updates["context_usage_percent"] = min((context_tokens / max_ctx) * 100, 100)

# Activity preview from latest entries
preview = _extract_activity_preview(parsed_entries)
if preview:
session_updates["last_activity_preview"] = preview

# Auto-generate display_name when missing and not locked
if first_user_message:
session = session or await db.get_session(session_id)
if session and not session.get("display_name_locked") and not session.get("display_name"):
auto_title = _generate_auto_title(first_user_message, git_branch)
if auto_title:
session_updates["display_name"] = auto_title

if session_updates:
await db.update_session(session_id, **session_updates)
# Broadcast to dashboard clients
try:
from server.routes.ws import broadcast_session_update
updated = await db.get_session(session_id)
if updated:
await broadcast_session_update(updated)
except Exception:
pass # Non-critical


async def _debounced_process(file_path: str):
"""Debounce file processing to avoid excessive reads."""
await asyncio.sleep(DEBOUNCE_SECONDS)
await _process_file_changes(file_path)
await _save_file_positions()


def _schedule_process(file_path: str):
Expand All @@ -428,6 +548,8 @@ async def start_watcher():
"""Start watching for JSONL file changes."""
global _watcher_task

await _load_file_positions()

projects_dir = CLAUDE_PROJECTS_DIR
if not os.path.isdir(projects_dir):
logger.info("Claude projects directory not found: %s", projects_dir)
Expand Down
Loading
Loading