Skip to content
Merged

0.9.9 #149

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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.9.9] - 2026-07-16

### Changed

- 💬 **Unread chats stay where you can see them.** Chat lists now keep unread finished chats above already-read chats, and they refresh when a chat is marked read.
- 🧭 **The app feels a little lighter.** Dividers and borders across the interface are softer, so the workspace chrome gets out of the way more.

### Fixed

- ⏰ **Follow-up timers behave more cleanly.** Timer prompts and subagent handoffs stay internal, pending follow-ups stay hidden until they are ready, and old timer state is still understood.
- 🔎 **Internal work stays out of normal chat search.** Recent-chat search no longer shows hidden timer or subagent chats as if they were regular conversations.
- 🧠 **The chat size meter keeps moving for local models.** llama.cpp chats now show live size updates even when the server does not send token totals until the end.
- 🔐 **File reading avoids credential files.** Computer can read normal absolute paths and `~/` paths, but it blocks common credential files, `.envrc`, and special device files.
- 📱 **Signal bot updates no longer disappear.** When a Signal chat needs an edited answer, Computer now sends the updated text as a new message instead of silently doing nothing.

## [0.9.8] - 2026-07-15

### Added
Expand Down
4 changes: 2 additions & 2 deletions cptr/frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
--app-fg: #525252;
--app-fg-muted: color-mix(in oklab, var(--app-fg) 62%, var(--app-bg));
--app-fg-subtle: color-mix(in oklab, var(--app-fg) 48%, var(--app-bg));
--app-border: color-mix(in oklab, var(--app-fg) 5%, transparent);
--app-divider: color-mix(in oklab, var(--app-fg) 3.5%, transparent);
--app-border: color-mix(in oklab, var(--app-fg) 1.5%, transparent);
--app-divider: color-mix(in oklab, var(--app-fg) 0.875%, transparent);
--app-hover: color-mix(in oklab, var(--app-fg) 6%, transparent);
--app-active: color-mix(in oklab, var(--app-fg) 7%, transparent);
--app-ui-font: var(--font-sans);
Expand Down
1 change: 0 additions & 1 deletion cptr/frontend/src/lib/apis/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export interface ContextUsage {
estimated_tokens: number;
threshold: number;
percent: number;
source: 'estimated';
}

export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
Expand Down
50 changes: 44 additions & 6 deletions cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,20 @@
const data = await getChats(path, 5, append ? existing.length : 0, 'updated_at', 'desc');
wsChatsCache = new Map([
...wsChatsCache,
[path, append ? [...existing, ...(data.chats || [])] : data.chats || []]
[
path,
append
? [...existing, ...(data.chats || [])].sort(
(a, b) =>
Number(
!b.is_active && (b.last_read_at === null || b.updated_at > b.last_read_at)
) -
Number(
!a.is_active && (a.last_read_at === null || a.updated_at > a.last_read_at)
) || b.updated_at - a.updated_at
)
: data.chats || []
]
]);
updateChatStatuses(data.chats || [], path);
wsChatsHasMore = new Map([...wsChatsHasMore, [path, data.has_more]]);
Expand Down Expand Up @@ -196,10 +209,14 @@
}

let known = false;
const shouldReorder =
typeof data.updated_at === 'number' ||
typeof data.last_read_at === 'number' ||
typeof data.active === 'boolean';

wsChatsCache = new Map(
[...wsChatsCache].map(([path, chats]) => [
path,
chats.map((chat) => {
[...wsChatsCache].map(([path, chats]) => {
const nextChats = chats.map((chat) => {
if (chat.id !== data.chat_id) return chat;
known = true;
return {
Expand All @@ -209,14 +226,35 @@
...(typeof data.last_read_at === 'number' ? { last_read_at: data.last_read_at } : {}),
...(typeof data.active === 'boolean' ? { is_active: data.active } : {})
};
})
])
});
return [
path,
shouldReorder
? nextChats.sort(
(a, b) =>
Number(
!b.is_active && (b.last_read_at === null || b.updated_at > b.last_read_at)
) -
Number(
!a.is_active && (a.last_read_at === null || a.updated_at > a.last_read_at)
) || b.updated_at - a.updated_at
)
: nextChats
] as [string, ChatInfo[]];
})
);

// A chat created in another session is not yet in this sidebar's page.
// Refresh only that expanded workspace; all known rows update in place.
if (!known && data.workspace && expandedWorkspaces.has(data.workspace)) {
void fetchWorkspaceChats(data.workspace);
} else if (
known &&
typeof data.last_read_at === 'number' &&
data.workspace &&
expandedWorkspaces.has(data.workspace)
) {
void fetchWorkspaceChats(data.workspace);
}
}

