Skip to content
Closed
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
b035955
feat(platform): expert task board on Home (POC)
0ubbe Oct 15, 2026
b5b4f9a
fix(backend): skip hidden messages in task card previews
0ubbe Oct 15, 2026
b62b699
fix(frontend): drop the custom Home background colour
0ubbe Oct 15, 2026
ff7db0c
fix(frontend): match the Home background to the team page
0ubbe Oct 15, 2026
829c5d5
feat(frontend): Linear-style task board
0ubbe Oct 15, 2026
054e3e9
fix(frontend): grey border on task card avatars
0ubbe Oct 15, 2026
e425180
fix(frontend): put the expert avatar beside their name on task cards
0ubbe Oct 15, 2026
211e698
feat(frontend): show the expert's job title on task cards
0ubbe Oct 15, 2026
225f3e5
fix(frontend): mute the expert name and job title on task cards
0ubbe Oct 15, 2026
c9c47fc
fix(frontend): more space between expert line and task card title
0ubbe Oct 15, 2026
9f8f11f
feat(platform): archive chats, with Clear on the Done column
0ubbe Oct 15, 2026
2a98576
feat(platform): file count on task cards, one files icon everywhere
0ubbe Oct 15, 2026
c661ac6
fix(frontend): task board columns fill the screen height
0ubbe Oct 15, 2026
df00944
fix(frontend): task board spans the page width
0ubbe Oct 15, 2026
4244c9a
fix(frontend): align the task card summary with the card edge
0ubbe Oct 15, 2026
db24ca0
fix(frontend): task card time in the bottom-right corner
0ubbe Oct 15, 2026
ea1df4e
feat(frontend): Scheduled column and follow-up badge on the task board
0ubbe Oct 15, 2026
6cbbe7b
feat(frontend): drop To do; New task card leads In progress
0ubbe Oct 15, 2026
cc5f53f
feat(frontend): glass orb on the New task card
0ubbe Oct 15, 2026
f952214
fix(frontend): quieter frosted button on the New task card
0ubbe Oct 15, 2026
3e3a7b3
feat(frontend): New schedule card on the Scheduled column
0ubbe Oct 15, 2026
4dd8328
fix(frontend): Add column sits in the task board header
0ubbe Oct 15, 2026
633ffdf
feat(frontend): frosted + button in the Home composer
0ubbe Oct 15, 2026
dfb632e
fix(frontend): larger sidebar logo on desktop
0ubbe Oct 15, 2026
d9cde47
fix(frontend): plain mic icon button in the Home composer
0ubbe Oct 15, 2026
8b81044
Merge remote-tracking branch 'origin/dev' into 0ubbe/expert-task-boar…
0ubbe Oct 16, 2026
660d5cb
feat(frontend): Recent work list under the Home task board
0ubbe Oct 16, 2026
c61ebef
fix(frontend): Recent work reads files from the file list, shows when…
0ubbe Oct 16, 2026
9a53475
fix(frontend): ringed avatars in the Recent work list
0ubbe Oct 16, 2026
92b8f7e
fix(frontend): centre Recent work avatars on their line
0ubbe Oct 16, 2026
95804aa
fix(frontend): larger avatars in the Recent work list
0ubbe Oct 16, 2026
c4bf231
fix(platform): preserve task board state and batch chat archives
0ubbe Oct 16, 2026
c1b54a6
fix(frontend): assert folder navigation without fixing sort query
0ubbe Oct 16, 2026
8367e75
Merge branch 'dev' into 0ubbe/expert-task-board-poc
0ubbe Oct 19, 2026
126a348
fix(backend): bring an archived chat back when its turn is queued
0ubbe Oct 19, 2026
14de254
fix(frontend): keep a chat preview that reads as a date as text
0ubbe Oct 19, 2026
8c1b2d8
fix(frontend): tint expert avatars on task cards
0ubbe Oct 19, 2026
843b293
fix(frontend): wait for the expert roster before crediting recent files
0ubbe Oct 19, 2026
938adbe
refactor(frontend): move task board archiving into its own hook
0ubbe Oct 19, 2026
7dd99d1
fix(backend): only archive idle chats
0ubbe Oct 19, 2026
6eeb3c1
refactor(backend): import update_chat_session_status at module level …
0ubbe Oct 19, 2026
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
86 changes: 85 additions & 1 deletion autogpt_platform/backend/backend/api/features/chat/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
from backend.copilot.db import (
chat_message_has_assistant_reply,
get_chat_messages_paginated,
get_last_message_texts,
set_chat_sessions_archived,
)
from backend.copilot.executor.utils import enqueue_cancel_task, schedule_chat_turn
from backend.copilot.expert_kickoff import (
Expand Down Expand Up @@ -169,7 +171,10 @@
from backend.data.credit import UsageTransactionMetadata, get_user_credit_model
from backend.data.redis_client import get_redis_async
from backend.data.understanding import get_business_understanding
from backend.data.workspace import build_files_block
from backend.data.workspace import (
build_files_block,
count_generated_files_by_session,
)
from backend.data.workspace_folder import resolve_attachable_workspace_folders
from backend.integrations.codex.access import enforce_codex_access_http
from backend.integrations.credentials_store import provider_matches
Expand Down Expand Up @@ -540,6 +545,18 @@ class SessionSummaryResponse(BaseModel):
source_platform: str | None = None
is_pinned: bool = False
expert_id: str | None = None
# First line of the newest user/assistant text; None until the chat starts.
last_message: str | None = None
# Files the chat produced, as counted by the chat's files button.
file_count: int = 0


LAST_MESSAGE_PREVIEW_CHARS = 200


def _first_line(text: str) -> str:
line = next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
return line[:LAST_MESSAGE_PREVIEW_CHARS]


class ListSessionsResponse(BaseModel):
Expand Down Expand Up @@ -570,6 +587,19 @@ def title_must_not_be_blank(cls, v: str) -> str:
return stripped


class ArchiveSessionsRequest(BaseModel):
"""Request model for archiving or restoring chat sessions."""

session_ids: list[str] = Field(min_length=1, max_length=100)
archived: bool = True


class ArchiveSessionsResponse(BaseModel):
"""Response model for the archive sessions endpoint."""

updated: int


class UpdateSessionPinnedRequest(BaseModel):
"""Request model for pinning/unpinning a session."""

Expand Down Expand Up @@ -647,6 +677,29 @@ async def list_sessions(
"Failed to fetch processing status from Redis; defaulting to empty"
)

previews: dict[str, str] = {}
file_counts: dict[str, int] = {}
if sessions:
session_ids = [session.session_id for session in sessions]
try:
file_counts = await count_generated_files_by_session(user_id, session_ids)
except Exception:
logger.warning(
"Failed to count session files; defaulting to empty", exc_info=True
)
try:
texts = await get_last_message_texts(session_ids)
previews = {
session_id: line
for session_id, text in texts.items()
if (line := _first_line(strip_injected_context_for_display(text)))
}
except Exception:
logger.warning(
"Failed to fetch last message previews; defaulting to empty",
exc_info=True,
)

return ListSessionsResponse(
sessions=[
SessionSummaryResponse(
Expand All @@ -659,6 +712,8 @@ async def list_sessions(
source_platform=session.metadata.source_platform,
is_pinned=session.is_pinned,
expert_id=session.expert_id,
last_message=previews.get(session.session_id),
file_count=file_counts.get(session.session_id, 0),
)
for session in sessions
],
Expand Down Expand Up @@ -1276,6 +1331,35 @@ async def update_session_title_route(
return {"status": "ok"}


@router.post(
"/sessions/archive",
summary="Archive sessions",
dependencies=[Security(auth.requires_user)],
)
async def archive_sessions(
request: ArchiveSessionsRequest,
user_id: Annotated[str, Security(auth.get_user_id)],
) -> ArchiveSessionsResponse:
"""
Archive or restore chat sessions.

Archived sessions drop out of the session list and search until the
user restores them or a new turn starts in them. Ids the user does not
own, or already in the requested state, are skipped.

Args:
request: The session ids and whether to archive or restore them.
user_id: The authenticated user's ID.

Returns:
ArchiveSessionsResponse: How many sessions changed.
"""
updated = await set_chat_sessions_archived(
list(dict.fromkeys(request.session_ids)), user_id, request.archived
)
return ArchiveSessionsResponse(updated=updated)


@router.patch(
"/sessions/{session_id}/pinned",
summary="Update session pinned",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3388,6 +3388,93 @@ def test_list_sessions_returns_source_platform(
assert response.json()["sessions"][0]["source_platform"] == "discord"


def test_list_sessions_returns_last_message_and_file_count(
mocker: pytest_mock.MockerFixture,
) -> None:
mocker.patch(
"backend.api.features.chat.routes.get_user_sessions",
new_callable=AsyncMock,
return_value=(
[_make_session_info("sess-talked"), _make_session_info("sess-new")],
2,
),
)
mocker.patch(
"backend.api.features.chat.routes.get_last_message_texts",
new_callable=AsyncMock,
return_value={"sess-talked": "\n Drafted the outline. \nSecond line"},
)
mocker.patch(
"backend.api.features.chat.routes.count_generated_files_by_session",
new_callable=AsyncMock,
return_value={"sess-talked": 3},
)
mock_redis = MagicMock()
mock_pipe = MagicMock()
mock_pipe.hget = MagicMock(return_value=None)
mock_pipe.execute = AsyncMock(return_value=[None, None])
mock_redis.pipeline = MagicMock(return_value=mock_pipe)
mocker.patch(
"backend.api.features.chat.routes.get_redis_async",
new_callable=AsyncMock,
return_value=mock_redis,
)

response = client.get("/sessions")

assert response.status_code == 200
sessions = response.json()["sessions"]
assert sessions[0]["last_message"] == "Drafted the outline."
assert sessions[1]["last_message"] is None
assert sessions[0]["file_count"] == 3
assert sessions[1]["file_count"] == 0


def test_archive_sessions_scopes_to_user_and_dedupes(
mocker: pytest_mock.MockerFixture,
test_user_id: str,
) -> None:
archive = mocker.patch(
"backend.api.features.chat.routes.set_chat_sessions_archived",
new_callable=AsyncMock,
return_value=2,
)

response = client.post(
"/sessions/archive",
json={"session_ids": ["sess-a", "sess-b", "sess-a"]},
)

assert response.status_code == 200
assert response.json() == {"updated": 2}
archive.assert_awaited_once_with(["sess-a", "sess-b"], test_user_id, True)


def test_archive_sessions_restores(
mocker: pytest_mock.MockerFixture,
test_user_id: str,
) -> None:
archive = mocker.patch(
"backend.api.features.chat.routes.set_chat_sessions_archived",
new_callable=AsyncMock,
return_value=1,
)

response = client.post(
"/sessions/archive",
json={"session_ids": ["sess-a"], "archived": False},
)

assert response.status_code == 200
archive.assert_awaited_once_with(["sess-a"], test_user_id, False)


def test_archive_sessions_rejects_empty_list() -> None:
response = client.post("/sessions/archive", json={"session_ids": []})

assert response.status_code == 422


def test_list_sessions_returns_is_pinned(
mocker: pytest_mock.MockerFixture,
) -> None:
Expand Down
58 changes: 57 additions & 1 deletion autogpt_platform/backend/backend/copilot/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,7 @@ async def get_user_chat_sessions(
autopilot_only: bool = False,
experts_only: bool = False,
pinned_first: bool = True,
include_archived: bool = False,
) -> list[ChatSessionInfo]:
"""Get chat sessions for a user, ordered by most recent.

Expand All @@ -844,6 +845,9 @@ async def get_user_chat_sessions(

``pinned_first=False`` provides strict recency ordering for internal
adoption flows; the user-facing sidebar keeps pinned sessions first.

Archived sessions are left out unless ``include_archived`` is set, for
callers that read history rather than show the user a list.
"""
if expert_id == "":
raise ValueError("expert_id must be non-empty")
Expand All @@ -854,6 +858,8 @@ async def get_user_chat_sessions(

params: list[Any] = [user_id]
conditions = ['"userId" = $1', _EXCLUDE_DREAM_SESSIONS_SQL]
if not include_archived:
conditions.append('"archivedAt" IS NULL')
if organization_id is not None:
params.append(organization_id)
# Same carve-out as _own_org_scope: the owner's expert sessions are
Expand Down Expand Up @@ -895,6 +901,7 @@ async def get_user_session_count(
organization_id: str | None = None,
expert_id: str | None = None,
autopilot_only: bool = False,
include_archived: bool = False,
) -> int:
"""Get the total number of chat sessions for a user.

Expand All @@ -909,6 +916,8 @@ async def get_user_session_count(

params: list[Any] = [user_id]
conditions = ['"userId" = $1', _EXCLUDE_DREAM_SESSIONS_SQL]
if not include_archived:
conditions.append('"archivedAt" IS NULL')
if organization_id is not None:
params.append(organization_id)
# Keep in lockstep with get_user_chat_sessions so pagination totals
Expand All @@ -930,6 +939,52 @@ async def get_user_session_count(
return rows[0]["count"] if rows else 0


async def set_chat_sessions_archived(
session_ids: list[str], user_id: str, archived: bool
) -> int:
"""Archive or restore the user's sessions among *session_ids*.

Ids the user does not own are skipped. ``updatedAt`` is left alone so a
restored chat keeps its place in recency order. Returns the number of
sessions changed.
"""
if not session_ids:
return 0
return await db.execute_raw_with_schema(
'UPDATE {schema_prefix}"ChatSession" SET "archivedAt" = '
+ ("now()" if archived else "NULL")
+ ' WHERE "id" = ANY($1::text[]) AND "userId" = $2 AND "archivedAt" IS '
+ ("NULL" if archived else "NOT NULL"),
Comment thread
0ubbe marked this conversation as resolved.
Outdated
session_ids,
user_id,
)


async def get_last_message_texts(session_ids: list[str]) -> dict[str, str]:
"""Newest visible user or assistant text in each session, keyed by id.

Rows marked hidden (the expert kickoff prompt) are skipped. Sessions
without any such message are left out, so a missing key means the
conversation has not started yet. Callers pass ids they already scoped
to the user; this does no ownership check of its own.
"""
if not session_ids:
return {}
rows = await db.query_raw_with_schema(
'SELECT s.id AS "sessionId", m.content FROM unnest($1::text[]) AS s(id) '
"CROSS JOIN LATERAL ("
' SELECT content FROM {schema_prefix}"ChatMessage" '
" WHERE \"sessionId\" = s.id AND role IN ('user', 'assistant') "
" AND btrim(coalesce(content, '')) <> '' "
# Same rule as expert_kickoff.is_hidden_chat_message.
" AND NOT coalesce(metadata->'hidden' = 'true'::jsonb, false) "
" ORDER BY sequence DESC LIMIT 1"
") m",
session_ids,
)
return {row["sessionId"]: row["content"] for row in rows}


async def user_has_any_session(user_id: str) -> bool:
"""Whether the user has at least one visible chat session.

Expand Down Expand Up @@ -1486,9 +1541,10 @@ async def admit_chat_session_turn(
return "full"
# Conditional: a transition outside this lock (a cancel) may have
# moved the row since it was read.
# A new turn brings an archived chat back into the user's lists.
updated = await sessions.update_many(
where={"id": session_id, "userId": user_id, "chatStatus": expect_status},
data={"chatStatus": CHAT_STATUS_RUNNING},
data={"chatStatus": CHAT_STATUS_RUNNING, "archivedAt": None},
Comment thread
0ubbe marked this conversation as resolved.
)
return "admitted" if updated else "busy"

Expand Down
4 changes: 2 additions & 2 deletions autogpt_platform/backend/backend/copilot/dream/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,11 +277,11 @@ async def _fetch_recent_sessions(
try:
if expert_id is None:
sessions = await chat_db().get_user_chat_sessions(
user_id, limit=limit, autopilot_only=True
user_id, limit=limit, autopilot_only=True, include_archived=True
)
else:
sessions = await chat_db().get_user_chat_sessions(
user_id, limit=limit, expert_id=expert_id
user_id, limit=limit, expert_id=expert_id, include_archived=True
)
except Exception:
logger.warning(
Expand Down
8 changes: 7 additions & 1 deletion autogpt_platform/backend/backend/copilot/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,7 @@ async def get_or_create_expert_kickoff_session(
organization_id=organization_id,
expert_id=expert_id,
pinned_first=False,
include_archived=True,
)
if sessions:
existing = await get_chat_session(sessions[0].session_id, user_id)
Expand Down Expand Up @@ -1586,6 +1587,7 @@ async def get_user_sessions(
title_contains: str | None = None,
expert_id: str | None = None,
pinned_first: bool = True,
include_archived: bool = False,
) -> tuple[list[ChatSessionInfo], int]:
"""Get chat sessions for a user from the database with total count.

Expand All @@ -1608,9 +1610,13 @@ async def get_user_sessions(
title_contains=title_contains,
expert_id=expert_id,
pinned_first=pinned_first,
include_archived=include_archived,
)
total_count = await db.get_user_session_count(
user_id, organization_id=organization_id, expert_id=expert_id
user_id,
organization_id=organization_id,
expert_id=expert_id,
include_archived=include_archived,
)

return sessions, total_count
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ async def _execute(
expert_id=one_expert,
# Mutually exclusive with expert_id, which already implies it.
experts_only=one_expert is None,
include_archived=True,
)
except Exception as e:
logger.warning(f"list_expert_chats lookup failed: {e}")
Expand Down
Loading
Loading