Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion public/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ body {
border-radius: var(--radius);
border-left: 2px solid var(--accent);
font-size: 11px;
max-height: 200px;
max-height: 380px;
overflow-y: auto;
}

Expand Down
11 changes: 11 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ <h2>Settings</h2>
<div class="settings-sidebar">
<button class="settings-tab active" data-tab="integrations">&#128279; Integrations</button>
<button class="settings-tab" data-tab="summaries">&#9881; AI Summaries</button>
<button class="settings-tab" data-tab="dashboard">&#9638; Dashboard</button>
</div>
<div class="settings-content">
<div class="settings-panel active" id="tab-integrations">
Expand Down Expand Up @@ -121,6 +122,16 @@ <h2>Settings</h2>
</div>
</div>
</div>
<div class="settings-panel" id="tab-dashboard">
<div class="settings-section">
<div class="section-title">Expanded View</div>
<div class="form-group">
<label for="expanded-tile-items">Transcript items per tile</label>
<input type="number" class="input" id="expanded-tile-items" min="1" max="50" value="5" />
<small class="form-hint">Number of recent transcript messages shown in each expanded tile.</small>
</div>
</div>
</div>
</div>
</div>
<div class="settings-footer">
Expand Down
8 changes: 5 additions & 3 deletions public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ const App = {
document.getElementById('jira-keys').value = keys.join(', ');
document.getElementById('jira-url').value = this.settings.jira_server_url || '';
document.getElementById('summary-interval').value = this.settings.summary_interval || 5;
document.getElementById('expanded-tile-items').value = this.settings.expanded_tile_items || 5;
indicator.textContent = '';
indicator.className = 'save-indicator';
modal.style.display = 'flex';
Expand Down Expand Up @@ -134,6 +135,7 @@ const App = {
const keys = keysRaw.split(',').map(k => k.trim().toUpperCase()).filter(Boolean);
const url = document.getElementById('jira-url').value.trim();
const summaryInterval = parseInt(document.getElementById('summary-interval').value, 10) || 5;
const expandedTileItems = parseInt(document.getElementById('expanded-tile-items').value, 10) || 5;
try {
const resp = await fetch('/api/settings', {
method: 'PUT',
Expand All @@ -142,13 +144,13 @@ const App = {
jira_project_keys: keys,
jira_server_url: url || null,
summary_interval: summaryInterval,
expanded_tile_items: expandedTileItems,
}),
});
const data = await resp.json();
this.settings = data.settings || {};
indicator.textContent = 'Settings saved';
indicator.className = 'save-indicator saved';
setTimeout(() => { indicator.textContent = ''; indicator.className = 'save-indicator'; }, 2000);
closeModal();
Dashboard.render(this.sessions);
} catch (e) {
console.error('Failed to save settings:', e);
indicator.textContent = 'Failed to save';
Expand Down
3 changes: 2 additions & 1 deletion public/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,8 @@ const Dashboard = {
const container = document.getElementById(`expanded-preview-${sessionId}`);
if (!container) return;
try {
const resp = await fetch(`/api/sessions/${sessionId}/transcript?limit=5`);
const limit = (App.settings && App.settings.expanded_tile_items) || 5;
const resp = await fetch(`/api/sessions/${sessionId}/transcript?limit=${limit}`);
const data = await resp.json();
const msgs = data.transcripts || [];
if (msgs.length === 0) {
Expand Down
5 changes: 5 additions & 0 deletions server/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class SettingsUpdate(BaseModel):
jira_project_keys: list[str] | None = None
jira_server_url: str | None = None
summary_interval: int | None = None
expanded_tile_items: int | None = None


_UNSET = object()
Expand Down Expand Up @@ -163,6 +164,10 @@ async def update_settings(req: SettingsUpdate):
if req.summary_interval < 1:
raise HTTPException(status_code=400, detail="summary_interval must be >= 1")
await db.set_setting("summary_interval", json.dumps(req.summary_interval))
if req.expanded_tile_items is not None:
if req.expanded_tile_items < 1:
raise HTTPException(status_code=400, detail="expanded_tile_items must be >= 1")
await db.set_setting("expanded_tile_items", json.dumps(req.expanded_tile_items))
return await get_settings()


Expand Down
26 changes: 26 additions & 0 deletions tests/unit/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,32 @@ async def test_update_settings_summary_interval_negative(client: AsyncClient):
assert resp.status_code == 400


async def test_update_settings_expanded_tile_items(client: AsyncClient):
resp = await client.put(
"/api/settings",
json={"expanded_tile_items": 15},
)
assert resp.status_code == 200
settings = resp.json()["settings"]
assert settings["expanded_tile_items"] == 15


async def test_update_settings_expanded_tile_items_invalid(client: AsyncClient):
resp = await client.put(
"/api/settings",
json={"expanded_tile_items": 0},
)
assert resp.status_code == 400


async def test_update_settings_expanded_tile_items_negative(client: AsyncClient):
resp = await client.put(
"/api/settings",
json={"expanded_tile_items": -1},
)
assert resp.status_code == 400


async def test_get_settings_with_values(client: AsyncClient):
await db.set_setting("jira_project_keys", '["PROJ"]')
await db.set_setting("plain_key", "not-json")
Expand Down
172 changes: 172 additions & 0 deletions tests/unit/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1025,3 +1025,175 @@ async def mock_sleep(seconds):

mock_logger.warning.assert_called_once()
assert "stale-warn-1" in mock_logger.warning.call_args[0][1]


async def test_check_stale_subagents_marks_completed():
"""_check_stale_subagents should mark inactive subagents as completed."""
from server.hooks import _check_stale_subagents

old_time = (datetime.now(UTC) - timedelta(minutes=10)).isoformat()
await db.create_session("parent-sub-stale")
await db.update_session("parent-sub-stale", status="working")
await db.create_session("sub-stale-1")
await db.update_session(
"sub-stale-1",
parent_session_id="parent-sub-stale",
status="working",
last_activity_at=old_time,
)

with patch("server.hooks._on_session_update", new=None):
await _check_stale_subagents(datetime.now(UTC))

sub = await db.get_session("sub-stale-1")
assert sub["status"] == "completed"
assert sub["ended_at"] is not None


async def test_check_stale_subagents_skips_recent():
"""_check_stale_subagents should not touch recently active subagents."""
from server.hooks import _check_stale_subagents

recent_time = datetime.now(UTC).isoformat()
await db.create_session("parent-sub-recent")
await db.create_session("sub-recent-1")
await db.update_session(
"sub-recent-1",
parent_session_id="parent-sub-recent",
status="working",
last_activity_at=recent_time,
)

await _check_stale_subagents(datetime.now(UTC))

sub = await db.get_session("sub-recent-1")
assert sub["status"] == "working"


async def test_check_stale_subagents_broadcasts_parent():
"""_check_stale_subagents should broadcast parent update when subagent completes."""
from server.hooks import _check_stale_subagents, set_update_callback

old_time = (datetime.now(UTC) - timedelta(minutes=10)).isoformat()
await db.create_session("parent-sub-bc")
await db.update_session("parent-sub-bc", status="working")
await db.create_session("sub-bc-1")
await db.update_session(
"sub-bc-1",
parent_session_id="parent-sub-bc",
status="working",
last_activity_at=old_time,
)

callback = AsyncMock()
set_update_callback(callback)

try:
await _check_stale_subagents(datetime.now(UTC))
assert callback.call_count >= 1
# The parent session should have been broadcast
updated_session = callback.call_args[0][0]
assert updated_session["id"] == "parent-sub-bc"
assert "subagents" in updated_session
finally:
set_update_callback(None)


async def test_check_stale_subagents_no_last_activity():
"""_check_stale_subagents should skip subagents without last_activity_at."""
from server.hooks import _check_stale_subagents

await db.create_session("parent-sub-nola")
await db.create_session("sub-nola-1")
await db.update_session(
"sub-nola-1",
parent_session_id="parent-sub-nola",
status="working",
last_activity_at=None,
)

# Verify last_activity_at is actually None
sub_before = await db.get_session("sub-nola-1")
assert sub_before["last_activity_at"] is None

await _check_stale_subagents(datetime.now(UTC))

sub = await db.get_session("sub-nola-1")
assert sub["status"] == "working"


async def test_check_stale_subagents_timezone_naive():
"""_check_stale_subagents should handle timezone-naive timestamps."""
from server.hooks import _check_stale_subagents

# Use a naive timestamp (no timezone info) that is old enough to be stale
old_naive = (datetime.now(UTC) - timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%S")
await db.create_session("parent-sub-naive")
await db.update_session("parent-sub-naive", status="working")
await db.create_session("sub-naive-1")
await db.update_session(
"sub-naive-1",
parent_session_id="parent-sub-naive",
status="working",
last_activity_at=old_naive,
)

with patch("server.hooks._on_session_update", new=None):
await _check_stale_subagents(datetime.now(UTC))

sub = await db.get_session("sub-naive-1")
assert sub["status"] == "completed"


async def test_check_stale_subagents_invalid_timestamp():
"""_check_stale_subagents should handle invalid timestamps gracefully."""
from server.hooks import _check_stale_subagents

await db.create_session("parent-sub-bad")
await db.create_session("sub-bad-ts")
await db.update_session(
"sub-bad-ts",
parent_session_id="parent-sub-bad",
status="working",
last_activity_at="not-a-timestamp",
)

# Should not crash
await _check_stale_subagents(datetime.now(UTC))

sub = await db.get_session("sub-bad-ts")
assert sub["status"] == "working"


async def test_check_stale_sessions_skips_working_subagent():
"""_check_stale_sessions should not mark a session stale if it has working subagents."""
import server.hooks as hooks_mod
from server.hooks import _check_stale_sessions

old_time = (datetime.now(UTC) - timedelta(minutes=10)).isoformat()
await db.create_session("parent-with-sub")
await db.update_session("parent-with-sub", status="working", last_activity_at=old_time)
await db.create_session("sub-active-1")
await db.update_session(
"sub-active-1",
parent_session_id="parent-with-sub",
status="working",
last_activity_at=datetime.now(UTC).isoformat(),
)

call_count = 0

async def mock_sleep(seconds):
nonlocal call_count
call_count += 1
if call_count > 1:
raise asyncio.CancelledError()

with patch("asyncio.sleep", side_effect=mock_sleep), patch.object(hooks_mod, "_on_session_update", new=None):
try:
await _check_stale_sessions()
except asyncio.CancelledError:
pass

parent = await db.get_session("parent-with-sub")
assert parent["status"] == "working"
Loading
Loading