Expand Down
66 changes: 55 additions & 11 deletions cptr/frontend/src/lib/components/chat/ChatPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,11 @@
}

function isPendingHiddenMessage(m: ChatMessageRow): boolean {
return !!(m.meta?.queued || m.meta?.async_subagent_pending);
return !!(
m.meta?.queued ||
m.meta?.async_subagent_pending ||
(m.meta?.internal === true && m.meta?.type === 'subagent' && m.meta?.status === 'pending')
);
}

function nearestVisibleAncestorId(
Expand Down Expand Up @@ -462,7 +466,17 @@
try {
const offset = (page - 1) * CHATS_PAGE_SIZE;
const data = await getChats(workspace, CHATS_PAGE_SIZE, offset, chatSortBy, chatSortDir);
previousChats = data.chats || [];
previousChats =
chatSortBy === 'updated_at'
? [...(data.chats || [])].sort(
(a, b) =>
Number(!b.is_active && (b.last_read_at === null || b.updated_at > b.last_read_at)) -
Number(
!a.is_active && (a.last_read_at === null || a.updated_at > a.last_read_at)
) ||
(chatSortDir === 'desc' ? b.updated_at - a.updated_at : a.updated_at - b.updated_at)
)
: data.chats || [];
totalChats = data.total;
chatPage = page;
} catch {
Expand Down Expand Up @@ -538,6 +552,10 @@
pending_inputs_processed?: boolean;
async_subagent_pending?: boolean;
title?: string;
workspace?: string;
active?: boolean;
updated_at?: number;
last_read_at?: number;
}) {
// On the landing page, update the chat list in place from socket events
if (isLanding) {
Expand All @@ -550,15 +568,41 @@
loadPreviousChats(chatPage);
}, 300);
} else {
if (data.done) {
previousChats = previousChats.map((c) =>
c.id === data.chat_id ? { ...c, is_active: false } : c
);
}
if (data.title) {
previousChats = previousChats.map((c) =>
c.id === data.chat_id ? { ...c, title: data.title! } : c
);
const nextChats = previousChats.map((c) =>
c.id === data.chat_id
? {
...c,
...(data.title ? { title: data.title } : {}),
...(data.done ? { is_active: false } : {}),
...(typeof data.active === 'boolean' ? { is_active: data.active } : {}),
...(typeof data.updated_at === 'number' ? { updated_at: data.updated_at } : {}),
...(typeof data.last_read_at === 'number'
? { last_read_at: data.last_read_at }
: {})
}
: c
);
previousChats =
chatSortBy === 'updated_at'
? nextChats.sort(
(a, b) =>
Number(
!b.is_active && (b.last_read_at === null || b.updated_at > b.last_read_at)
) -
Number(
!a.is_active && (a.last_read_at === null || a.updated_at > a.last_read_at)
) ||
(chatSortDir === 'desc'
? b.updated_at - a.updated_at
: a.updated_at - b.updated_at)
)
: nextChats;
if (typeof data.last_read_at === 'number') {
if (landingRefreshTimer) clearTimeout(landingRefreshTimer);
landingRefreshTimer = setTimeout(() => {
landingRefreshTimer = null;
loadPreviousChats(chatPage);
}, 100);
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion cptr/frontend/src/lib/components/chat/UserMessage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@
let textareaEl: HTMLTextAreaElement;
let asyncExpanded = $state(false);
let timerExpanded = $state(false);
const isAsyncSubagentResult = $derived(meta?.async_subagent_result === true);
const isAsyncSubagentResult = $derived(
(meta?.internal === true && meta?.type === 'subagent') || meta?.async_subagent_result === true
);
const isTimer = $derived(meta?.internal === true && meta?.type === 'timer');
const delegationId = $derived(meta?.delegation_id || '');
const delegationIds = $derived(Array.isArray(meta?.delegation_ids) ? meta.delegation_ids : []);
Expand Down
12 changes: 11 additions & 1 deletion cptr/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
from cptr.models.workspaces import Workspace
from cptr.models.config import Config
from cptr.models.files import File
from cptr.models.chats import Chat, ChatMessage, is_internal_chat
from cptr.models.chats import (
Chat,
ChatMessage,
internal_status,
is_internal_chat,
is_pending_subagent_result_message,
is_subagent_result_message,
)
from cptr.models.automations import Automation, AutomationRun

__all__ = [
Expand All @@ -18,7 +25,10 @@
"File",
"Chat",
"ChatMessage",
"internal_status",
"is_internal_chat",
"is_pending_subagent_result_message",
"is_subagent_result_message",
"Automation",
"AutomationRun",
]
37 changes: 33 additions & 4 deletions cptr/models/chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,36 @@ def _uuid() -> str:
def is_internal_chat(meta: dict | None) -> bool:
"""Whether a chat is harness-owned and should stay out of normal chat surfaces."""
meta = meta or {}
return bool(meta.get("internal") or meta.get("subagent"))
return bool(meta.get("internal") is True or meta.get("subagent") is True)


def internal_status(meta: dict | None) -> str | None:
"""Canonical internal lifecycle status, with legacy timer fallback."""
meta = meta or {}
return meta.get("status") or meta.get("timer_status")


def is_subagent_result_message(meta: dict | None) -> bool:
"""Whether a parent-chat message carries a subagent result."""
meta = meta or {}
return bool(
(meta.get("internal") is True and meta.get("type") == "subagent")
or meta.get("async_subagent_result")
or meta.get("async_subagent_pending")
)


def is_pending_subagent_result_message(meta: dict | None) -> bool:
"""Whether a subagent result is queued behind active parent work."""
meta = meta or {}
return bool(
(
meta.get("internal") is True
and meta.get("type") == "subagent"
and meta.get("status") == "pending"
)
or meta.get("async_subagent_pending")
)


class Chat(Base):
Expand Down Expand Up @@ -75,7 +104,7 @@ async def get_due_timers(now_ns: int, limit: int = 10) -> list[Chat]:
for chat in result.scalars().all()
if (chat.meta or {}).get("internal") is True
and (chat.meta or {}).get("type") == "timer"
and (chat.meta or {}).get("timer_status") == "pending"
and internal_status(chat.meta) == "pending"
and int((chat.meta or {}).get("timer_at") or 0) <= now_ns
]
timers.sort(key=lambda chat: int((chat.meta or {}).get("timer_at") or 0))
Expand All @@ -92,7 +121,7 @@ async def get_pending_timers(parent_chat_id: str) -> list[Chat]:
if (chat.meta or {}).get("internal") is True
and (chat.meta or {}).get("type") == "timer"
and (chat.meta or {}).get("parent_chat_id") == parent_chat_id
and (chat.meta or {}).get("timer_status") == "pending"
and internal_status(chat.meta) == "pending"
]

@staticmethod
Expand All @@ -105,7 +134,7 @@ async def get_timers(status: str | None = None) -> list[Chat]:
for chat in result.scalars().all()
if (chat.meta or {}).get("internal") is True
and (chat.meta or {}).get("type") == "timer"
and (status is None or (chat.meta or {}).get("timer_status") == status)
and (status is None or internal_status(chat.meta) == status)
]

@staticmethod
Expand Down
20 changes: 17 additions & 3 deletions cptr/routers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,22 @@ def _scan_chat_files() -> list[dict]:
reverse = sort_dir != "asc"
if sort_field == "updated_at":
visible_chats.sort(
key=lambda c: c["updated_at"] if isinstance(c["updated_at"], int) else 0,
reverse=reverse,
key=lambda c: (
not (
not c.get("is_active")
and (c["updated_at"] if isinstance(c["updated_at"], int) else 0) > 0
and (
c["last_read_at"] is None
or (c["updated_at"] if isinstance(c["updated_at"], int) else 0)
> c["last_read_at"]
)
),
-(c["updated_at"] if isinstance(c["updated_at"], int) else 0)
if reverse
else c["updated_at"]
if isinstance(c["updated_at"], int)
else 0,
),
)
else:
visible_chats.sort(key=lambda c: str(c["title"] or ""), reverse=reverse)
Expand Down Expand Up @@ -478,7 +492,7 @@ async def _get_chat_context_usage(chat, model_id: str | None = None) -> dict | N
[{"role": m.role, "content": m.content or ""} for m in trailing_messages]
)
return build_context_usage(
tokens, threshold=compact_token_threshold, source="estimated"
tokens, threshold=compact_token_threshold
)

return estimate_context_usage(messages, system, threshold=compact_token_threshold)
Expand Down
4 changes: 2 additions & 2 deletions cptr/routers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from fastapi import APIRouter, HTTPException, Query, Request

from cptr.models import Chat
from cptr.models import Chat, is_internal_chat
from cptr.routers.workspace import walk_and_rank_files

router = APIRouter(prefix="/api/search", tags=["search"])
Expand Down Expand Up @@ -123,7 +123,7 @@ async def recent_chats(
rows = [
c
for c in rows
if not (c.meta or {}).get("subagent")
if not is_internal_chat(c.meta)
and (workspace is None or (c.meta or {}).get("workspace") == workspace)
][:limit]

Expand Down
Loading
Loading