diff --git a/docker-compose.yml b/docker-compose.yml index 2335b0957..664f6fa76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,8 @@ services: SP_FEATURE_CHAT_RUNTIME_RESULTS: ${SP_FEATURE_CHAT_RUNTIME_RESULTS:-true} SP_FEATURE_CHAT_RUNTIME_ARTIFACTS: ${SP_FEATURE_CHAT_RUNTIME_ARTIFACTS:-true} SP_FEATURE_CHAT_DATASET_REFS: ${SP_FEATURE_CHAT_DATASET_REFS:-false} + SP_FEATURE_CHAT_ORG_SHARING: ${SP_FEATURE_CHAT_ORG_SHARING:-false} + SP_FEATURE_CHAT_FORKING: ${SP_FEATURE_CHAT_FORKING:-false} # Dedicated billing key for automated improvement runs (from root .env). SP_IMPROVEMENT_ANTHROPIC_KEY: ${SP_IMPROVEMENT_ANTHROPIC_KEY:-} SP_CHAT_OBJECTS_BUCKET: ${SP_CHAT_OBJECTS_BUCKET:-sp-chat-runtime} @@ -228,6 +230,8 @@ services: SP_FEATURE_CHAT_RUNTIME_RESULTS: ${SP_FEATURE_CHAT_RUNTIME_RESULTS:-true} SP_FEATURE_CHAT_RUNTIME_ARTIFACTS: ${SP_FEATURE_CHAT_RUNTIME_ARTIFACTS:-true} SP_FEATURE_CHAT_DATASET_REFS: ${SP_FEATURE_CHAT_DATASET_REFS:-false} + SP_FEATURE_CHAT_ORG_SHARING: ${SP_FEATURE_CHAT_ORG_SHARING:-false} + SP_FEATURE_CHAT_FORKING: ${SP_FEATURE_CHAT_FORKING:-false} # Dedicated billing key for automated improvement runs (from root .env). SP_IMPROVEMENT_ANTHROPIC_KEY: ${SP_IMPROVEMENT_ANTHROPIC_KEY:-} SP_CHAT_OBJECTS_BUCKET: ${SP_CHAT_OBJECTS_BUCKET:-sp-chat-runtime} diff --git a/signalpilot/gateway/gateway/api/chat_routes/conversations.py b/signalpilot/gateway/gateway/api/chat_routes/conversations.py index 27a317ee1..e6712d211 100644 --- a/signalpilot/gateway/gateway/api/chat_routes/conversations.py +++ b/signalpilot/gateway/gateway/api/chat_routes/conversations.py @@ -8,9 +8,7 @@ from gateway.git.repos import branch_head_sha from gateway.models.standalone_chat import ( ChatShareGrantInfo, - ForkConfirmation, ForkedConversationInfo, - ForkPreviewInfo, SharedConversationDetail, StandaloneConversationCreate, StandaloneConversationDetail, @@ -334,7 +332,8 @@ async def get_shared_conversation( response_model=ForkedConversationInfo, dependencies=[RequireScope("write")], ) -async def fork_shared_conversation(token: str, body: ForkConfirmation, store: StoreD): +async def fork_shared_conversation(token: str, store: StoreD): + """Copy the whole shared chat into the caller's chats. No body needed.""" _require_enabled() _require_enterprise_feature("forking") try: @@ -343,30 +342,9 @@ async def fork_shared_conversation(token: str, body: ForkConfirmation, store: St org_id=store._require_org_id(), user_id=store.user_id or "local", token=token, - per_query_budget_usd=body.per_query_budget_usd, - chat_budget_usd=body.chat_budget_usd, ) except RuntimeError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc if conversation is None: raise HTTPException(status_code=404, detail="Shared conversation not found") return ForkedConversationInfo(id=conversation.id) - - -@router.get( - "/shared/{token}/fork-preview", - response_model=ForkPreviewInfo, - dependencies=[RequireScope("read")], -) -async def preview_shared_conversation_fork(token: str, store: StoreD): - _require_enabled() - _require_enterprise_feature("forking") - preview = await chat_store.get_fork_preview( - store.session, - org_id=store._require_org_id(), - user_id=store.user_id or "local", - token=token, - ) - if preview is None: - raise HTTPException(status_code=404, detail="Shared conversation not found") - return preview diff --git a/signalpilot/gateway/gateway/api/chat_routes/files.py b/signalpilot/gateway/gateway/api/chat_routes/files.py index 5c03668d6..1055f5c71 100644 --- a/signalpilot/gateway/gateway/api/chat_routes/files.py +++ b/signalpilot/gateway/gateway/api/chat_routes/files.py @@ -15,6 +15,7 @@ from gateway.standalone_chat.object_storage import chat_object_storage from gateway.standalone_chat.sql_trace import list_sql_trace from gateway.store import standalone_chat as chat_store +from gateway.store.standalone_chat.files import file_manifest_entry from ..deps import StoreD from .common import owned_conversation_or_404 as _owned_conversation_or_404 @@ -40,21 +41,9 @@ IfNoneMatchD = Annotated[str | None, Header(alias="If-None-Match")] -def _file_info(row: GatewayChatFile) -> dict: - return { - "id": row.id, - "path": row.path, - "filename": row.filename, - "kind": row.kind, - "mime_type": row.mime_type, - "byte_size": row.byte_size, - "content_hash": row.content_hash, - "origin_run_id": row.origin_run_id, - "origin": row.origin, - "status": row.status, - "created_at": row.created_at, - "updated_at": row.updated_at, - } +# The manifest wire shape lives in the store so the shared snapshot can +# embed it without importing this module. +_file_info = file_manifest_entry def _etag(row: GatewayChatFile) -> str: @@ -219,3 +208,18 @@ async def get_conversation_sql_trace(conversation_id: str, store: StoreD): conversation_id=conversation_id, ) return {"executions": executions} + + +@router.get("/shared/{token}/sql-trace", dependencies=[RequireScope("read")]) +async def get_shared_conversation_sql_trace(token: str, store: StoreD): + """Return the shared chat's governed query executions for finished runs.""" + _require_enabled() + _require_enterprise_feature("organization_sharing") + executions = await chat_store.list_shared_sql_trace( + store.session, + org_id=store._require_org_id(), + token=token, + ) + if executions is None: + raise HTTPException(status_code=404, detail="Shared conversation not found") + return {"executions": executions} diff --git a/signalpilot/gateway/gateway/api/chat_routes/projects.py b/signalpilot/gateway/gateway/api/chat_routes/projects.py index 9617f2548..f497dd411 100644 --- a/signalpilot/gateway/gateway/api/chat_routes/projects.py +++ b/signalpilot/gateway/gateway/api/chat_routes/projects.py @@ -22,6 +22,7 @@ evaluate_project_readiness, resolve_default_project, ) +from gateway.store.standalone_chat.preferences import default_chat_budgets from ..deps import StoreD from .common import is_admin as _is_admin @@ -121,14 +122,9 @@ async def bootstrap_chat(store: StoreD, role: OrgRole): project=selected, readiness=readiness_by_project[selected_id], ) - preference = ( - await store.session.execute( - select(GatewayChatUserPreference).where( - GatewayChatUserPreference.org_id == org_id, - GatewayChatUserPreference.user_id == user_id, - ) - ) - ).scalar_one_or_none() + per_query_budget_usd, chat_budget_usd = await default_chat_budgets( + store.session, org_id=org_id, user_id=user_id + ) return ChatBootstrapResponse( enabled=True, projects=[ @@ -155,8 +151,8 @@ async def bootstrap_chat(store: StoreD, role: OrgRole): selected_project_id=selected_id, is_admin=_is_admin(role), starter_questions=starters, - default_per_query_budget_usd=(preference.default_per_query_budget_usd if preference else 0.25), - default_chat_budget_usd=(preference.default_chat_budget_usd if preference else 1.0), + default_per_query_budget_usd=per_query_budget_usd, + default_chat_budget_usd=chat_budget_usd, available_models=model_options, default_model=selected_model, available_efforts=effort_options, diff --git a/signalpilot/gateway/gateway/api/chat_routes/query_results.py b/signalpilot/gateway/gateway/api/chat_routes/query_results.py index f72aec2de..2e35c7c05 100644 --- a/signalpilot/gateway/gateway/api/chat_routes/query_results.py +++ b/signalpilot/gateway/gateway/api/chat_routes/query_results.py @@ -20,9 +20,10 @@ from gateway.db.models import GatewayStructuredQueryResult from gateway.security.scope_guard import RequireScope from gateway.standalone_chat.query_results import QueryResultUnavailable, load_result_rows +from gateway.store import standalone_chat as chat_store from ..deps import StoreD -from .common import owned_conversation_or_404, require_enabled +from .common import owned_conversation_or_404, require_enabled, require_enterprise_feature router = APIRouter() @@ -37,6 +38,31 @@ def _connection_name(provenance: object) -> str | None: return value if isinstance(value, str) and value else None +def _clamp(offset: int, limit: int) -> tuple[int, int]: + """Clamp instead of rejecting: offset >= 0, 1 <= limit <= MAX_LIMIT.""" + return max(0, offset), min(max(1, limit), MAX_LIMIT) + + +async def _result_page(stored: GatewayStructuredQueryResult, *, offset: int, limit: int) -> dict: + try: + rows = await load_result_rows(stored) + except QueryResultUnavailable as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return { + "result_id": stored.id, + "execution_id": stored.execution_id, + "columns": stored.columns_json, + "rows": rows[offset : offset + limit], + "offset": offset, + "limit": limit, + "saved_row_count": stored.saved_row_count, + "query_row_count": stored.query_row_count, + "completeness": stored.result_completeness, + "truncation_reason": stored.truncation_reason, + "connection_name": _connection_name(stored.provenance_json), + } + + @router.get( "/conversations/{conversation_id}/results/{result_id}", dependencies=[RequireScope("read")], @@ -51,9 +77,7 @@ async def get_conversation_query_result( """Return one page of saved rows for a result the owner produced in this conversation.""" require_enabled() await owned_conversation_or_404(store, conversation_id) - # Clamp instead of rejecting: offset >= 0, 1 <= limit <= MAX_LIMIT. - offset = max(0, offset) - limit = min(max(1, limit), MAX_LIMIT) + offset, limit = _clamp(offset, limit) stored = ( await store.session.execute( select(GatewayStructuredQueryResult).where( @@ -66,20 +90,30 @@ async def get_conversation_query_result( ).scalar_one_or_none() if stored is None: raise HTTPException(status_code=404, detail="Query result not found") - try: - rows = await load_result_rows(stored) - except QueryResultUnavailable as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc - return { - "result_id": stored.id, - "execution_id": stored.execution_id, - "columns": stored.columns_json, - "rows": rows[offset : offset + limit], - "offset": offset, - "limit": limit, - "saved_row_count": stored.saved_row_count, - "query_row_count": stored.query_row_count, - "completeness": stored.result_completeness, - "truncation_reason": stored.truncation_reason, - "connection_name": _connection_name(stored.provenance_json), - } + return await _result_page(stored, offset=offset, limit=limit) + + +@router.get( + "/shared/{token}/results/{result_id}", + dependencies=[RequireScope("read")], +) +async def get_shared_query_result( + token: str, + result_id: str, + store: StoreD, + offset: int = 0, + limit: int = DEFAULT_LIMIT, +): + """Same page shape for a shared chat, scoped to the grant's owner and conversation.""" + require_enabled() + require_enterprise_feature("organization_sharing") + offset, limit = _clamp(offset, limit) + stored = await chat_store.get_shared_query_result( + store.session, + org_id=store._require_org_id(), + token=token, + result_id=result_id, + ) + if stored is None: + raise HTTPException(status_code=404, detail="Query result not found") + return await _result_page(stored, offset=offset, limit=limit) diff --git a/signalpilot/gateway/gateway/models/standalone_chat.py b/signalpilot/gateway/gateway/models/standalone_chat.py index 67f9450d1..9552ccb89 100644 --- a/signalpilot/gateway/gateway/models/standalone_chat.py +++ b/signalpilot/gateway/gateway/models/standalone_chat.py @@ -263,42 +263,33 @@ class ChatShareGrantInfo(BaseModel): class SharedConversationInfo(BaseModel): + """Share-safe conversation header. No owner ids, no budgets, no spend.""" + title: str project_name: str | None = None + origin: str = "user" + model: str + effort: str = "medium" + commit_sha: str | None = None + branch: str created_at: float updated_at: float -class SharedMessageInfo(BaseModel): - id: str - role: Literal["user", "assistant"] - content: str - sequence: int - created_at: float +class SharedConversationDetail(BaseModel): + """Read-only snapshot of a shared chat: finished runs only. + Messages and events carry the same shapes the owner sees so the shared + page renders through the same components. Files are the share-safe + manifest in the same dict shape as the owner file routes. + """ -class SharedConversationDetail(BaseModel): conversation: SharedConversationInfo - messages: list[SharedMessageInfo] + messages: list[StandaloneMessageInfo] + run_events: list[ChatRunEventInfo] = Field(default_factory=list) + files: list[dict[str, Any]] = Field(default_factory=list) shared_at: datetime class ForkedConversationInfo(BaseModel): id: str - - -class ForkPreviewInfo(BaseModel): - project_id: str - project_name: str - commit_sha: str - per_query_budget_usd: float - chat_budget_usd: float - warehouse_cost_notice: str - - -class ForkConfirmation(BaseModel): - model_config = ConfigDict(extra="forbid") - - confirmed: Literal[True] - per_query_budget_usd: float = Field(ge=0) - chat_budget_usd: float = Field(ge=0) diff --git a/signalpilot/gateway/gateway/store/standalone_chat/__init__.py b/signalpilot/gateway/gateway/store/standalone_chat/__init__.py index 30f98a3b2..0c2bd0182 100644 --- a/signalpilot/gateway/gateway/store/standalone_chat/__init__.py +++ b/signalpilot/gateway/gateway/store/standalone_chat/__init__.py @@ -44,7 +44,6 @@ ChatRunInfo, SharedConversationDetail, SharedConversationInfo, - SharedMessageInfo, StandaloneConversationDetail, StandaloneConversationInfo, StandaloneMessageInfo, @@ -87,6 +86,10 @@ mark_conversation_file_deleted, upsert_conversation_file, ) +from gateway.store.standalone_chat.forking import ( + fork_shared_conversation, + remap_ids, +) from gateway.store.standalone_chat.helpers import ( _append_status_message, _event_info, @@ -108,6 +111,7 @@ list_conversation_notebooks, upsert_conversation_notebook, ) +from gateway.store.standalone_chat.preferences import default_chat_budgets from gateway.store.standalone_chat.runs import ( append_event, create_run, @@ -125,11 +129,11 @@ _share_token_hash, _shared_grant_row, create_share_grant, - fork_shared_conversation, - get_fork_preview, get_shared_conversation, get_shared_file, + get_shared_query_result, list_shared_files, + list_shared_sql_trace, revoke_share_grants, ) from gateway.store.standalone_chat.worker import ( @@ -171,7 +175,6 @@ "RunStatus", "SharedConversationDetail", "SharedConversationInfo", - "SharedMessageInfo", "StandaloneConversationDetail", "StandaloneConversationInfo", "StandaloneMessageInfo", @@ -203,6 +206,7 @@ "create_run", "create_share_grant", "datetime", + "default_chat_budgets", "delete", "derive_file_kind", "enterprise_chat_feature_flags", @@ -214,11 +218,11 @@ "get_conversation_detail", "get_conversation_file", "get_conversation_file_by_path", - "get_fork_preview", "get_owned_conversation", "get_shared_conversation", "get_shared_conversation_file", "get_shared_file", + "get_shared_query_result", "get_worker_run", "hashlib", "list_conversation_files", @@ -226,6 +230,7 @@ "list_conversations", "list_shared_conversation_files", "list_shared_files", + "list_shared_sql_trace", "list_run_events", "mark_conversation_file_deleted", "mark_steering_message_picked_up", @@ -238,6 +243,7 @@ "rename_conversation", "update_conversation_effort", "update_conversation_model", + "remap_ids", "renew_lease", "request_cancellation", "retry_run", diff --git a/signalpilot/gateway/gateway/store/standalone_chat/files.py b/signalpilot/gateway/gateway/store/standalone_chat/files.py index 1559359c3..d54e3398c 100644 --- a/signalpilot/gateway/gateway/store/standalone_chat/files.py +++ b/signalpilot/gateway/gateway/store/standalone_chat/files.py @@ -48,6 +48,24 @@ } +def file_manifest_entry(row: GatewayChatFile) -> dict: + """The wire shape of one manifest row. Owner and shared routes share it.""" + return { + "id": row.id, + "path": row.path, + "filename": row.filename, + "kind": row.kind, + "mime_type": row.mime_type, + "byte_size": row.byte_size, + "content_hash": row.content_hash, + "origin_run_id": row.origin_run_id, + "origin": row.origin, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def derive_file_kind(filename: str, mime_type: str | None) -> str: """Classify a file for the artifacts panel from its extension and MIME type.""" extension = PurePosixPath(filename.lower()).suffix @@ -263,8 +281,8 @@ async def conversation_file_usage( def _shared_file_query(*, org_id: str, owner_user_id: str, conversation_id: str): """Select active files whose origin run is terminal or absent. - A file written by a running run is not share-safe yet. A forked copy has - no origin run and is always safe. + A file written by a running run is not share-safe yet. A forked copy + points at a copied, already-terminal run and is always safe. """ return ( select(GatewayChatFile) diff --git a/signalpilot/gateway/gateway/store/standalone_chat/forking.py b/signalpilot/gateway/gateway/store/standalone_chat/forking.py new file mode 100644 index 000000000..ca8614848 --- /dev/null +++ b/signalpilot/gateway/gateway/store/standalone_chat/forking.py @@ -0,0 +1,445 @@ +"""Fork a shared chat: copy the whole conversation to the caller. + +The fork owner gets a faithful private copy: runs, messages, run events, +files, structured query results, and governed query executions. Every row +gets a new id and one id map rewrites every reference inside the copied +JSON blobs, so tool cards and inline artifact cards resolve in the fork. +""" + +from __future__ import annotations + +import time +import uuid +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.db.models import ( + GatewayChatConversation, + GatewayChatFile, + GatewayChatMessage, + GatewayChatRun, + GatewayChatRunEvent, + GatewayGovernedQueryExecution, + GatewayStructuredQueryResult, +) +from gateway.standalone_chat.domain import NONTERMINAL_RUN_STATUSES +from gateway.standalone_chat.object_storage import ( + conversation_file_key, + conversation_prefix, + runtime_object_key, +) +from gateway.store.standalone_chat.preferences import default_chat_budgets +from gateway.store.standalone_chat.sharing import _shared_grant_row + + +def _object_storage(): + """Resolve the storage factory through the package namespace at call time. + + Tests patch chat_object_storage on the package module. Read the name late + so the patch takes effect.""" + from gateway.store import standalone_chat as chat_store + + return chat_store.chat_object_storage() + + +def remap_ids(value: Any, id_map: dict[str, str]) -> Any: + """Return a deep copy of ``value`` with every string equal to an old id replaced. + + Only whole string values are rewritten. Keys, numbers, and substrings + stay untouched. Pure: the input is never mutated. + """ + if isinstance(value, str): + return id_map.get(value, value) + if isinstance(value, dict): + return {key: remap_ids(item, id_map) for key, item in value.items()} + if isinstance(value, list): + return [remap_ids(item, id_map) for item in value] + if isinstance(value, tuple): + return tuple(remap_ids(item, id_map) for item in value) + return value + + +def _result_object_category(object_key: str | None) -> str: + """Category segment of a stored result key, e.g. "results" or "derived-results".""" + parts = (object_key or "").split("/") + if len(parts) >= 2 and "runs" in parts: + try: + return parts[parts.index("runs") + 2] + except IndexError: + pass + return "results" + + +async def _rows(db: AsyncSession, query) -> list: + return list((await db.execute(query)).scalars()) + + +async def _load_source(db: AsyncSession, *, org_id: str, source: GatewayChatConversation) -> dict[str, list]: + return { + "runs": await _rows( + db, + select(GatewayChatRun) + .where(GatewayChatRun.conversation_id == source.id) + .order_by(GatewayChatRun.created_at), + ), + "messages": await _rows( + db, + select(GatewayChatMessage) + .where( + GatewayChatMessage.conversation_id == source.id, + GatewayChatMessage.org_id == org_id, + GatewayChatMessage.user_id == source.user_id, + GatewayChatMessage.role.in_(("user", "assistant")), + ) + .order_by(GatewayChatMessage.sequence), + ), + "events": await _rows( + db, + select(GatewayChatRunEvent) + .where( + GatewayChatRunEvent.conversation_id == source.id, + GatewayChatRunEvent.org_id == org_id, + ) + .order_by(GatewayChatRunEvent.created_at, GatewayChatRunEvent.sequence), + ), + "files": await _rows( + db, + select(GatewayChatFile) + .where( + GatewayChatFile.conversation_id == source.id, + GatewayChatFile.org_id == org_id, + GatewayChatFile.user_id == source.user_id, + GatewayChatFile.status == "active", + ) + .order_by(GatewayChatFile.created_at), + ), + "results": await _rows( + db, + select(GatewayStructuredQueryResult) + .where( + GatewayStructuredQueryResult.conversation_id == source.id, + GatewayStructuredQueryResult.org_id == org_id, + ) + .order_by(GatewayStructuredQueryResult.created_at), + ), + "executions": await _rows( + db, + select(GatewayGovernedQueryExecution) + .where( + GatewayGovernedQueryExecution.conversation_id == source.id, + GatewayGovernedQueryExecution.org_id == org_id, + ) + .order_by(GatewayGovernedQueryExecution.created_at), + ), + } + + +def _copy_run( + row: GatewayChatRun, *, org_id: str, user_id: str, fork_id: str, id_map: dict[str, str] +) -> GatewayChatRun: + return GatewayChatRun( + id=id_map[row.id], + org_id=org_id, + user_id=user_id, + conversation_id=fork_id, + project_id=row.project_id, + user_message_id=id_map.get(row.user_message_id, row.user_message_id), + status=row.status, + runtime_env=None, + retry_of_run_id=id_map.get(row.retry_of_run_id or "", row.retry_of_run_id), + execution_session_id=None, + runtime_archive_id=None, + execution_attempt=0, + lease_owner=None, + lease_expires_at=None, + cancellation_requested_at=row.cancellation_requested_at, + public_error_code=row.public_error_code, + public_error_message=row.public_error_message, + cost_usd=row.cost_usd, + usage_json=row.usage_json, + created_at=row.created_at, + started_at=row.started_at, + terminal_at=row.terminal_at, + last_event_sequence=row.last_event_sequence, + ) + + +def _copy_message( + row: GatewayChatMessage, + *, + org_id: str, + user_id: str, + project_id: str | None, + fork_id: str, + id_map: dict[str, str], +) -> GatewayChatMessage: + metadata = dict(remap_ids(row.metadata_json or {}, id_map)) + metadata.pop("internal", None) + metadata.pop("runtime_archive_available", None) + metadata["forked"] = True + return GatewayChatMessage( + id=id_map[row.id], + org_id=org_id, + user_id=user_id, + project_id=project_id, + conversation_id=fork_id, + role=row.role, + content=row.content, + metadata_json=metadata, + idempotency_key=None, + sequence=row.sequence, + created_at=row.created_at, + ) + + +def _copy_event( + row: GatewayChatRunEvent, *, org_id: str, user_id: str, fork_id: str, id_map: dict[str, str] +) -> GatewayChatRunEvent: + return GatewayChatRunEvent( + id=str(uuid.uuid4()), + org_id=org_id, + user_id=user_id, + conversation_id=fork_id, + run_id=id_map.get(row.run_id, row.run_id), + sequence=row.sequence, + event_type=row.event_type, + payload_json=remap_ids(row.payload_json or {}, id_map), + created_at=row.created_at, + ) + + +def _copy_execution( + row: GatewayGovernedQueryExecution, *, user_id: str, fork_id: str, id_map: dict[str, str] +) -> GatewayGovernedQueryExecution: + return GatewayGovernedQueryExecution( + id=id_map[row.id], + org_id=row.org_id, + user_id=user_id, + conversation_id=fork_id, + run_id=id_map.get(row.run_id or "", row.run_id), + project_id=row.project_id, + commit_sha=row.commit_sha, + connection_name=row.connection_name, + plan_id=row.plan_id, + query_path=row.query_path, + sql_hash=row.sql_hash, + status=row.status, + timeout_seconds=row.timeout_seconds, + warehouse_query_id=row.warehouse_query_id, + estimated_cost_usd=row.estimated_cost_usd, + actual_cost_usd=row.actual_cost_usd, + actual_scan_bytes=row.actual_scan_bytes, + actual_output_bytes=row.actual_output_bytes, + execution_ms=row.execution_ms, + row_count=row.row_count, + completeness=row.completeness, + truncation_reason=row.truncation_reason, + public_error_code=row.public_error_code, + created_at=row.created_at, + started_at=row.started_at, + terminal_at=row.terminal_at, + ) + + +async def _copy_result( + row: GatewayStructuredQueryResult, + *, + storage, + org_id: str, + user_id: str, + fork_id: str, + id_map: dict[str, str], +) -> GatewayStructuredQueryResult: + new_id = id_map[row.id] + object_key = row.object_key + byte_size = row.byte_size + content_hash = row.content_hash + if row.storage_kind == "object" and row.object_key: + object_key = runtime_object_key( + org_id=org_id, + conversation_id=fork_id, + run_id=id_map.get(row.run_id or "", row.run_id) or "fork", + category=_result_object_category(row.object_key), + object_id=new_id, + filename="rows.json", + ) + copied = await storage.copy(source_key=row.object_key, destination_key=object_key) + byte_size = copied.byte_size or row.byte_size + content_hash = copied.content_hash or row.content_hash + return GatewayStructuredQueryResult( + id=new_id, + execution_id=id_map.get(row.execution_id or "", row.execution_id), + org_id=org_id, + owner_user_id=user_id, + conversation_id=fork_id, + run_id=id_map.get(row.run_id or "", row.run_id), + columns_json=row.columns_json, + rows_json=row.rows_json, + preview_rows_json=row.preview_rows_json, + storage_kind=row.storage_kind, + object_key=object_key, + byte_size=byte_size, + content_hash=content_hash, + source_result_ids_json=remap_ids(row.source_result_ids_json or [], id_map), + code_hash=row.code_hash, + result_origin=row.result_origin, + query_row_count=row.query_row_count, + saved_row_count=row.saved_row_count, + source_completeness=row.source_completeness, + result_completeness=row.result_completeness, + display_completeness=row.display_completeness, + truncation_reason=row.truncation_reason, + provenance_json=remap_ids(row.provenance_json or {}, id_map), + freshness_at=row.freshness_at, + created_at=row.created_at, + ) + + +async def _copy_file( + row: GatewayChatFile, *, storage, org_id: str, user_id: str, fork_id: str, id_map: dict[str, str] +) -> GatewayChatFile: + copied_file_id = id_map[row.id] + object_key = conversation_file_key( + org_id=org_id, + conversation_id=fork_id, + file_id=copied_file_id, + filename=row.filename, + ) + copied = await storage.copy(source_key=row.object_key, destination_key=object_key) + return GatewayChatFile( + id=copied_file_id, + org_id=org_id, + user_id=user_id, + conversation_id=fork_id, + path=row.path, + filename=row.filename, + kind=row.kind, + mime_type=row.mime_type, + byte_size=copied.byte_size or row.byte_size, + content_hash=copied.content_hash or row.content_hash, + object_key=object_key, + origin_run_id=id_map.get(row.origin_run_id or "", row.origin_run_id), + origin="fork", + status="active", + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +async def fork_shared_conversation( + db: AsyncSession, + *, + org_id: str, + user_id: str, + token: str, +) -> GatewayChatConversation | None: + """Copy the whole shared chat into a new private conversation for the caller. + + Budgets come from the caller's saved defaults. Raises RuntimeError while + the source has a run in flight. Returns None when the grant is not active + in the caller's organization. + """ + shared = await _shared_grant_row(db, org_id=org_id, token=token, lock=True) + if shared is None: + return None + _, source = shared + active_run = ( + await db.execute( + select(GatewayChatRun.id).where( + GatewayChatRun.conversation_id == source.id, + GatewayChatRun.status.in_(NONTERMINAL_RUN_STATUSES), + ) + ) + ).scalar_one_or_none() + if active_run is not None: + raise RuntimeError("Wait for the current answer to finish before forking this chat") + + per_query_budget_usd, chat_budget_usd = await default_chat_budgets(db, org_id=org_id, user_id=user_id) + rows = await _load_source(db, org_id=org_id, source=source) + now = time.time() + fork = GatewayChatConversation( + id=str(uuid.uuid4()), + org_id=org_id, + user_id=user_id, + project_id=source.project_id, + surface="standalone", + origin=source.origin, + branch=source.branch, + commit_sha=source.commit_sha, + per_query_budget_usd=per_query_budget_usd, + chat_budget_usd=chat_budget_usd, + estimated_spend_usd=0.0, + actual_spend_usd=0.0, + reserved_spend_usd=0.0, + model=source.model, + effort=source.effort, + forked_from_conversation_id=source.id, + status="active", + title=(source.title or "New chat")[:200], + internal_summary=None, + agent_session_id=None, + notebook_session_id=None, + notebook_kernel_session_id=None, + notebook_path=None, + message_count=len(rows["messages"]), + total_tokens=0, + total_cost_usd=0.0, + created_at=now, + updated_at=now, + ) + + # One id map across every copied table. Payloads reference results, + # executions, runs, messages, and files by id; the walker rewrites all. + id_map: dict[str, str] = {source.id: fork.id} + for name in ("runs", "messages", "results", "executions", "files"): + for row in rows[name]: + id_map[row.id] = str(uuid.uuid4()) + + db.add(fork) + for run in rows["runs"]: + db.add(_copy_run(run, org_id=org_id, user_id=user_id, fork_id=fork.id, id_map=id_map)) + for message in rows["messages"]: + db.add( + _copy_message( + message, + org_id=org_id, + user_id=user_id, + project_id=source.project_id, + fork_id=fork.id, + id_map=id_map, + ) + ) + for event in rows["events"]: + db.add(_copy_event(event, org_id=org_id, user_id=user_id, fork_id=fork.id, id_map=id_map)) + for execution in rows["executions"]: + db.add(_copy_execution(execution, user_id=user_id, fork_id=fork.id, id_map=id_map)) + + storage = _object_storage() if rows["files"] or rows["results"] else None + copied_objects = False + try: + for result in rows["results"]: + if result.storage_kind == "object" and result.object_key: + copied_objects = True + db.add( + await _copy_result( + result, storage=storage, org_id=org_id, user_id=user_id, fork_id=fork.id, id_map=id_map + ) + ) + for file in rows["files"]: + copied_objects = True + db.add( + await _copy_file(file, storage=storage, org_id=org_id, user_id=user_id, fork_id=fork.id, id_map=id_map) + ) + await db.commit() + except Exception: + await db.rollback() + if copied_objects and storage is not None: + try: + await storage.delete_prefix(conversation_prefix(org_id, fork.id)) + except Exception: + pass + raise + await db.refresh(fork) + return fork diff --git a/signalpilot/gateway/gateway/store/standalone_chat/preferences.py b/signalpilot/gateway/gateway/store/standalone_chat/preferences.py new file mode 100644 index 000000000..71be141bd --- /dev/null +++ b/signalpilot/gateway/gateway/store/standalone_chat/preferences.py @@ -0,0 +1,44 @@ +"""Per-user chat preference lookups.""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.db.models import GatewayChatUserPreference + +DEFAULT_PER_QUERY_BUDGET_USD = 0.25 +DEFAULT_CHAT_BUDGET_USD = 1.0 + + +async def get_user_chat_preference( + db: AsyncSession, + *, + org_id: str, + user_id: str, +) -> GatewayChatUserPreference | None: + return ( + await db.execute( + select(GatewayChatUserPreference).where( + GatewayChatUserPreference.org_id == org_id, + GatewayChatUserPreference.user_id == user_id, + ) + ) + ).scalar_one_or_none() + + +async def default_chat_budgets( + db: AsyncSession, + *, + org_id: str, + user_id: str, +) -> tuple[float, float]: + """Return (per_query_budget_usd, chat_budget_usd) a new chat starts with. + + The user's saved defaults win; otherwise the product defaults apply. + Bootstrap and forking both read budgets through here. + """ + preference = await get_user_chat_preference(db, org_id=org_id, user_id=user_id) + if preference is None: + return DEFAULT_PER_QUERY_BUDGET_USD, DEFAULT_CHAT_BUDGET_USD + return preference.default_per_query_budget_usd, preference.default_chat_budget_usd diff --git a/signalpilot/gateway/gateway/store/standalone_chat/sharing.py b/signalpilot/gateway/gateway/store/standalone_chat/sharing.py index 0c05fd887..e951ab571 100644 --- a/signalpilot/gateway/gateway/store/standalone_chat/sharing.py +++ b/signalpilot/gateway/gateway/store/standalone_chat/sharing.py @@ -1,12 +1,15 @@ -"""Share grants, shared read views, and conversation forking.""" +"""Share grants and the read-only shared views of one standalone chat. + +Forking lives in ``forking.py``. This module owns the grant lifecycle and +every read that a share token unlocks: the transcript snapshot, files, the +SQL trace, and query result pages. +""" from __future__ import annotations import hashlib import secrets -import time import uuid -from typing import Any from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -16,39 +19,32 @@ GatewayChatFile, GatewayChatMessage, GatewayChatRun, + GatewayChatRunEvent, GatewayChatShareGrant, - GatewayChatUserPreference, + GatewayStructuredQueryResult, GatewayWorkspaceProject, ) from gateway.models.standalone_chat import ( SharedConversationDetail, SharedConversationInfo, - SharedMessageInfo, ) +from gateway.standalone_chat import config as chat_config from gateway.standalone_chat.domain import NONTERMINAL_RUN_STATUSES -from gateway.standalone_chat.object_storage import ( - conversation_file_key, - conversation_prefix, -) +from gateway.standalone_chat.sql_trace import list_sql_trace from gateway.store.standalone_chat.files import ( + file_manifest_entry, get_shared_conversation_file, list_shared_conversation_files, ) from gateway.store.standalone_chat.helpers import ( + _event_info, + _message_info, _now, _owned_conversation_row, + _token_usage, ) -def _object_storage(): - """Resolve the storage factory through the package namespace at call time. - - Tests patch chat_object_storage on the package module. Read the name late - so the patch takes effect.""" - from gateway.store import standalone_chat as chat_store - - return chat_store.chat_object_storage() - def _share_token_hash(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() @@ -159,12 +155,46 @@ async def _shared_grant_row( return (await db.execute(query)).one_or_none() +async def _nonterminal_runs( + db: AsyncSession, + *, + conversation_id: str, +) -> list[GatewayChatRun]: + """Runs still in flight. Their messages, events, and queries stay private.""" + return list( + ( + await db.execute( + select(GatewayChatRun).where( + GatewayChatRun.conversation_id == conversation_id, + GatewayChatRun.status.in_(NONTERMINAL_RUN_STATUSES), + ) + ) + ).scalars() + ) + + +def _belongs_to_run(message: GatewayChatMessage, run_ids: set[str], user_message_ids: set[str]) -> bool: + metadata = message.metadata_json or {} + if message.id in user_message_ids: + return True + for key in ("run_id", "clarification_for_run_id", "steering_for_run_id"): + value = metadata.get(key) + if isinstance(value, str) and value in run_ids: + return True + return False + + async def get_shared_conversation( db: AsyncSession, *, org_id: str, token: str, ) -> SharedConversationDetail | None: + """Full read-only snapshot of the finished part of a shared chat. + + Everything that belongs to an in-flight run is left out: its user + message, its events, and any file it is still writing. + """ shared = await _shared_grant_row(db, org_id=org_id, token=token) if shared is None: return None @@ -177,6 +207,22 @@ async def get_shared_conversation( ) ) ).scalar_one_or_none() + runs = list( + ( + await db.execute( + select(GatewayChatRun) + .where(GatewayChatRun.conversation_id == conversation.id) + .order_by(GatewayChatRun.created_at) + ) + ).scalars() + ) + hidden_run_ids = {run.id for run in runs if run.status in NONTERMINAL_RUN_STATUSES} + hidden_message_ids = {run.user_message_id for run in runs if run.id in hidden_run_ids} + run_usage = { + run.id: usage + for run in runs + if run.id not in hidden_run_ids and (usage := _token_usage(run.usage_json)) is not None + } messages = list( ( await db.execute( @@ -191,23 +237,43 @@ async def get_shared_conversation( ) ).scalars() ) + events = list( + ( + await db.execute( + select(GatewayChatRunEvent) + .where( + GatewayChatRunEvent.conversation_id == conversation.id, + GatewayChatRunEvent.org_id == org_id, + ) + .order_by(GatewayChatRunEvent.created_at, GatewayChatRunEvent.sequence) + ) + ).scalars() + ) + files = await list_shared_conversation_files( + db, + org_id=org_id, + owner_user_id=conversation.user_id, + conversation_id=conversation.id, + ) return SharedConversationDetail( conversation=SharedConversationInfo( title=conversation.title or "New chat", project_name=(project.display_name or project.name) if project else None, + origin=conversation.origin, + model=conversation.model or chat_config.default_chat_model(), + effort=conversation.effort or chat_config.default_chat_effort(), + commit_sha=conversation.commit_sha, + branch=conversation.branch or "main", created_at=conversation.created_at, updated_at=conversation.updated_at, ), messages=[ - SharedMessageInfo( - id=row.id, - role=row.role, - content=row.content, - sequence=row.sequence, - created_at=row.created_at, - ) + _message_info(row, run_usage=run_usage) for row in messages + if not _belongs_to_run(row, hidden_run_ids, hidden_message_ids) ], + run_events=[_event_info(row) for row in events if row.run_id not in hidden_run_ids], + files=[file_manifest_entry(row) for row in files], shared_at=grant.created_at, ) @@ -251,177 +317,53 @@ async def get_shared_file( ) -async def fork_shared_conversation( +async def list_shared_sql_trace( db: AsyncSession, *, org_id: str, - user_id: str, token: str, - per_query_budget_usd: float, - chat_budget_usd: float, -) -> GatewayChatConversation | None: - """Copy the share-safe snapshot into a new private conversation.""" - shared = await _shared_grant_row(db, org_id=org_id, token=token, lock=True) +) -> list[dict] | None: + """The owner's SQL trace for finished runs. None when the grant is not active.""" + shared = await _shared_grant_row(db, org_id=org_id, token=token) if shared is None: return None - _, source = shared - active_run = ( - await db.execute( - select(GatewayChatRun.id).where( - GatewayChatRun.conversation_id == source.id, - GatewayChatRun.status.in_(NONTERMINAL_RUN_STATUSES), - ) - ) - ).scalar_one_or_none() - if active_run is not None: - raise RuntimeError("Wait for the current answer to finish before forking this chat") - - messages = list( - ( - await db.execute( - select(GatewayChatMessage) - .where( - GatewayChatMessage.conversation_id == source.id, - GatewayChatMessage.org_id == org_id, - GatewayChatMessage.user_id == source.user_id, - GatewayChatMessage.role.in_(("user", "assistant")), - ) - .order_by(GatewayChatMessage.sequence) - ) - ).scalars() - ) - now = time.time() - fork = GatewayChatConversation( - id=str(uuid.uuid4()), + _, conversation = shared + hidden_run_ids = {run.id for run in await _nonterminal_runs(db, conversation_id=conversation.id)} + executions = await list_sql_trace( + db, org_id=org_id, - user_id=user_id, - project_id=source.project_id, - surface="standalone", - origin=source.origin, - branch=source.branch, - commit_sha=source.commit_sha, - per_query_budget_usd=per_query_budget_usd, - chat_budget_usd=chat_budget_usd, - model=source.model, - effort=source.effort, - forked_from_conversation_id=source.id, - status="active", - title=(source.title or "New chat")[:200], - internal_summary=None, - message_count=len(messages), - total_tokens=0, - total_cost_usd=0.0, - created_at=now, - updated_at=now, - ) - db.add(fork) - - message_ids = {row.id: str(uuid.uuid4()) for row in messages} - for sequence, row in enumerate(messages, start=1): - db.add( - GatewayChatMessage( - id=message_ids[row.id], - org_id=org_id, - user_id=user_id, - project_id=source.project_id, - conversation_id=fork.id, - role=row.role, - content=row.content, - metadata_json={"surface": "standalone", "forked": True}, - sequence=sequence, - created_at=row.created_at, - ) - ) - - files = list( - ( - await db.execute( - select(GatewayChatFile) - .where( - GatewayChatFile.conversation_id == source.id, - GatewayChatFile.org_id == org_id, - GatewayChatFile.user_id == source.user_id, - GatewayChatFile.status == "active", - ) - .order_by(GatewayChatFile.created_at) - ) - ).scalars() + user_id=conversation.user_id, + conversation_id=conversation.id, ) - - storage = _object_storage() - try: - # Copy each conversation file under the fork prefix. The hash stays - # the same. Only the key and the owner change. - for row in files: - copied_file_id = str(uuid.uuid4()) - object_key = conversation_file_key( - org_id=org_id, - conversation_id=fork.id, - file_id=copied_file_id, - filename=row.filename, - ) - copied = await storage.copy(source_key=row.object_key, destination_key=object_key) - db.add( - GatewayChatFile( - id=copied_file_id, - org_id=org_id, - user_id=user_id, - conversation_id=fork.id, - path=row.path, - filename=row.filename, - kind=row.kind, - mime_type=row.mime_type, - byte_size=copied.byte_size or row.byte_size, - content_hash=copied.content_hash or row.content_hash, - object_key=object_key, - origin_run_id=None, - origin="fork", - status="active", - ) - ) - await db.commit() - except Exception: - await db.rollback() - if files: - try: - await storage.delete_prefix(conversation_prefix(org_id, fork.id)) - except Exception: - pass - raise - await db.refresh(fork) - return fork + return [entry for entry in executions if entry["run_id"] not in hidden_run_ids] -async def get_fork_preview( +async def get_shared_query_result( db: AsyncSession, *, org_id: str, - user_id: str, token: str, -) -> dict[str, Any] | None: + result_id: str, +) -> GatewayStructuredQueryResult | None: + """One stored result of the shared chat, scoped to the owner and conversation.""" shared = await _shared_grant_row(db, org_id=org_id, token=token) if shared is None: return None - _, source = shared - project = await db.get(GatewayWorkspaceProject, source.project_id) - if project is None or project.org_id != org_id or not source.commit_sha: - return None - preference = ( + _, conversation = shared + stored = ( await db.execute( - select(GatewayChatUserPreference).where( - GatewayChatUserPreference.org_id == org_id, - GatewayChatUserPreference.user_id == user_id, + select(GatewayStructuredQueryResult).where( + GatewayStructuredQueryResult.id == result_id, + GatewayStructuredQueryResult.org_id == org_id, + GatewayStructuredQueryResult.conversation_id == conversation.id, + GatewayStructuredQueryResult.owner_user_id == conversation.user_id, ) ) ).scalar_one_or_none() - return { - "project_id": project.id, - "project_name": project.display_name or project.name, - "commit_sha": source.commit_sha, - "per_query_budget_usd": preference.default_per_query_budget_usd if preference else 0.25, - "chat_budget_usd": preference.default_chat_budget_usd if preference else 1.0, - "warehouse_cost_notice": ( - "New questions run against live warehouse data and may incur warehouse cost. " - "The dbt project remains frozen at the displayed commit." - ), - } + if stored is None: + return None + if stored.run_id and any( + run.id == stored.run_id for run in await _nonterminal_runs(db, conversation_id=conversation.id) + ): + return None + return stored diff --git a/signalpilot/gateway/tests/test_chat_files.py b/signalpilot/gateway/tests/test_chat_files.py index 066c76086..eb0150b45 100644 --- a/signalpilot/gateway/tests/test_chat_files.py +++ b/signalpilot/gateway/tests/test_chat_files.py @@ -446,6 +446,22 @@ async def test_sql_trace_route_wraps_the_projection(db_session, enabled): assert result == {"executions": []} +@pytest.mark.asyncio +async def test_shared_sql_trace_route_follows_the_grant(db_session, enabled, monkeypatch): + conversation = await _conversation(db_session) + monkeypatch.setenv("SP_FEATURE_CHAT_ORG_SHARING", "1") + with pytest.raises(HTTPException) as exc: + await files_routes.get_shared_conversation_sql_trace("x" * 40, _store(db_session, "user-b")) + assert exc.value.status_code == 404 + shared = await chat_store.create_share_grant( + db_session, org_id=ORG, user_id=USER, conversation_id=conversation.id + ) + assert shared is not None + _, token = shared + result = await files_routes.get_shared_conversation_sql_trace(token, _store(db_session, "user-b")) + assert result == {"executions": []} + + # ── Adversarial: sanitization bypass and header safety ─────────────────────── diff --git a/signalpilot/gateway/tests/test_chat_query_results.py b/signalpilot/gateway/tests/test_chat_query_results.py index 491d42b5e..c0a4ef833 100644 --- a/signalpilot/gateway/tests/test_chat_query_results.py +++ b/signalpilot/gateway/tests/test_chat_query_results.py @@ -336,3 +336,59 @@ async def test_structured_results_flag_off_does_not_block(db_session, enabled, m await _result(db_session, conversation, run, _rows(3)) page = await _get(db_session, conversation.id, "res-1") assert len(page["rows"]) == 3 + + +# ── Route: shared (read-only) access ──────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_shared_route_pages_the_owners_result_for_a_teammate(db_session, enabled, monkeypatch): + monkeypatch.setenv("SP_FEATURE_CHAT_ORG_SHARING", "1") + conversation, run = await _conversation(db_session) + run.status = "completed" + await db_session.commit() + await _result(db_session, conversation, run, _rows(7)) + shared = await chat_store.create_share_grant( + db_session, org_id=ORG, user_id=USER, conversation_id=conversation.id + ) + assert shared is not None + _, token = shared + page = await results_routes.get_shared_query_result( + token, "res-1", _store(db_session, user_id="user-b"), offset=2, limit=3 + ) + assert page["result_id"] == "res-1" + assert [row["id"] for row in page["rows"]] == [2, 3, 4] + assert page["saved_row_count"] == 7 + assert page["connection_name"] == "production" + + with pytest.raises(HTTPException) as exc: + await results_routes.get_shared_query_result( + token, "missing", _store(db_session, user_id="user-b") + ) + assert exc.value.status_code == 404 + with pytest.raises(HTTPException) as exc: + await results_routes.get_shared_query_result( + "x" * 40, "res-1", _store(db_session, user_id="user-b") + ) + assert exc.value.status_code == 404 + foreign = SimpleNamespace(session=db_session, user_id="user-b", _require_org_id=lambda: "org-b") + with pytest.raises(HTTPException) as exc: + await results_routes.get_shared_query_result(token, "res-1", foreign) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_shared_route_hides_results_of_a_running_run(db_session, enabled, monkeypatch): + monkeypatch.setenv("SP_FEATURE_CHAT_ORG_SHARING", "1") + conversation, run = await _conversation(db_session) + await _result(db_session, conversation, run, _rows(2)) + shared = await chat_store.create_share_grant( + db_session, org_id=ORG, user_id=USER, conversation_id=conversation.id + ) + assert shared is not None + _, token = shared + with pytest.raises(HTTPException) as exc: + await results_routes.get_shared_query_result( + token, "res-1", _store(db_session, user_id="user-b") + ) + assert exc.value.status_code == 404 diff --git a/signalpilot/gateway/tests/test_standalone_chat.py b/signalpilot/gateway/tests/test_standalone_chat.py index 91125379b..b959d9d98 100644 --- a/signalpilot/gateway/tests/test_standalone_chat.py +++ b/signalpilot/gateway/tests/test_standalone_chat.py @@ -26,6 +26,7 @@ GatewayConnection, GatewayCredential, GatewayDbtManifest, + GatewayGovernedQueryExecution, GatewayStructuredQueryResult, GatewayWorkspaceProject, ) @@ -193,6 +194,57 @@ async def _completed_shared_conversation( caveats=["Partial current day"], ) ) + db.add( + GatewayGovernedQueryExecution( + id="execution-a", + org_id="org-a", + user_id="user-a", + conversation_id=conversation_id, + run_id=run.id, + project_id="project-a", + connection_name="production", + query_path="sdk", + sql_hash="h" * 64, + status="completed", + timeout_seconds=30, + estimated_cost_usd=0.01, + row_count=1, + ) + ) + db.add( + GatewayStructuredQueryResult( + id="result-a", + execution_id="execution-a", + org_id="org-a", + owner_user_id="user-a", + conversation_id=conversation_id, + run_id=run.id, + columns_json=[{"name": "revenue"}], + rows_json=[[112]], + preview_rows_json=[[112]], + saved_row_count=1, + source_completeness="complete", + result_completeness="complete", + display_completeness="complete", + provenance_json={"connection_name": "production"}, + ) + ) + db.add( + GatewayChatRunEvent( + id="event-a", + org_id="org-a", + user_id="user-a", + conversation_id=conversation_id, + run_id=run.id, + sequence=1, + event_type="tool_completed", + payload_json={ + "tool": "run_query", + "result": {"result_id": "result-a", "execution_id": "execution-a"}, + }, + ) + ) + run.last_event_sequence = 1 conversation.title = "Revenue trend" conversation.internal_summary = "Hidden execution summary" conversation.message_count = 2 @@ -295,7 +347,7 @@ async def test_same_org_peer_cannot_discover_private_conversation(db_session): @pytest.mark.asyncio async def test_share_grant_is_hashed_same_org_and_trace_free(db_session): - conversation_id, _, _ = await _completed_shared_conversation(db_session) + conversation_id, run, _ = await _completed_shared_conversation(db_session) result = await chat_store.create_share_grant( db_session, org_id="org-a", @@ -315,7 +367,15 @@ async def test_share_grant_is_hashed_same_org_and_trace_free(db_session): assert shared is not None assert shared.conversation.title == "Revenue trend" assert [message.role for message in shared.messages] == ["user", "assistant"] - assert not hasattr(shared.messages[1], "metadata") + assert "internal" not in shared.messages[1].metadata + assert shared.messages[1].metadata["run_id"] == run.id + assert [event.type for event in shared.run_events] == ["tool_completed"] + assert shared.run_events[0].run_id == run.id + assert shared.files == [] + assert shared.conversation.branch == "main" + assert shared.conversation.commit_sha == "a" * 40 + assert not hasattr(shared.conversation, "per_query_budget_usd") + assert not hasattr(shared.conversation, "id") assert ( await chat_store.get_shared_conversation( db_session, @@ -402,11 +462,19 @@ async def test_rotating_revoking_and_archiving_share_returns_not_found(db_sessio @pytest.mark.asyncio -async def test_same_org_viewer_can_fork_share_safe_snapshot(db_session): - conversation_id, _, assistant_message_id = await _completed_shared_conversation(db_session) +async def test_same_org_viewer_can_fork_the_whole_chat(db_session): + conversation_id, run, assistant_message_id = await _completed_shared_conversation(db_session) source = await db_session.get(GatewayChatConversation, conversation_id) assert source is not None source.model = "claude-fable-5-1" + db_session.add( + GatewayChatUserPreference( + org_id="org-a", + user_id="user-b", + default_per_query_budget_usd=0.4, + default_chat_budget_usd=1.5, + ) + ) await db_session.commit() shared = await chat_store.create_share_grant( db_session, @@ -422,17 +490,18 @@ async def test_same_org_viewer_can_fork_share_safe_snapshot(db_session): org_id="org-a", user_id="user-b", token=token, - per_query_budget_usd=0.5, - chat_budget_usd=2.0, ) assert fork is not None assert fork.id != conversation_id assert fork.user_id == "user-b" assert fork.internal_summary is None + assert fork.agent_session_id is None assert fork.commit_sha == "a" * 40 assert fork.forked_from_conversation_id == conversation_id - assert fork.per_query_budget_usd == 0.5 - assert fork.chat_budget_usd == 2.0 + # Budgets come from the caller's saved defaults, not the source chat. + assert fork.per_query_budget_usd == 0.4 + assert fork.chat_budget_usd == 1.5 + assert fork.actual_spend_usd == 0.0 assert fork.model == "claude-fable-5-1" detail = await chat_store.get_conversation_detail( @@ -447,7 +516,41 @@ async def test_same_org_viewer_can_fork_share_safe_snapshot(db_session): "Revenue increased by 12%.", ] assert detail.messages[1].id != assistant_message_id - assert detail.current_run is None + assert detail.messages[1].metadata["forked"] is True + assert "internal" not in detail.messages[1].metadata + new_run_id = detail.messages[1].metadata["run_id"] + assert new_run_id != run.id + # The copied run is terminal, so the fork is idle and continuable. + assert detail.current_run is not None + assert detail.current_run.id == new_run_id + assert detail.current_run.status == "completed" + assert detail.current_run.runtime_archive_available is False + # The timeline copied with every id reference rewritten. + assert [event.type for event in detail.run_events] == ["tool_completed"] + assert detail.run_events[0].run_id == new_run_id + new_result_id = detail.run_events[0].payload["result"]["result_id"] + new_execution_id = detail.run_events[0].payload["result"]["execution_id"] + assert new_result_id != "result-a" + assert new_execution_id != "execution-a" + copied_result = await db_session.get(GatewayStructuredQueryResult, new_result_id) + assert copied_result is not None + assert copied_result.owner_user_id == "user-b" + assert copied_result.conversation_id == fork.id + assert copied_result.run_id == new_run_id + assert copied_result.execution_id == new_execution_id + assert copied_result.rows_json == [[112]] + copied_execution = await db_session.get(GatewayGovernedQueryExecution, new_execution_id) + assert copied_execution is not None + assert copied_execution.user_id == "user-b" + assert copied_execution.conversation_id == fork.id + assert copied_execution.run_id == new_run_id + copied_run = await db_session.get(GatewayChatRun, new_run_id) + assert copied_run is not None + assert copied_run.user_message_id == detail.messages[0].id + assert copied_run.lease_owner is None + assert copied_run.execution_session_id is None + # The source rows are untouched. + assert (await db_session.get(GatewayStructuredQueryResult, "result-a")).conversation_id == conversation_id reshared = await chat_store.create_share_grant( db_session, @@ -467,41 +570,45 @@ async def test_same_org_viewer_can_fork_share_safe_snapshot(db_session): "What changed in revenue?", "Revenue increased by 12%.", ] + assert [event.run_id for event in reshared_detail.run_events] == [new_run_id] @pytest.mark.asyncio -async def test_fork_preview_preserves_project_commit_and_recipient_budgets(db_session): +async def test_fork_budgets_fall_back_to_product_defaults(db_session): conversation_id, _, _ = await _completed_shared_conversation(db_session) shared = await chat_store.create_share_grant( - db_session, - org_id="org-a", - user_id="user-a", - conversation_id=conversation_id, + db_session, org_id="org-a", user_id="user-a", conversation_id=conversation_id ) assert shared is not None _, token = shared - db_session.add( - GatewayChatUserPreference( - org_id="org-a", - user_id="user-b", - default_per_query_budget_usd=0.4, - default_chat_budget_usd=1.5, - ) + fork = await chat_store.fork_shared_conversation( + db_session, org_id="org-a", user_id="user-b", token=token ) - await db_session.commit() + assert fork is not None + assert fork.per_query_budget_usd == 0.25 + assert fork.chat_budget_usd == 1.0 - preview = await chat_store.get_fork_preview( - db_session, - org_id="org-a", - user_id="user-b", - token=token, - ) - assert preview is not None - assert preview["project_id"] == "project-a" - assert preview["commit_sha"] == "a" * 40 - assert preview["per_query_budget_usd"] == 0.4 - assert preview["chat_budget_usd"] == 1.5 - assert "live warehouse data" in preview["warehouse_cost_notice"] + +def test_remap_ids_rewrites_whole_string_values_only(): + id_map = {"old-run": "new-run", "old-result": "new-result"} + payload = { + "run_id": "old-run", + "nested": {"ids": ["old-result", "keep", 3, None], "text": "old-run/extra"}, + "old-run": "value", + "tuple": ("old-run",), + } + remapped = chat_store.remap_ids(payload, id_map) + assert remapped == { + "run_id": "new-run", + "nested": {"ids": ["new-result", "keep", 3, None], "text": "old-run/extra"}, + "old-run": "value", + "tuple": ("new-run",), + } + # Pure: the input is untouched and the output is a fresh structure. + assert payload["run_id"] == "old-run" + assert remapped["nested"] is not payload["nested"] + assert chat_store.remap_ids("old-run", id_map) == "new-run" + assert chat_store.remap_ids(42, id_map) == 42 @pytest.mark.asyncio @@ -521,15 +628,15 @@ async def test_share_fork_rejects_active_run_and_cross_org(db_session): token=token, ) assert shared_detail is not None - assert [message.role for message in shared_detail.messages] == ["user"] + # The queued run and its user message stay private until it finishes. + assert shared_detail.messages == [] + assert shared_detail.run_events == [] with pytest.raises(RuntimeError, match="finish before forking"): await chat_store.fork_shared_conversation( db_session, org_id="org-a", user_id="user-b", token=token, - per_query_budget_usd=0.25, - chat_budget_usd=1.0, ) assert ( await chat_store.fork_shared_conversation( @@ -537,8 +644,6 @@ async def test_share_fork_rejects_active_run_and_cross_org(db_session): org_id="org-b", user_id="user-b", token=token, - per_query_budget_usd=0.25, - chat_budget_usd=1.0, ) is None ) @@ -1876,10 +1981,12 @@ async def _copy(*, source_key: str, destination_key: str) -> StoredObject: org_id="org-a", user_id="user-b", token=token, - per_query_budget_usd=0.5, - chat_budget_usd=2.0, ) assert fork is not None + detail = await chat_store.get_conversation_detail( + db_session, org_id="org-a", user_id="user-b", conversation_id=fork.id + ) + assert detail is not None and detail.current_run is not None copied_rows = await chat_store.list_conversation_files( db_session, org_id="org-a", user_id="user-b", conversation_id=fork.id @@ -1892,7 +1999,10 @@ async def _copy(*, source_key: str, destination_key: str) -> StoredObject: assert copied.kind == "image" assert copied.mime_type == "image/png" assert copied.origin == "fork" - assert copied.origin_run_id is None + # Inline artifact cards key on the run that wrote the file: the copy + # points at the copied run, never at the source run. + assert copied.origin_run_id == detail.current_run.id + assert copied.origin_run_id != run.id assert copied.object_key != active.object_key assert copied.object_key.startswith(f"{conversation_prefix('org-a', fork.id)}/files/") assert copies == [(active.object_key, copied.object_key)] diff --git a/signalpilot/web/components/chat/assistant-message.tsx b/signalpilot/web/components/chat/assistant-message.tsx new file mode 100644 index 000000000..da5a52994 --- /dev/null +++ b/signalpilot/web/components/chat/assistant-message.tsx @@ -0,0 +1,275 @@ +"use client"; + +// The assistant turn of the standalone data chat transcript: activity +// blocks, inline artifact cards, the answer and the action row. Rendered +// live by chat-message.tsx and again, on the compressed replay clock, by +// chat-replay-view.tsx. + +import { + AlertCircle, + ChevronRight, + CircleStop, + Copy, + Loader2, + Play, + Sparkles, + Wrench, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { ChatMarkdown } from "~/components/chat/chat-markdown"; +import { + openStandaloneNotebookArchive, + type StandaloneChatRunStatus, +} from "~/lib/api"; +import { + RunActivityBlocks, + RunTimeline, + StepArtifactCardsContext, + collectStepSequences, +} from "~/components/chat/run-timeline"; +import { MessageRunContext } from "~/components/chat/message-run-context"; +import { ConnectorSignInCards } from "~/components/chat/connector-signin-card"; +import { RuntimeBootCard } from "~/components/chat/runtime-boot-card"; +import { + deriveLiveStateFromBlocks, + extractRuntimeBoot, + foldRunBlocks, + foldRunSteps, + shouldShowRuntimeBoot, +} from "~/lib/chat-run-steps"; +import { LivePill } from "~/components/chat/live-pill"; +import { useToast } from "~/components/ui/toast"; +import { useChatUi, type UiMessage } from "~/components/chat/chat-ui-context"; +import { + DashboardPreviewCard, + messageDashboardPreview, +} from "~/components/chat/chat-dashboard-preview-card"; +import { + deriveArtifactCards, + groupCardsByAnchor, +} from "~/lib/chat-artifact-cards"; +import { MessageTiming } from "~/components/chat/chat-message-timing"; + +function WorkTimeline({ runId }: { runId: string }) { + const { events } = useChatUi(); + const steps = useMemo(() => foldRunSteps(events, runId), [events, runId]); + return ; +} + +const ACTION_BUTTON = + "inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[11px] text-[var(--color-text-dim)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"; + +export function AssistantMessage({ + message, + previousMessageAt, + replayMode = false, +}: { + message: UiMessage; + previousMessageAt?: number; + /** Rendered by the conversation replay: no action row. */ + replayMode?: boolean; +}) { + const runId = + message.runId ?? + (typeof message.metadata.run_id === "string" + ? message.metadata.run_id + : ""); + const runStatus = + message.runStatus ?? + (typeof message.metadata.status === "string" + ? (message.metadata.status as StandaloneChatRunStatus) + : "completed"); + const [showWork, setShowWork] = useState(false); + const ui = useChatUi(); + const { events, files, onRetry, onStop } = ui; + // A read-only surface (the shared page) keeps Copy and Replay but has no + // run to stop or retry. Read defensively: the flag is optional. + const readOnly = ui.readOnly === true; + // Replay paused or scrubbed: text renders complete, with no caret. + const textInstant = ui.textInstant === true; + const { toast } = useToast(); + const blocks = useMemo( + () => (runId ? foldRunBlocks(events, runId) : []), + [events, runId], + ); + // Present only on cold sandbox starts — warm follow-ups emit no boot events. + const runtimeBoot = useMemo( + () => (runId ? extractRuntimeBoot(events, runId) : null), + [events, runId], + ); + const steps = useMemo( + () => + blocks.flatMap((block) => (block.kind === "steps" ? block.steps : [])), + [blocks], + ); + const blocksHaveText = blocks.some((block) => block.kind === "text"); + const runError = steps.find((step) => step.category === "error")?.detail; + const messageRepeatsRunError = + runStatus === "failed" && + Boolean(runError) && + message.content.trim() === runError?.trim(); + const successful = runStatus === "completed"; + const running = runStatus === "queued" || runStatus === "running"; + // What the agent is doing right now: drives the caret, the inline + // indicator and the footer pill. Idle whenever the run is not active. + const live = useMemo( + () => deriveLiveStateFromBlocks(blocks, runtimeBoot, runStatus), + [blocks, runtimeBoot, runStatus], + ); + // Artifact cards: every captured file gets one, placed in the timeline + // right after the step that produced it (joined on the tool_started + // sequence). Files no step claims trail the timeline. Derived from the + // persisted events and manifest, so rehydration on refresh is free. + const fileCards = useMemo( + () => (runId ? deriveArtifactCards(events, files, runId, running) : []), + [events, files, runId, running], + ); + const anchoredCards = useMemo( + () => groupCardsByAnchor(fileCards, collectStepSequences(steps)), + [fileCards, steps], + ); + const messageRun = useMemo( + () => ({ runId: runId || null, running }), + [runId, running], + ); + const runtimeArchiveAvailable = + message.metadata.runtime_archive_available === true; + const dashboardPreview = successful + ? messageDashboardPreview(message.metadata) + : null; + return ( +
+
+
+ {running ? ( + + ) : runStatus === "failed" ? ( + + ) : ( + + )} +
+ + +
+ {shouldShowRuntimeBoot(runtimeBoot, running) && runtimeBoot && ( + + )} + {(running || + blocks.length > 0 || + anchoredCards.trailing.length > 0) && ( +
+ +
+ )} + {runId && } + {!blocksHaveText && message.content && !messageRepeatsRunError && ( + + )} + {dashboardPreview && ( + + )} + {runStatus === "cancelled" && ( +

+ This run was stopped. Completed work remains available below. +

+ )} + {!replayMode && ( +
+ {successful && ( + + )} + {runId && (runtimeArchiveAvailable || steps.length === 0) && ( + + )} + {running && } + + {running && runId && !readOnly && ( + + + + + )} + {runStatus === "failed" && runId && !readOnly && ( + + )} +
+ )} + {showWork && runId && !runtimeArchiveAvailable && ( +
+ +
+ )} +
+
+
+
+
+ ); +} diff --git a/signalpilot/web/components/chat/chat-artifact-card.tsx b/signalpilot/web/components/chat/chat-artifact-card.tsx index 3e9fd62aa..82254b845 100644 --- a/signalpilot/web/components/chat/chat-artifact-card.tsx +++ b/signalpilot/web/components/chat/chat-artifact-card.tsx @@ -9,7 +9,6 @@ import { AlertCircle, ArrowDownToLine, ChevronDown, ChevronRight } from "lucide-react"; import { memo, useEffect, useRef, useState } from "react"; -import { downloadConversationFile } from "~/lib/api"; import { cardKindLabel, middleTruncate, @@ -20,6 +19,7 @@ import { import { formatByteSize } from "~/lib/chat-artifacts"; import { kindIcon } from "~/components/chat/artifacts-panel"; import { useChatUi } from "~/components/chat/chat-ui-context"; +import { downloadUiFile } from "~/components/chat/download-ui-file"; import { useFileObjectUrl } from "~/components/chat/use-file-object-url"; import { useToast } from "~/components/ui/toast"; @@ -56,14 +56,13 @@ type CardActionsProps = { function useDownload(conversationId: string | null) { const { toast } = useToast(); + // The shared page has no owner conversation id; its context injects a + // token-scoped `downloadFile` override instead. + const { downloadFile } = useChatUi(); return async (card: ArtifactCardModel) => { - if (!conversationId || !card.file) return; + if (!card.file) return; try { - await downloadConversationFile( - conversationId, - card.file.id, - card.filename, - ); + await downloadUiFile({ conversationId, downloadFile }, card.file); } catch { toast("This file is no longer available.", "error"); } @@ -83,7 +82,7 @@ function ImageThumb({ card, onOpen, }: { - conversationId: string; + conversationId: string | null; card: ArtifactCardModel; onOpen: (fileId: string) => void; }) { @@ -125,7 +124,7 @@ function ImagePreviewToggle({ card, onOpen, }: { - conversationId: string; + conversationId: string | null; card: ArtifactCardModel; onOpen: (fileId: string) => void; }) { @@ -209,6 +208,7 @@ const FullCard = memo(function FullCard({ onOpen, }: CardActionsProps) { const download = useDownload(conversationId); + const { getFileObjectUrl } = useChatUi(); const flash = useUpdateFlash(card.file?.content_hash); const now = useCardNow(); if (card.state === "unfinished") return ; @@ -273,7 +273,7 @@ const FullCard = memo(function FullCard({ - {card.kind === "image" && conversationId && ( + {card.kind === "image" && (conversationId || getFileObjectUrl) && ( { let cancelled = false; - getConversationFileText(conversationId, file.id) + (override + ? override(file.id) + : getConversationFileText(conversationId, file.id) + ) .then((text) => { if (!cancelled) setLoaded({ key, state: { phase: "text", text } }); }) @@ -65,7 +72,7 @@ function useFileText( return () => { cancelled = true; }; - }, [conversationId, file.id, key]); + }, [conversationId, file.id, key, override]); return loaded?.key === key ? loaded.state : { phase: "loading" }; } @@ -213,10 +220,14 @@ export function ChatFileViewer({ conversationId: string; file: ConversationFileInfo; }) { + // Downloads go through the context override when one is set (the shared + // page); owner pages use the conversation route. + const ui = useContext(ChatUiContext); const download = () => { - void downloadConversationFile(conversationId, file.id, file.filename).catch( - () => undefined, - ); + void downloadUiFile( + { conversationId, downloadFile: ui?.downloadFile }, + file, + ).catch(() => undefined); }; return (
+ + {formatTelemetryClock(recordedAt)} + {duration != null ? ` · ${formatTelemetryDuration(duration)}` : ""} + + · + + ~{formatTokenCount(estimatedTextTokens)} text + {usage ? ` · ${formatTokenCount(runTokens)} run tokens` : " tokens"} + + + ); +} diff --git a/signalpilot/web/components/chat/chat-message.tsx b/signalpilot/web/components/chat/chat-message.tsx index d27c38f89..294068f9b 100644 --- a/signalpilot/web/components/chat/chat-message.tsx +++ b/signalpilot/web/components/chat/chat-message.tsx @@ -1,347 +1,15 @@ "use client"; -// Message components for the standalone data chat transcript. +// Message components for the standalone data chat transcript. The +// assistant turn lives in assistant-message.tsx; conversation replay in +// chat-replay-view.tsx. -import { - AlertCircle, - ChevronRight, - CircleStop, - Copy, - Loader2, - Play, - Sparkles, - Wrench, -} from "lucide-react"; -import { useMemo, useState } from "react"; -import { ChatMarkdown } from "~/components/chat/chat-markdown"; -import { - openStandaloneNotebookArchive, - type StandaloneChatRunStatus, -} from "~/lib/api"; -import { - RunActivityBlocks, - RunTimeline, - StepArtifactCardsContext, - collectStepSequences, -} from "~/components/chat/run-timeline"; -import { MessageRunContext } from "~/components/chat/message-run-context"; -import { ConnectorSignInCards } from "~/components/chat/connector-signin-card"; -import { RuntimeBootCard } from "~/components/chat/runtime-boot-card"; -import { ReplayControls } from "~/components/chat/replay-controls"; -import { - deriveLiveStateFromBlocks, - extractRuntimeBoot, - foldRunBlocks, - foldRunSteps, - shouldShowRuntimeBoot, -} from "~/lib/chat-run-steps"; -import { LivePill } from "~/components/chat/live-pill"; -import { useChatReplay } from "~/lib/chat-replay"; -import { useToast } from "~/components/ui/toast"; -import { - ChatUiContext, - useChatUi, - type UiMessage, -} from "~/components/chat/chat-ui-context"; -import { - DashboardPreviewCard, - messageDashboardPreview, -} from "~/components/chat/chat-dashboard-preview-card"; -import { - deriveArtifactCards, - groupCardsByAnchor, -} from "~/lib/chat-artifact-cards"; -import { - formatTelemetryClock, - formatTelemetryDuration, - formatTokenCount, - estimateMessageTokens, - parseChatTokenUsage, - totalChatTokens, -} from "~/lib/chat-telemetry"; -import { useChatTelemetryContext } from "~/components/chat/chat-telemetry-context"; +import { Loader2 } from "lucide-react"; +import type { UiMessage } from "~/components/chat/chat-ui-context"; +import { AssistantMessage } from "~/components/chat/assistant-message"; +import { MessageTiming } from "~/components/chat/chat-message-timing"; -function MessageTiming({ - message, - previousMessageAt, - running, -}: { - message: UiMessage; - previousMessageAt?: number; - running?: boolean; -}) { - const telemetry = useChatTelemetryContext(); - if (!telemetry.enabled) return null; - const recordedAt = message.created_at * 1_000; - const endAt = running ? telemetry.nowMs : recordedAt; - const duration = - message.role === "assistant" && previousMessageAt != null - ? Math.max(0, endAt - previousMessageAt * 1_000) - : null; - const exact = new Date(recordedAt).toLocaleString(); - const estimatedTextTokens = estimateMessageTokens(message.content); - const usage = parseChatTokenUsage(message.metadata.token_usage); - const runTokens = totalChatTokens(usage); - const usageTitle = usage - ? [ - `Exact SDK run usage: ${runTokens.toLocaleString("en-US")} tokens`, - `${(usage.input_tokens ?? 0).toLocaleString("en-US")} input`, - `${(usage.output_tokens ?? 0).toLocaleString("en-US")} output`, - `${(usage.cache_creation_input_tokens ?? 0).toLocaleString("en-US")} cache write`, - `${(usage.cache_read_input_tokens ?? 0).toLocaleString("en-US")} cache read`, - ].join(" · ") - : "Estimated visible-text tokens; exact usage is only available at run completion"; - return ( - - - {formatTelemetryClock(recordedAt)} - {duration != null ? ` · ${formatTelemetryDuration(duration)}` : ""} - - · - - ~{formatTokenCount(estimatedTextTokens)} text - {usage ? ` · ${formatTokenCount(runTokens)} run tokens` : " tokens"} - - - ); -} - -function WorkTimeline({ runId }: { runId: string }) { - const { events } = useChatUi(); - const steps = useMemo(() => foldRunSteps(events, runId), [events, runId]); - return ; -} - -function AssistantMessage({ - message, - previousMessageAt, - onReplay, - replayMode = false, -}: { - message: UiMessage; - previousMessageAt?: number; - onReplay?: () => void; - replayMode?: boolean; -}) { - const runId = - message.runId ?? - (typeof message.metadata.run_id === "string" - ? message.metadata.run_id - : ""); - const runStatus = - message.runStatus ?? - (typeof message.metadata.status === "string" - ? (message.metadata.status as StandaloneChatRunStatus) - : "completed"); - const [showWork, setShowWork] = useState(false); - const { events, files, onRetry, onStop } = useChatUi(); - const { toast } = useToast(); - const blocks = useMemo( - () => (runId ? foldRunBlocks(events, runId) : []), - [events, runId], - ); - // Present only on cold sandbox starts — warm follow-ups emit no boot events. - const runtimeBoot = useMemo( - () => (runId ? extractRuntimeBoot(events, runId) : null), - [events, runId], - ); - const steps = useMemo( - () => - blocks.flatMap((block) => (block.kind === "steps" ? block.steps : [])), - [blocks], - ); - const blocksHaveText = blocks.some((block) => block.kind === "text"); - const runError = steps.find((step) => step.category === "error")?.detail; - const messageRepeatsRunError = - runStatus === "failed" && - Boolean(runError) && - message.content.trim() === runError?.trim(); - const successful = runStatus === "completed"; - const running = runStatus === "queued" || runStatus === "running"; - // What the agent is doing right now: drives the caret, the inline - // indicator and the footer pill. Idle whenever the run is not active. - const live = useMemo( - () => deriveLiveStateFromBlocks(blocks, runtimeBoot, runStatus), - [blocks, runtimeBoot, runStatus], - ); - // Artifact cards: every captured file gets one, placed in the timeline - // right after the step that produced it (joined on the tool_started - // sequence). Files no step claims trail the timeline. Derived from the - // persisted events and manifest, so rehydration on refresh is free. - const fileCards = useMemo( - () => (runId ? deriveArtifactCards(events, files, runId, running) : []), - [events, files, runId, running], - ); - const anchoredCards = useMemo( - () => groupCardsByAnchor(fileCards, collectStepSequences(steps)), - [fileCards, steps], - ); - const messageRun = useMemo( - () => ({ runId: runId || null, running }), - [runId, running], - ); - const runtimeArchiveAvailable = - message.metadata.runtime_archive_available === true; - const dashboardPreview = successful - ? messageDashboardPreview(message.metadata) - : null; - return ( -
-
-
- {running ? ( - - ) : runStatus === "failed" ? ( - - ) : ( - - )} -
- - -
- {shouldShowRuntimeBoot(runtimeBoot, running) && runtimeBoot && ( - - )} - {(running || - blocks.length > 0 || - anchoredCards.trailing.length > 0) && ( -
- -
- )} - {runId && } - {!blocksHaveText && message.content && !messageRepeatsRunError && ( - - )} - {dashboardPreview && ( - - )} - {runStatus === "cancelled" && ( -

- This run was stopped. Completed work remains available below. -

- )} - {!replayMode && ( -
- {successful && ( - - )} - {onReplay && successful && ( - - )} - {runId && (runtimeArchiveAvailable || steps.length === 0) && ( - - )} - {running && } - - {running && runId && ( - - - - - )} - {runStatus === "failed" && runId && ( - - )} -
- )} - {showWork && runId && !runtimeArchiveAvailable && ( -
- -
- )} -
-
-
-
-
- ); -} - -function UserMessage({ message }: { message: UiMessage }) { +export function UserMessage({ message }: { message: UiMessage }) { const steeringStatus = message.metadata.steering_status; return (
void; -}) { - const { - events, - conversationId, - files, - openArtifact, - getFileObjectUrl, - downloadFile, - nowMs, - onStop, - onRetry, - } = useChatUi(); - const replay = useChatReplay(events, runId); - // Runs that streamed text carry text_delta events, and the blocks rebuild - // the message from the replayed deltas. Runs that only produced a final - // message have no deltas — for those, reveal the persisted content at the - // moment it actually appeared: when the run completed. - const runStreamedText = useMemo( - () => - events.some( - (event) => event.run_id === runId && event.type === "text_delta", - ), - [events, runId], - ); - const replayMessage = useMemo( - () => ({ - ...message, - content: runStreamedText ? "" : replay.finished ? message.content : "", - runId, - runStatus: replay.finished - ? (message.runStatus ?? "completed") - : "running", - }), - [message, replay.finished, runId, runStreamedText], - ); - return ( -
-
- -
- undefined, - }} - > - - -
- ); -} - -function ReplayableAssistantMessage({ - message, - previousMessageAt, -}: { - message: UiMessage; - previousMessageAt?: number; -}) { - const { events } = useChatUi(); - const [replaying, setReplaying] = useState(false); - const runId = messageRunId(message); - const canReplay = - Boolean(runId) && - events.some((event) => event.run_id === runId && event.type !== "status"); - if (replaying && runId) { - return ( - setReplaying(false)} - /> - ); - } - return ( - setReplaying(true) : undefined} - /> - ); -} - export function ChatMessage({ message, previousMessageAt, @@ -502,9 +52,6 @@ export function ChatMessage({ return message.role === "user" ? ( ) : ( - + ); } diff --git a/signalpilot/web/components/chat/chat-replay-view.tsx b/signalpilot/web/components/chat/chat-replay-view.tsx new file mode 100644 index 000000000..0100bbe2c --- /dev/null +++ b/signalpilot/web/components/chat/chat-replay-view.tsx @@ -0,0 +1,181 @@ +"use client"; + +// Replay mode of the chat transcript: the whole conversation re-plays on +// one compressed clock. A sticky control bar sits above the transcript; +// below it, only the messages whose moment has been reached render, each +// assistant turn through the same AssistantMessage as live, fed by a +// nested chat UI context whose events, file manifest and clock follow the +// replay frame. The first time a file becomes ready while playing, the +// artifacts panel opens on it; while paused or scrubbed every visible +// text block renders complete, with no caret. + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; +import { ReplayControls } from "~/components/chat/replay-controls"; +import { + replayVisibleFiles, + useConversationReplay, + useReplayArtifactAutoOpen, + type ConversationReplayState, + canReplayConversation, +} from "~/lib/chat-replay"; +import type { StandaloneChatEvent } from "~/lib/api"; +import { + ChatUiContext, + useChatUi, + type ChatUiContextValue, + type UiMessage, +} from "~/components/chat/chat-ui-context"; +import { AssistantMessage } from "~/components/chat/assistant-message"; +import { useChatReplaySetting } from "~/components/chat/use-chat-replay-setting"; +import { UserMessage } from "~/components/chat/chat-message"; + +/** Replay mode of one conversation; leaving the conversation leaves it. + * `enabled` is the browser-local "Enable replay" chat setting: the pages + * only offer the button when it is on. */ +export function useReplayMode( + conversationId: string | undefined, + events: StandaloneChatEvent[], + streaming: boolean, +) { + const enabled = useChatReplaySetting(); + const [forConversation, setForConversation] = useState(null); + const replaying = + Boolean(conversationId) && forConversation === conversationId; + const canReplay = + enabled && + Boolean(conversationId) && + !streaming && + !replaying && + canReplayConversation(events); + const enterReplay = useCallback( + () => setForConversation(conversationId ?? null), + [conversationId], + ); + const exitReplay = useCallback(() => setForConversation(null), []); + return { canReplay, replaying, enterReplay, exitReplay }; +} + +/** Distance from the bottom under which the viewport counts as "at the + * bottom" — the live page's stick-to-bottom rule. */ +const STICK_THRESHOLD_PX = 96; + +/** + * Follow the newest content while playing, as a live chat does: scroll the + * viewport to the bottom on every frame until the user scrolls up, and + * resume once they return to the bottom. A restart follows again. + */ +function useReplayFollow( + viewportRef: RefObject | undefined, + replay: Pick, +) { + const stickRef = useRef(true); + useEffect(() => { + stickRef.current = true; + }, [replay.session]); + useEffect(() => { + const viewport = viewportRef?.current; + if (!viewport) return; + const onScroll = () => { + stickRef.current = + viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < + STICK_THRESHOLD_PX; + }; + viewport.addEventListener("scroll", onScroll); + return () => viewport.removeEventListener("scroll", onScroll); + }, [viewportRef]); + useLayoutEffect(() => { + const viewport = viewportRef?.current; + if (!viewport || !replay.playing || !stickRef.current) return; + viewport.scrollTo({ top: viewport.scrollHeight }); + }, [viewportRef, replay.playing, replay.elapsed]); +} + +export function ChatReplayView({ + messages, + onExit, + viewportRef, +}: { + messages: UiMessage[]; + onExit: () => void; + /** The scrolling transcript container, for the follow-the-stream rule. */ + viewportRef?: RefObject; +}) { + const ui = useChatUi(); + const { events, files, openArtifact, nowMs } = ui; + const source = useMemo(() => ({ messages, events }), [messages, events]); + const replay = useConversationReplay(source); + const { frame } = replay; + const visibleFiles = useMemo( + () => + replayVisibleFiles(files, { + events, + visibleEvents: frame.visibleEvents, + }), + [files, events, frame.visibleEvents], + ); + useReplayArtifactAutoOpen({ + files: visibleFiles, + runIds: frame.runIds, + playing: replay.playing, + session: replay.session, + openArtifact, + }); + useReplayFollow(viewportRef, replay); + const replayUi = useMemo( + () => ({ + ...ui, + events: frame.visibleEvents, + files: visibleFiles, + // The replay clock: relative timestamps measure from the instant the + // frame shows, not from today. Falls back to the page's clock only + // when nothing carries a parsable timestamp. + nowMs: replay.nowMs ?? nowMs, + textInstant: replay.textInstant, + onOpenDashboardPreview: () => undefined, + }), + [ui, frame.visibleEvents, visibleFiles, replay.nowMs, replay.textInstant, nowMs], + ); + return ( +
+
+
+ +
+
+ +
+ {frame.messages.map((message, index) => + message.role === "user" ? ( + + ) : ( + + ), + )} +
+
+
+ ); +} diff --git a/signalpilot/web/components/chat/chat-settings-panel.tsx b/signalpilot/web/components/chat/chat-settings-panel.tsx index 07b42cce2..8fe7fe01c 100644 --- a/signalpilot/web/components/chat/chat-settings-panel.tsx +++ b/signalpilot/web/components/chat/chat-settings-panel.tsx @@ -30,6 +30,10 @@ import { setChatTelemetryEnabled, useChatTelemetrySetting, } from "~/components/chat/use-chat-telemetry-setting"; +import { + setChatReplayEnabled, + useChatReplaySetting, +} from "~/components/chat/use-chat-replay-setting"; export type ChatBudgetSettings = { perQueryBudgetUsd: number; @@ -311,6 +315,34 @@ function ModelSection({ model }: { model: ChatModelSettings }) { ); } +function ReplaySection() { + const enabled = useChatReplaySetting(); + return ( +
+ + Replay + +
+
+

+ Enable replay +

+

+ Adds a “Replay chat” button that plays a finished chat + back as it happened. Saved only in this browser. +

+
+ +
+
+ ); +} + function TelemetrySection() { const enabled = useChatTelemetrySetting(); if (!CHAT_TELEMETRY_AVAILABLE) return null; @@ -426,6 +458,7 @@ export function ChatSettingsPanel({ )} {budgets && } +
diff --git a/signalpilot/web/components/chat/chat-ui-context.ts b/signalpilot/web/components/chat/chat-ui-context.ts index d4026b22b..9f6c87011 100644 --- a/signalpilot/web/components/chat/chat-ui-context.ts +++ b/signalpilot/web/components/chat/chat-ui-context.ts @@ -41,6 +41,12 @@ export type ChatUiContextValue = { * conversation route. */ downloadFile?: (fileId: string, filename: string) => Promise; + /** + * Override for fetching a text file's content. The shared read-only page + * injects it so the artifacts panel viewer reads through the share-token + * route; owner pages omit it and the viewer uses the conversation route. + */ + getFileText?: (fileId: string) => Promise; /** * Override for paging the full rows of a governed query result. The * fixture harness injects a deterministic generator; live pages omit it @@ -60,6 +66,13 @@ export type ChatUiContextValue = { * it and the cards tick on the real clock. */ nowMs?: number; + /** True on read-only surfaces (the shared page): no Stop/Retry actions. */ + readOnly?: boolean; + /** + * Replay frame that was paused or scrubbed to: every text block renders + * complete, with no smoothing and no caret. Set only by the replay view. + */ + textInstant?: boolean; /** Opens the right-side Chat settings panel (connectors, budgets). */ openChatSettings?: () => void; onStop: (runId: string) => Promise; diff --git a/signalpilot/web/components/chat/download-ui-file.ts b/signalpilot/web/components/chat/download-ui-file.ts new file mode 100644 index 000000000..1f6e7d192 --- /dev/null +++ b/signalpilot/web/components/chat/download-ui-file.ts @@ -0,0 +1,16 @@ +import { downloadConversationFile, type ConversationFileInfo } from "~/lib/api"; +import type { ChatUiContextValue } from "~/components/chat/chat-ui-context"; + +/** + * Download a file's bytes through the context override when one is set + * (the shared page), else through the owner conversation route. Resolves + * without doing anything when neither is available. + */ +export function downloadUiFile( + ui: Pick, + file: Pick, +): Promise { + if (ui.downloadFile) return ui.downloadFile(file.id, file.filename); + if (!ui.conversationId) return Promise.resolve(); + return downloadConversationFile(ui.conversationId, file.id, file.filename); +} diff --git a/signalpilot/web/components/chat/markdown/file-chip.tsx b/signalpilot/web/components/chat/markdown/file-chip.tsx index e06209c77..9bd94ca12 100644 --- a/signalpilot/web/components/chat/markdown/file-chip.tsx +++ b/signalpilot/web/components/chat/markdown/file-chip.tsx @@ -1,26 +1,17 @@ "use client"; import { AlertTriangle, ArrowDownToLine } from "lucide-react"; -import { downloadConversationFile, type ConversationFileInfo } from "~/lib/api"; +import type { ConversationFileInfo } from "~/lib/api"; import { middleTruncate } from "~/lib/chat-artifact-cards"; import { formatByteSize } from "~/lib/chat-artifacts"; import { kindIcon } from "~/components/chat/artifacts-panel"; import type { ChatUiContextValue } from "~/components/chat/chat-ui-context"; +import { downloadUiFile } from "~/components/chat/download-ui-file"; import { useToast } from "~/components/ui/toast"; -/** - * Download a file's bytes through the context override when one is set - * (the shared page), else through the owner conversation route. Resolves - * without doing anything when neither is available. - */ -export function downloadUiFile( - ui: Pick, - file: Pick, -): Promise { - if (ui.downloadFile) return ui.downloadFile(file.id, file.filename); - if (!ui.conversationId) return Promise.resolve(); - return downloadConversationFile(ui.conversationId, file.id, file.filename); -} +// Re-exported so existing importers keep working; the implementation lives +// in its own module to avoid an import cycle with the file viewer. +export { downloadUiFile } from "~/components/chat/download-ui-file"; /** Primary verb by kind. Data previews, documents open, the rest download. */ export function chipActionLabel(kind: string): "Preview" | "Open" | "Download" { diff --git a/signalpilot/web/components/chat/replay-controls.tsx b/signalpilot/web/components/chat/replay-controls.tsx index 9d3ad4eea..0daacd42f 100644 --- a/signalpilot/web/components/chat/replay-controls.tsx +++ b/signalpilot/web/components/chat/replay-controls.tsx @@ -1,15 +1,19 @@ "use client"; import { Pause, Play, RotateCcw, X } from "lucide-react"; +import { REPLAY_SPEEDS, type ReplaySpeed } from "~/lib/chat-replay"; /** - * Control bar shown above a message while its run is being replayed. - * Timing is "smart" compressed: 4x speed with tool waits capped at 10s. + * Control bar shown above the transcript while the conversation is being + * replayed. Timing is "smart" compressed: the chosen speed, with any single + * wait capped at 10s. */ export function ReplayControls({ elapsed, totalMs, playing, + speed, + onSpeedChange, onTogglePlay, onRestart, onScrub, @@ -18,6 +22,8 @@ export function ReplayControls({ elapsed: number; totalMs: number; playing: boolean; + speed: ReplaySpeed; + onSpeedChange: (speed: ReplaySpeed) => void; onTogglePlay: () => void; onRestart: () => void; onScrub: (ms: number) => void; @@ -26,7 +32,7 @@ export function ReplayControls({ return (
Replay @@ -47,6 +53,30 @@ export function ReplayControls({ > +
+ {REPLAY_SPEEDS.map((value) => ( + + ))} +
) : block.kind === "thinking" ? ( diff --git a/signalpilot/web/components/chat/share-link-dialog.tsx b/signalpilot/web/components/chat/share-link-dialog.tsx new file mode 100644 index 000000000..8821f2e2d --- /dev/null +++ b/signalpilot/web/components/chat/share-link-dialog.tsx @@ -0,0 +1,167 @@ +"use client"; + +// Centered dialog shown right after a chat is shared. It puts the link in +// front of the user with a copy action and states plainly who can open it: +// signed-in members of this organization, nobody else, ever. + +import { CheckCircle2, Copy, Loader2, LockKeyhole, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useFocusTrap } from "~/components/ui/use-focus-trap"; + +export function ShareLinkDialog({ + url: link, + onClose, +}: { + /** The share URL; "pending" shows the dialog with a loader while the + * gateway mints the link; null hides it. */ + url: string | "pending" | null; + onClose: () => void; +}) { + const open = link !== null; + const pending = link === "pending"; + const url = pending ? null : link; + const panelRef = useRef(null); + const inputRef = useRef(null); + const [copied, setCopied] = useState(false); + useFocusTrap(panelRef, open); + + const copy = useCallback(async () => { + if (!url) return; + try { + await navigator.clipboard.writeText(url); + setCopied(true); + } catch { + // Clipboard blocked: leave the link selected so Ctrl+C works. + inputRef.current?.select(); + } + }, [url]); + + // Copy on open so the common case is one click; the button re-copies. + useEffect(() => { + if (!open) { + setCopied(false); + return; + } + if (!url) return; + void copy(); + inputRef.current?.select(); + }, [open, url, copy]); + + if (!open) return null; + + return ( +
{ + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + }} + > +
event.stopPropagation()} + > +
+ + Share this chat with your team + + +
+ +
+

+ Teammates who open this link see the whole chat, including the + work timeline and every file it produced, and can fork it into + their own chats. +

+ {pending ? ( +
+ + Generating your team link. Any previous link for this chat is + being revoked. +
+ ) : ( +
+ event.currentTarget.select()} + className="min-w-0 flex-1 rounded-[10px] border border-[var(--color-border)] bg-[var(--color-bg-input)] px-3 py-2 font-mono text-[11px] text-[var(--color-text)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-text)]" + /> + +
+ )} + +
+ +
+
+ Private to your organization +
+ Only people signed in to your organization can open this link. + It is not public and never becomes public: anyone else, signed + in or not, sees nothing. Sharing again replaces this link, and + you can revoke it at any time from the chat menu. +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/signalpilot/web/components/chat/shared-chat-files.tsx b/signalpilot/web/components/chat/shared-chat-files.tsx deleted file mode 100644 index 5ce05f803..000000000 --- a/signalpilot/web/components/chat/shared-chat-files.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -// Read-only file surfaces for the shared conversation page: the files list -// under the transcript and the lightbox the inline figures and chips open. -// The page has no artifacts panel, so this is the whole viewer. - -import { Download, Loader2 } from "lucide-react"; -import type { ConversationFileInfo } from "~/lib/api"; -import { formatByteSize } from "~/lib/chat-artifacts"; -import { middleTruncate } from "~/lib/chat-artifact-cards"; -import { kindIcon } from "~/components/chat/artifacts-panel"; -import { ArtifactLightbox } from "~/components/chat/artifact-lightbox"; -import { useFileObjectUrl } from "~/components/chat/use-file-object-url"; - -export type SharedFileActions = { - /** Opens an image in the lightbox; downloads anything else. */ - open: (file: ConversationFileInfo) => void; - download: (file: ConversationFileInfo) => void; -}; - -export function SharedFilesSection({ - files, - actions, -}: { - files: ConversationFileInfo[]; - actions: SharedFileActions; -}) { - if (files.length === 0) return null; - return ( -
-

- Files -

-
    - {files.map((file) => ( -
  • - {kindIcon(file.kind, "h-3.5 w-3.5 flex-none text-[var(--color-text-dim)]")} - - - {formatByteSize(file.byte_size)} - - -
  • - ))} -
-
- ); -} - -/** - * Fullscreen image viewer for one shared file. Must render inside the - * page's ChatUiContext provider so the object URL comes through the - * shared content route override. - */ -export function SharedFileLightbox({ - file, - onClose, -}: { - file: ConversationFileInfo | null; - onClose: () => void; -}) { - const { url, error } = useFileObjectUrl(file, null); - return ( - - {error ? ( -

- This file is no longer available. -

- ) : url ? ( - // eslint-disable-next-line @next/next/no-img-element - {file?.filename - ) : ( - - )} -
- ); -} diff --git a/signalpilot/web/components/chat/shared-standalone-data-chat.tsx b/signalpilot/web/components/chat/shared-standalone-data-chat.tsx index 6a474c685..1fb9df4f8 100644 --- a/signalpilot/web/components/chat/shared-standalone-data-chat.tsx +++ b/signalpilot/web/components/chat/shared-standalone-data-chat.tsx @@ -1,147 +1,96 @@ "use client"; -import { - AlertCircle, - ArrowLeft, - Bot, - GitFork, - Loader2, - LockKeyhole, - Sparkles, -} from "lucide-react"; +// The shared chat page IS the chat page, read only: the same message tree, +// inline artifact cards, and artifacts panel as the owner sees, fed by the +// share-token routes. One button forks the whole chat into the viewer's own. + +import { AlertCircle, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useMemo, useState } from "react"; -import { ChatMarkdown } from "~/components/chat/chat-markdown"; -import useSWR from "swr"; -import { - ChatUiContext, - type ChatUiContextValue, -} from "~/components/chat/chat-ui-context"; -import { - SharedFileLightbox, - SharedFilesSection, -} from "~/components/chat/shared-chat-files"; -import { useToast } from "~/components/ui/toast"; +import { useCallback, useRef, useState } from "react"; +import { ArtifactsPanel } from "~/components/chat/artifacts-panel"; +import { ChatMessage } from "~/components/chat/chat-message"; +import { ChatReplayView } from "~/components/chat/chat-replay-view"; +import { ChatUiContext } from "~/components/chat/chat-ui-context"; +import { SharedChatHeader } from "~/components/chat/shared/shared-chat-header"; import { - downloadSharedConversationFile, - forkSharedStandaloneConversation, - getSharedConversationFileObjectUrl, - getSharedConversationFiles, - getSharedStandaloneForkPreview, - getSharedStandaloneConversation, - type ConversationFileInfo, - type StandaloneForkPreview, -} from "~/lib/api"; + useForkSharedChat, + useSharedChat, + useSharedChatUi, +} from "~/components/chat/shared/use-shared-chat"; +import { chatShellClassName } from "~/components/chat/standalone-chat-derivations"; +import { ChatPanelToggles } from "~/components/chat/standalone-chat-panels"; +import { useOpenArtifact } from "~/components/chat/use-open-artifact"; +import { hasArtifactsContent } from "~/lib/chat-artifacts"; +import { canReplayConversation } from "~/lib/chat-replay"; +import { useChatReplaySetting } from "~/components/chat/use-chat-replay-setting"; +import { standaloneMessageKey } from "~/lib/standalone-chat-state"; -const noop = async () => undefined; - -export function SharedStandaloneDataChat({ token }: { token: string }) { - const router = useRouter(); - const { toast } = useToast(); - const [forking, setForking] = useState(false); - const [forkPreview, setForkPreview] = useState( - null, - ); - const [perQueryBudget, setPerQueryBudget] = useState(0.25); - const [chatBudget, setChatBudget] = useState(1); - const { data, error, isLoading } = useSWR( - `shared-standalone-chat:${token}`, - () => getSharedStandaloneConversation(token), - { revalidateOnFocus: false }, - ); - // Files from the conversation's finished runs. A failure here only hides - // the file surfaces; the transcript still renders. - const { data: fileData } = useSWR( - data ? `shared-standalone-chat-files:${token}` : null, - () => getSharedConversationFiles(token), - { revalidateOnFocus: false, shouldRetryOnError: false }, - ); - const files = useMemo(() => fileData?.files ?? [], [fileData]); - const [lightboxFile, setLightboxFile] = useState( - null, - ); - const downloadFile = useCallback( - (fileId: string, filename: string) => - downloadSharedConversationFile(token, fileId, filename).catch(() => { - toast("This file is no longer available.", "error"); - }), - [toast, token], - ); - // The shared page has no artifacts panel: opening a file shows an image - // in the lightbox and downloads anything else. - const openFile = useCallback( - (file: ConversationFileInfo) => { - if (file.kind === "image") setLightboxFile(file); - else void downloadFile(file.id, file.filename); - }, - [downloadFile], - ); - const openArtifact = useCallback( - (fileId: string) => { - const file = files.find((entry) => entry.id === fileId); - if (file) openFile(file); - }, - [files, openFile], - ); - const getFileObjectUrl = useCallback( - (fileId: string) => getSharedConversationFileObjectUrl(token, fileId), - [token], - ); - // Read-only: no run events reach this page, so no inline cards derive; - // the markdown overrides (figure, chip) resolve against `files`. - const ui = useMemo( - () => ({ - events: [], - conversationId: null, - files, - openArtifact, - getFileObjectUrl, - downloadFile, - onStop: noop, - onRetry: noop, - onOpenDashboardPreview: () => undefined, - }), - [downloadFile, files, getFileObjectUrl, openArtifact], +function SharedChatUnavailable({ onBack }: { onBack: () => void }) { + return ( +
+
+ +

+ Shared conversation unavailable +

+

+ This link is invalid, revoked, archived, or belongs to another + organization. +

+ +
+
); +} - const prepareFork = async () => { - setForking(true); - try { - const preview = await getSharedStandaloneForkPreview(token); - setForkPreview(preview); - setPerQueryBudget(preview.per_query_budget_usd); - setChatBudget(preview.chat_budget_usd); - } catch (forkError) { - toast( - forkError instanceof Error - ? forkError.message - : "Could not fork this conversation", - "error", - ); - } finally { - setForking(false); - } - }; +/** + * Blocks the page while the gateway copies the chat. Forking a long chat + * with many files takes several seconds, and the redirect to the new chat + * only happens when it is done, so the wait must be visible. + */ +function ForkingOverlay() { + return ( +
+
+ +
+
+ Copying this chat into your workspace +
+
+ Messages, work timeline, and files. You will land on your copy + when it is ready. +
+
+
+
+ ); +} - const confirmFork = async () => { - setForking(true); - try { - const fork = await forkSharedStandaloneConversation( - token, - perQueryBudget, - chatBudget, - ); - router.push(`/chats/${fork.id}`); - } catch (forkError) { - toast( - forkError instanceof Error - ? forkError.message - : "Could not fork this conversation", - "error", - ); - setForking(false); - } - }; +export function SharedStandaloneDataChat({ token }: { token: string }) { + const replayEnabled = useChatReplaySetting(); + const router = useRouter(); + const goToChats = useCallback(() => router.push("/chats"), [router]); + const { detail, error, isLoading, uiMessages, executions, forkingEnabled } = + useSharedChat(token); + const { forking, fork } = useForkSharedChat(token); + const [artifactsOpen, setArtifactsOpen] = useState(false); + const openArtifactsPanel = useCallback(() => setArtifactsOpen(true), []); + const { openFileRequest, openArtifact } = useOpenArtifact(openArtifactsPanel); + const ui = useSharedChatUi(token, detail, openArtifact); + const [replaying, setReplaying] = useState(false); + const viewportRef = useRef(null); if (isLoading) { return ( @@ -150,197 +99,74 @@ export function SharedStandaloneDataChat({ token }: { token: string }) {
); } - - if (error || !data) { - return ( -
-
- -

- Shared conversation unavailable -

-

- This link is invalid, revoked, archived, or belongs to another - organization. -

- -
-
- ); + if (error || !detail) { + return ; } - return ( -
-
-
-
- -
-
- - Team-shared chat -
-
-
- {data.conversation.title} -
- {data.conversation.origin === "improvement" && ( - - - Automated improvement run - - )} -
-
-
-
- {data.conversation.project_name && ( - - {data.conversation.project_name} - - )} - - Read only - - -
-
+ const files = detail.files; + const artifactsAvailable = hasArtifactsContent([], files, executions); - {forkPreview && ( -
-
-
- Confirm a private fork of {forkPreview.project_name} -
-
- Frozen commit {forkPreview.commit_sha} -
-

{forkPreview.warehouse_cost_notice}

-
- -
- )} - -
-
-
- This authenticated view includes the business conversation and - the files its finished runs produced. SQL, tool traces, and work - details are not shared. Fork it to continue privately in your - own chat. -
- - -
- {data.messages.map((message) => - message.role === "user" ? ( -
-
{message.content}
-
) : ( -
-
- -
-
- -
+
+ {uiMessages.map((message, index) => ( + + ))}
- ), - )} + )} +
+ undefined} + />
- - + {artifactsOpen && ( + void downloadFile(file.id, file.filename), - }} - /> - setLightboxFile(null)} + executions={executions} + openFileRequest={openFileRequest} + onClose={() => setArtifactsOpen(false)} /> -
-
-
+ )} +
- + ); } diff --git a/signalpilot/web/components/chat/shared/shared-chat-header.tsx b/signalpilot/web/components/chat/shared/shared-chat-header.tsx new file mode 100644 index 000000000..42790b662 --- /dev/null +++ b/signalpilot/web/components/chat/shared/shared-chat-header.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { ArrowLeft, GitFork, History, Loader2, LockKeyhole } from "lucide-react"; +import type { SharedConversation } from "~/lib/api"; +import { AutomatedBadge } from "~/components/chat/standalone-chat-helpers"; + +/** + * Header of the read-only shared chat page: title, project, a "shared with + * your team" label, "Replay chat" when there is something to replay, and + * the one primary action, "Fork to my chats". + */ +export function SharedChatHeader({ + conversation, + forkingEnabled, + forking, + onFork, + onBack, + onReplay, +}: { + conversation: SharedConversation; + forkingEnabled: boolean; + forking: boolean; + onFork: () => void; + onBack: () => void; + /** Offered only when the conversation has something to replay. */ + onReplay?: () => void; +}) { + return ( +
+
+ +
+
+ + Shared with your team · read only +
+
+
+ {conversation.title} +
+ {conversation.origin === "improvement" && } +
+
+
+
+ {conversation.project_name && ( + + {conversation.project_name} + + )} + {onReplay && ( + + )} + {forkingEnabled && ( + + )} +
+
+ ); +} diff --git a/signalpilot/web/components/chat/shared/use-shared-chat.ts b/signalpilot/web/components/chat/shared/use-shared-chat.ts new file mode 100644 index 000000000..f76248e1a --- /dev/null +++ b/signalpilot/web/components/chat/shared/use-shared-chat.ts @@ -0,0 +1,147 @@ +"use client"; + +// Data and actions for the read-only shared chat page. The gateway's +// share-token routes are the single source of truth; nothing here derives +// state from the live run stream. + +import { useRouter } from "next/navigation"; +import { useCallback, useMemo, useState } from "react"; +import useSWR from "swr"; +import type { ChatUiContextValue } from "~/components/chat/chat-ui-context"; +import { useToast } from "~/components/ui/toast"; +import { + downloadSharedConversationFile, + forkSharedStandaloneConversation, + getSharedConversationFileObjectUrl, + getSharedConversationFileText, + getSharedConversationSqlTrace, + getSharedStandaloneConversation, + getStandaloneChatBootstrap, + type SharedConversationDetail, + type SqlTraceExecution, +} from "~/lib/api"; +import { buildStandaloneUiMessages } from "~/lib/standalone-chat-ui-messages"; +import { getSharedToolResult } from "~/lib/api/chat-results"; + +const noop = async () => undefined; +const EMPTY_EXECUTIONS: SqlTraceExecution[] = []; + +/** The shared snapshot, its SQL trace, and whether the viewer may fork. */ +export function useSharedChat(token: string) { + const { data, error, isLoading } = useSWR( + `shared-standalone-chat:${token}`, + () => getSharedStandaloneConversation(token), + { revalidateOnFocus: false }, + ); + // The trace only feeds the Queries tab; a failure hides that tab's rows. + const { data: trace } = useSWR( + data ? `shared-standalone-chat-sql-trace:${token}` : null, + () => getSharedConversationSqlTrace(token), + { revalidateOnFocus: false, shouldRetryOnError: false }, + ); + // Same key as the live page so the bootstrap is fetched once per session. + // A failure only hides the fork button; the transcript still renders. + const { data: bootstrap } = useSWR( + "standalone-chat-bootstrap", + getStandaloneChatBootstrap, + { revalidateOnFocus: false, shouldRetryOnError: false }, + ); + const uiMessages = useMemo( + () => + data + ? buildStandaloneUiMessages({ + detailMessages: data.messages, + events: data.run_events, + }) + : [], + [data], + ); + return { + detail: data, + error, + isLoading, + uiMessages, + executions: trace?.executions ?? EMPTY_EXECUTIONS, + forkingEnabled: Boolean(bootstrap?.enterprise_features.forking), + }; +} + +/** + * The ChatUiContext value for the shared page: every file and result read + * goes through the share-token routes, and run controls are inert. + */ +export function useSharedChatUi( + token: string, + detail: SharedConversationDetail | undefined, + openArtifact: (fileId: string) => void, +): ChatUiContextValue { + const { toast } = useToast(); + const getFileObjectUrl = useCallback( + (fileId: string) => getSharedConversationFileObjectUrl(token, fileId), + [token], + ); + const getFileText = useCallback( + (fileId: string) => getSharedConversationFileText(token, fileId), + [token], + ); + const downloadFile = useCallback( + (fileId: string, filename: string) => + downloadSharedConversationFile(token, fileId, filename).catch(() => { + toast("This file is no longer available.", "error"); + }), + [toast, token], + ); + const getToolResultRows = useCallback( + (resultId: string, opts?: { offset?: number; limit?: number }) => + getSharedToolResult(token, resultId, opts), + [token], + ); + const events = detail?.run_events; + const files = detail?.files; + return useMemo( + () => ({ + events: events ?? [], + conversationId: null, + files: files ?? [], + openArtifact, + getFileObjectUrl, + getFileText, + downloadFile, + getToolResultRows, + readOnly: true, + onStop: noop, + onRetry: noop, + onOpenDashboardPreview: () => undefined, + }), + [ + downloadFile, + events, + files, + getFileObjectUrl, + getFileText, + getToolResultRows, + openArtifact, + ], + ); +} + +/** "Fork to my chats": one call, then land on the new conversation. */ +export function useForkSharedChat(token: string) { + const router = useRouter(); + const { toast } = useToast(); + const [forking, setForking] = useState(false); + const fork = useCallback(async () => { + setForking(true); + try { + const forked = await forkSharedStandaloneConversation(token); + router.push(`/chats/${forked.id}`); + } catch (error) { + toast( + error instanceof Error ? error.message : "Could not fork this chat", + "error", + ); + setForking(false); + } + }, [router, toast, token]); + return { forking, fork }; +} diff --git a/signalpilot/web/components/chat/standalone-chat-panels.tsx b/signalpilot/web/components/chat/standalone-chat-panels.tsx index b53944cf0..2bb3f353e 100644 --- a/signalpilot/web/components/chat/standalone-chat-panels.tsx +++ b/signalpilot/web/components/chat/standalone-chat-panels.tsx @@ -5,7 +5,7 @@ // or the dashboard preview). The container owns the open/close state; this // module only renders it. -import { LayoutDashboard, Loader2, NotebookPen } from "lucide-react"; +import { History, LayoutDashboard, Loader2, NotebookPen, Share2 } from "lucide-react"; import type { ConversationFileInfo, ConversationNotebook, @@ -21,7 +21,7 @@ import { } from "~/components/chat/chat-settings-panel"; const TOGGLE_CLASS = - "absolute top-4 z-20 flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-card)] text-[var(--color-text-muted)] shadow-lg shadow-black/20 hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"; + "flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-card)] text-[var(--color-text-muted)] shadow-lg shadow-black/20 hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"; export function ChatPanelToggles({ artifactsAvailable, @@ -31,7 +31,13 @@ export function ChatPanelToggles({ dashboardSessionId, dashboardOpen, onOpenDashboard, + onShare, + onReplay, }: { + /** Owner pages with team sharing: the share-link action. */ + onShare?: () => void; + /** Offered only when the conversation has something to replay. */ + onReplay?: () => void; artifactsAvailable: boolean; artifactsLoading: boolean; artifactsOpen: boolean; @@ -41,8 +47,33 @@ export function ChatPanelToggles({ dashboardOpen: boolean; onOpenDashboard: (sessionId: string) => void; }) { + // One floating row, outermost action last, so absent toggles leave no gap. return ( - <> +
+ {onReplay && ( + + )} + {dashboardSessionId && !dashboardOpen && ( + + )} {(artifactsLoading || artifactsAvailable) && !artifactsOpen && ( )} - +
); } diff --git a/signalpilot/web/components/chat/standalone-chat-test-harness.tsx b/signalpilot/web/components/chat/standalone-chat-test-harness.tsx index ce593553c..b9ac34412 100644 --- a/signalpilot/web/components/chat/standalone-chat-test-harness.tsx +++ b/signalpilot/web/components/chat/standalone-chat-test-harness.tsx @@ -3,6 +3,7 @@ import { FastForward, FlaskConical, + History, Pause, Play, RotateCcw, @@ -16,6 +17,7 @@ import { type UiMessage, } from "~/components/chat/standalone-data-chat"; import { ArtifactsPanel } from "~/components/chat/artifacts-panel"; +import { ChatReplayView } from "~/components/chat/chat-replay-view"; import { StandaloneChatComposer } from "~/components/chat/standalone-chat-composer"; import { useDockScrollCompensation } from "~/components/chat/use-dock-scroll-compensation"; import { selectComposerPlan } from "~/lib/chat-composer-plan"; @@ -85,6 +87,9 @@ export function StandaloneChatTestHarness() { // a click that lands before hydration is silently lost. const [hydrated, setHydrated] = useState(false); useEffect(() => setHydrated(true), []); + // Conversation replay mode, as on the chat page (the transcript swaps + // for the replay view; the harness clock keeps its own position). + const [replaying, setReplaying] = useState(false); const [speed, setSpeed] = useState<(typeof SPEEDS)[number]>(1); const [selectedModel, setSelectedModel] = useState("claude-opus-5"); @@ -192,7 +197,9 @@ export function StandaloneChatTestHarness() { role: "user", content: FIXTURE_USER_PROMPT, sequence: 1, - created_at: 0, + // Epoch seconds, as the gateway records them: the replay anchors + // user messages on this clock. + created_at: fixtureNowMs(0) / 1_000, metadata: {}, }, { @@ -200,7 +207,7 @@ export function StandaloneChatTestHarness() { role: "assistant", content: fixtureAssembledText(elapsed), sequence: 2, - created_at: 0, + created_at: fixtureNowMs(FIXTURE_TOTAL_MS) / 1_000, metadata: { run_id: FIXTURE_RUN_ID }, runId: FIXTURE_RUN_ID, runStatus: status, @@ -212,7 +219,9 @@ export function StandaloneChatTestHarness() { role: "user", content: FIXTURE_FOLLOW_UP_PROMPT, sequence: 3, - created_at: 0, + // A minute after the first run ended: the replay collapses + // the pause to the gap cap. + created_at: fixtureNowMs(FIXTURE_TOTAL_MS + 60_000) / 1_000, metadata: {}, }, { @@ -220,7 +229,7 @@ export function StandaloneChatTestHarness() { role: "assistant", content: "", sequence: 4, - created_at: 0, + created_at: fixtureNowMs(FIXTURE_TOTAL_MS + 60_000) / 1_000, metadata: { run_id: FIXTURE_FOLLOW_UP_RUN_ID }, runId: FIXTURE_FOLLOW_UP_RUN_ID, runStatus: "running", @@ -336,6 +345,20 @@ export function StandaloneChatTestHarness() { {(elapsed / 1000).toFixed(1)}s · {progress}% + - )} {conversationId && isImprovementConversation(detail?.conversation) && (
@@ -507,6 +505,12 @@ export function StandaloneDataChat({ )}
+ ) : replaying ? ( + ) : (
{uiMessages.map((message, index) => ( @@ -518,7 +522,7 @@ export function StandaloneDataChat({ ))}
)} - {!isEmptyNewChat && ( + {!isEmptyNewChat && !replaying && (
void shareConversation(detail.conversation) + : undefined + } + onReplay={canReplay ? enterReplay : undefined} /> )} @@ -581,6 +593,7 @@ export function StandaloneDataChat({
+ ); diff --git a/signalpilot/web/components/chat/use-chat-replay-setting.test.tsx b/signalpilot/web/components/chat/use-chat-replay-setting.test.tsx new file mode 100644 index 000000000..77bdebf44 --- /dev/null +++ b/signalpilot/web/components/chat/use-chat-replay-setting.test.tsx @@ -0,0 +1,55 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + CHAT_REPLAY_STORAGE_KEY, + setChatReplayEnabled, + useChatReplaySetting, +} from "~/components/chat/use-chat-replay-setting"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function Probe() { + const enabled = useChatReplaySetting(); + return ( + + ); +} + +describe("chat replay local setting", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + window.localStorage.removeItem(CHAT_REPLAY_STORAGE_KEY); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + window.localStorage.removeItem(CHAT_REPLAY_STORAGE_KEY); + container.remove(); + }); + + it("defaults off and persists an opt-in only in local storage", async () => { + await act(async () => root.render()); + const toggle = container.querySelector("button")!; + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + expect(window.localStorage.getItem(CHAT_REPLAY_STORAGE_KEY)).toBeNull(); + + await act(async () => toggle.click()); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + expect(window.localStorage.getItem(CHAT_REPLAY_STORAGE_KEY)).toBe("true"); + + await act(async () => toggle.click()); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + expect(window.localStorage.getItem(CHAT_REPLAY_STORAGE_KEY)).toBeNull(); + }); +}); diff --git a/signalpilot/web/components/chat/use-chat-replay-setting.ts b/signalpilot/web/components/chat/use-chat-replay-setting.ts new file mode 100644 index 000000000..272e7e3f1 --- /dev/null +++ b/signalpilot/web/components/chat/use-chat-replay-setting.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +export const CHAT_REPLAY_STORAGE_KEY = "sp:chat-replay-enabled"; + +const CHANGE_EVENT = "sp:chat-replay-setting-change"; + +function getSnapshot(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(CHAT_REPLAY_STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +function subscribe(onStoreChange: () => void): () => void { + const onStorage = (event: StorageEvent) => { + if (event.key === CHAT_REPLAY_STORAGE_KEY) onStoreChange(); + }; + window.addEventListener("storage", onStorage); + window.addEventListener(CHANGE_EVENT, onStoreChange); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(CHANGE_EVENT, onStoreChange); + }; +} + +export function setChatReplayEnabled(enabled: boolean): void { + try { + if (enabled) { + window.localStorage.setItem(CHAT_REPLAY_STORAGE_KEY, "true"); + } else { + window.localStorage.removeItem(CHAT_REPLAY_STORAGE_KEY); + } + } catch { + // Keep the control usable when browser storage is unavailable. + } + window.dispatchEvent(new Event(CHANGE_EVENT)); +} + +/** Browser-only opt-in for the "Replay chat" action. Replay is a demo + * feature most users never need, so it is off until enabled here; missing + * or unavailable storage always means disabled. */ +export function useChatReplaySetting(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, () => false); +} diff --git a/signalpilot/web/components/chat/use-standalone-chat-actions.ts b/signalpilot/web/components/chat/use-standalone-chat-actions.ts index 544daf83e..6653709c7 100644 --- a/signalpilot/web/components/chat/use-standalone-chat-actions.ts +++ b/signalpilot/web/components/chat/use-standalone-chat-actions.ts @@ -4,13 +4,7 @@ // conversation load/select, and rail management. import { useRouter } from "next/navigation"; -import { - useCallback, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction, -} from "react"; +import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useRef, useState } from "react"; import { useSWRConfig } from "swr"; import { archiveStandaloneConversation, @@ -405,20 +399,22 @@ export function useStandaloneChatActions({ toastRequestError(toast, error, "Could not remove the chat"); } }; + // The new link opens in a centered dialog (ShareLinkDialog) that copies + // it and spells out who can open it. A corner toast was too easy to miss. + // `shareLink` drives the dialog: "pending" opens it with a loader the + // moment the button is pressed, a string fills in the link, null closes. + const [shareLink, setShareLink] = useState(null); const shareConversation = async (conversation: StandaloneConversation) => { + setShareLink("pending"); try { const grant = await shareStandaloneConversation(conversation.id); - const url = `${window.location.origin}/chats/shared/${grant.token}`; - try { - await navigator.clipboard.writeText(url); - toast("Team link copied", "success"); - } catch { - window.prompt("Copy team link", url); - } + setShareLink(`${window.location.origin}/chats/shared/${grant.token}`); } catch (error) { + setShareLink(null); toastRequestError(toast, error, "Could not share the chat"); } }; + const dismissShareLink = () => setShareLink(null); const revokeShare = async (conversation: StandaloneConversation) => { if (!window.confirm("Revoke all active team links for this chat?")) return; try { @@ -439,6 +435,8 @@ export function useStandaloneChatActions({ renameConversation, archiveConversation, shareConversation, + shareLink, + dismissShareLink, revokeShare, }; } diff --git a/signalpilot/web/components/chat/use-standalone-chat-run.ts b/signalpilot/web/components/chat/use-standalone-chat-run.ts index e8a487844..ffd8bacc1 100644 --- a/signalpilot/web/components/chat/use-standalone-chat-run.ts +++ b/signalpilot/web/components/chat/use-standalone-chat-run.ts @@ -28,18 +28,14 @@ import { useToast } from "~/components/ui/toast"; import { toastRequestError } from "~/components/chat/toast-request-error"; import { applyStandaloneChatEvent, - assembleStandaloneRunText, containsStandaloneSubmission, - deriveStandaloneRunActivity, isStandaloneRunReconciled, type OptimisticUserMessage, } from "~/lib/standalone-chat-state"; +import { buildStandaloneUiMessages } from "~/lib/standalone-chat-ui-messages"; import type { UiMessage } from "~/components/chat/chat-ui-context"; import type { ChatEventArrival } from "~/lib/chat-telemetry"; -import { - eventText, - isStreamingStatus, -} from "~/components/chat/standalone-chat-helpers"; +import { eventText } from "~/components/chat/standalone-chat-helpers"; export type DetailMutator = KeyedMutator; export type HistoryMutator = KeyedMutator<{ @@ -203,110 +199,17 @@ export function useStandaloneUiMessages({ pendingSubmission: OptimisticUserMessage | null; setPendingSubmission: (value: OptimisticUserMessage | null) => void; }) { - const uiMessages = useMemo(() => { - const messages: UiMessage[] = [...(detailMessages ?? [])]; - if (currentRun) { - const runMessages = messages.filter( - (message) => message.metadata.run_id === currentRun.id, - ); - const hasTerminalMessage = runMessages.some( - (message) => - message.role === "assistant" && - ["completed", "failed", "cancelled"].includes( - typeof message.metadata.status === "string" - ? message.metadata.status - : "", - ), - ); - const hasWaitingMessage = runMessages.some( - (message) => - message.role === "assistant" && - message.metadata.status === "waiting_for_user", - ); - if ( - !hasTerminalMessage && - !(currentRun.status === "waiting_for_user" && hasWaitingMessage) - ) { - const runEvents = events.filter( - (event) => event.run_id === currentRun.id, - ); - const resetSequence = runEvents.reduce( - (latest, event) => - event.type === "status" && event.payload?.reset_text === true - ? Math.max(latest, event.sequence) - : latest, - 0, - ); - const streamed = assembleStandaloneRunText( - runEvents, - currentRun.id, - resetSequence, - ); - const clarification = [...runEvents] - .reverse() - .find((event) => event.type === "clarification_requested"); - const error = [...runEvents] - .reverse() - .find((event) => event.type === "error"); - const content = - (clarification && eventText(clarification, "message")) || - streamed || - (error && eventText(error, "message")) || - (currentRun.status === "cancelled" - ? "This run was stopped." - : currentRun.status === "completed" - ? "Finalizing your answer…" - : ""); - messages.push({ - id: `run-${currentRun.id}`, - role: "assistant", - content, - sequence: Number.MAX_SAFE_INTEGER, - created_at: Date.parse(currentRun.created_at) / 1_000, - metadata: { - run_id: currentRun.id, - optimistic: true, - ...(currentRun.usage ? { token_usage: currentRun.usage } : {}), - }, - runId: currentRun.id, - runStatus: currentRun.status, - activity: deriveStandaloneRunActivity(runEvents, currentRun.id), - synthetic: true, - }); - } - } - if ( - pendingSubmission && - !containsStandaloneSubmission(messages, pendingSubmission) - ) { - messages.push({ - id: pendingSubmission.id, - role: "user", - content: pendingSubmission.content, - sequence: Number.MAX_SAFE_INTEGER - 1, - created_at: pendingSubmission.createdAt, - metadata: { optimistic: true }, - }); - } - if ( - pendingSubmission && - isSubmitting && - !isStreamingStatus(currentRun?.status) - ) { - messages.push({ - id: `pending-assistant-${pendingSubmission.id}`, - role: "assistant", - content: "", - sequence: Number.MAX_SAFE_INTEGER, - created_at: pendingSubmission.createdAt, - metadata: { optimistic: true }, - runStatus: "queued", - activity: deriveStandaloneRunActivity([], ""), - synthetic: true, - }); - } - return messages; - }, [currentRun, detailMessages, events, isSubmitting, pendingSubmission]); + const uiMessages = useMemo( + () => + buildStandaloneUiMessages({ + detailMessages, + currentRun, + events, + isSubmitting, + pendingSubmission, + }), + [currentRun, detailMessages, events, isSubmitting, pendingSubmission], + ); useEffect(() => { if ( diff --git a/signalpilot/web/e2e/chat-replay-artifacts.spec.ts b/signalpilot/web/e2e/chat-replay-artifacts.spec.ts new file mode 100644 index 000000000..be047ce00 --- /dev/null +++ b/signalpilot/web/e2e/chat-replay-artifacts.spec.ts @@ -0,0 +1,238 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +/** + * Conversation replay ("Replay chat" in the header), exercised through the + * fixture harness at /chats/test. The harness is scrubbed to its end first + * so the conversation is complete, then the replay re-streams the whole + * chat on the compressed clock. At the default 5x every fixture gap is + * under the 10s cap, so replay offset = original offset / 5 (the 24.8s + * fixture replays in ~5.4s). + * + * Inline artifact cards must follow the replay frame the way they follow a + * live run: none before the Write step that produced the file, then the + * card right under that step, and gone again when the scrub moves back. + * Scrubbing renders text instantly; later turns appear at their anchor. + */ + +const BASE = process.env.SP_WEB_BASE_URL ?? "http://localhost:3200"; +const at = (ms: number, extra = "") => + `${BASE}/chats/test?at=${ms}&paused=1${extra}`; +const FIXTURE_END_MS = 24_800; +/** Replay offsets at 5x for original fixture instants. */ +const R = (originalMs: number) => Math.round(originalMs / 5 / 100) * 100; +const REPLAY_END_MS = 5_400; + +async function waitForHydration(page: Page) { + await expect(page.getByTestId("chat-test-harness")).toHaveAttribute( + "data-hydrated", + "1", + ); +} + +/** Drives the replay's range slider the way a user drag does (React listens + * to the native `input` event; the value must go through the native setter + * or React's tracker swallows the change). */ +async function scrubTo(controls: Locator, ms: number) { + await controls + .getByRole("slider", { name: "Replay position" }) + .evaluate((element, value) => { + const input = element as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + setter?.call(input, String(value)); + input.dispatchEvent(new Event("input", { bubbles: true })); + }, ms); +} + +/** Opens the conversation replay and pauses it at once so frames are + * chosen by the scrub, not by the wall clock. */ +async function startPausedReplay(page: Page, extra = "") { + await page.goto(at(FIXTURE_END_MS, extra)); + await waitForHydration(page); + await page.getByTestId("chat-replay-button").click(); + const replay = page.getByTestId("chat-replay"); + await expect(replay).toBeVisible(); + const controls = replay.getByTestId("chat-replay-controls"); + await controls.getByRole("button", { name: "Pause replay" }).click(); + await expect( + controls.getByRole("button", { name: "Play replay" }), + ).toBeVisible(); + return { replay, controls }; +} + +test.describe("conversation replay: artifact cards (fixture harness)", () => { + test("no cards at replay start; cards anchor under their steps as the scrub reaches them", async ({ + page, + }) => { + const { replay, controls } = await startPausedReplay(page); + // The per-message action row is gone; the header owns the entry point. + await expect(page.getByTestId("standalone-chat-messages")).toHaveCount(0); + // Paused within the first fraction of a second of replay: the user + // message and the queued answer show, nothing has been written yet. + await expect(replay.getByText("Which regions drove Q3")).toBeVisible(); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(0); + await expect(replay.getByTestId("chat-artifact-card-pending")).toHaveCount(0); + await expect(replay.getByTestId("chat-artifact-card-row")).toHaveCount(0); + await expect(replay.getByTestId("chat-trailing-artifact-cards")).toHaveCount(0); + + // 15.5s of the original run: five files written, each card under its + // own Write step inside the open group. + await scrubTo(controls, R(15_500)); + await expect(replay.getByTestId("chat-step-artifact-cards")).toHaveCount(5); + const cards = replay.getByTestId("chat-artifact-card"); + await expect(cards).toHaveCount(5); + await expect(cards.nth(0)).toContainText("q3_growth.py"); + await expect(cards.nth(4)).toContainText("q3_summary.md"); + await expect(replay.getByTestId("chat-trailing-artifact-cards")).toHaveCount(0); + await expect(replay.getByTestId("chat-artifact-card-pending")).toHaveCount(0); + // Timestamps run on the replay clock, not today's: the frame is seconds + // after the edit, so the card says so. + await expect(cards.nth(0)).toContainText("just now"); + + // 12.5s of the original run: the report's Write has landed but its + // mirror (13.0s) has not — a pending card, under the Write step, + // exactly as live. + await scrubTo(controls, R(12_500)); + const pending = replay.getByTestId("chat-artifact-card-pending"); + await expect(pending).toHaveCount(1); + await expect(pending).toContainText("q3_regional_review.html"); + await expect( + replay + .getByTestId("chat-step-artifact-cards") + .filter({ hasText: "q3_regional_review.html" }), + ).toHaveCount(1); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(1); + + // Scrub back before the first Write (8s original): the files are gone + // again, not stranded as trailing cards. + await scrubTo(controls, R(8_000)); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(0); + await expect(replay.getByTestId("chat-artifact-card-pending")).toHaveCount(0); + await expect(replay.getByTestId("chat-trailing-artifact-cards")).toHaveCount(0); + + // End of the replay: the run completes, the group collapses and hoists + // every card into its footer, none trailing — the live end state. + await scrubTo(controls, REPLAY_END_MS); + await expect(replay.getByTestId("chat-group-artifact-cards")).toHaveCount(1); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(3); + await expect(replay.getByTestId("chat-artifact-card-row")).toHaveCount(4); + await expect(replay.getByTestId("chat-trailing-artifact-cards")).toHaveCount(0); + await expect(replay.getByTestId("chat-step-artifact-cards")).toHaveCount(0); + }); + + test("changing the speed mid-run keeps the same frame and the same cards", async ({ + page, + }) => { + const { replay, controls } = await startPausedReplay(page); + await scrubTo(controls, R(15_500)); + await expect(replay.getByTestId("chat-step-artifact-cards")).toHaveCount(5); + await expect(controls.getByTestId("chat-replay-speed-5")).toHaveAttribute( + "aria-pressed", + "true", + ); + await controls.getByTestId("chat-replay-speed-10").click(); + await expect(controls.getByTestId("chat-replay-speed-10")).toHaveAttribute( + "aria-pressed", + "true", + ); + // Same instant of the original run: the five cards stay, nothing + // pending, and the clock reads half the elapsed. + await expect(replay.getByTestId("chat-step-artifact-cards")).toHaveCount(5); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(5); + await expect(replay.getByTestId("chat-artifact-card-pending")).toHaveCount(0); + await expect( + controls.getByRole("slider", { name: "Replay position" }), + ).toHaveValue(String(Math.round(15_500 / 10 / 100) * 100)); + await controls.getByTestId("chat-replay-speed-2").click(); + await expect(replay.getByTestId("chat-step-artifact-cards")).toHaveCount(5); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(5); + // Still paused: a speed change never starts playback. + await expect( + controls.getByRole("button", { name: "Play replay" }), + ).toBeVisible(); + }); + + test("scrubbing to the end renders the final answer at once, with no caret", async ({ + page, + }) => { + const { replay, controls } = await startPausedReplay(page); + await scrubTo(controls, REPLAY_END_MS); + // The last streamed sentence is present on the very frame, not typed in. + await expect(replay).toContainText("so they are unaffected", { + timeout: 1_000, + }); + await expect(replay.locator('[data-caret="true"]')).toHaveCount(0); + await expect(replay.getByTestId("chat-live-indicator")).toHaveCount(0); + // And stays that way: no smoothing drains text after the fact. + await page.waitForTimeout(600); + await expect(replay.locator('[data-caret="true"]')).toHaveCount(0); + await expect(replay).toContainText("so they are unaffected"); + }); + + test("a later turn's user message appears only once its anchor is reached", async ({ + page, + }) => { + // ?followup=1 adds a second turn a minute after the first run ended; + // the replay collapses that pause to the 10s cap. + const { replay, controls } = await startPausedReplay(page, "&followup=1"); + const followUp = replay.getByText("Project Q4 from these growth rates."); + await scrubTo(controls, REPLAY_END_MS); + await expect(replay).toContainText("so they are unaffected"); + await expect(followUp).toHaveCount(0); + // Past the capped pause: the second turn is on screen. + await scrubTo(controls, REPLAY_END_MS + 10_000); + await expect(followUp).toBeVisible(); + }); + + test("a replayed card still opens the artifacts panel on its file", async ({ + page, + }) => { + const { replay, controls } = await startPausedReplay(page); + await scrubTo(controls, R(15_500)); + await replay + .getByTestId("chat-artifact-card") + .filter({ hasText: "q3_summary.md" }) + .getByTestId("chat-artifact-card-primary") + .click(); + await expect(page.getByTestId("live-notebook-panel")).toBeVisible(); + await expect(page.getByTestId("artifacts-tab-files")).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByTestId("chat-file-stub")).toBeVisible(); + }); + + test("while playing, the first ready card pops the artifacts panel open on its file", async ({ + page, + }) => { + await page.goto(at(FIXTURE_END_MS)); + await waitForHydration(page); + // At the end frame the notebook has already ended, so the harness did + // not auto-open the panel; close it if it is open so the pop-up is + // observable. + const close = page.getByTestId("live-notebook-close"); + if (await close.count()) await close.click(); + await expect(page.getByTestId("live-notebook-panel")).toHaveCount(0); + await page.getByTestId("chat-replay-button").click(); + const replay = page.getByTestId("chat-replay"); + await expect(replay).toBeVisible(); + // The first file (q3_growth.py, 9.2s original) is ready ~1.8s in. + await expect(page.getByTestId("live-notebook-panel")).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByTestId("artifacts-tab-files")).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByTestId("chat-file-stub")).toBeVisible(); + await expect( + replay.getByTestId("chat-artifact-card").first(), + ).toContainText("q3_growth.py"); + // Exit returns the live transcript. + await replay.getByRole("button", { name: "Exit replay" }).click(); + await expect(page.getByTestId("chat-replay")).toHaveCount(0); + await expect(page.getByTestId("standalone-chat-messages")).toBeVisible(); + }); +}); diff --git a/signalpilot/web/lib/api/chat-files.ts b/signalpilot/web/lib/api/chat-files.ts index a2c284676..4ce721bd4 100644 --- a/signalpilot/web/lib/api/chat-files.ts +++ b/signalpilot/web/lib/api/chat-files.ts @@ -132,6 +132,11 @@ export const getSharedConversationFiles = (token: string) => `/api/chat/shared/${encodeURIComponent(token)}/files`, ); +export const getSharedConversationSqlTrace = (token: string) => + request<{ executions: SqlTraceExecution[] }>( + `/api/chat/shared/${encodeURIComponent(token)}/sql-trace`, + ); + async function fetchSharedConversationFileContent( token: string, fileId: string, @@ -149,6 +154,15 @@ async function fetchSharedConversationFileContent( return response; } +/** Text content of a shared file. Use for markdown, code, html, and data. */ +export async function getSharedConversationFileText( + token: string, + fileId: string, +): Promise { + const response = await fetchSharedConversationFileContent(token, fileId); + return response.text(); +} + /** Object URL for a shared file's bytes. The caller revokes it. */ export async function getSharedConversationFileObjectUrl( token: string, diff --git a/signalpilot/web/lib/api/chat-results.ts b/signalpilot/web/lib/api/chat-results.ts index c605f1eb9..95fd8bca7 100644 --- a/signalpilot/web/lib/api/chat-results.ts +++ b/signalpilot/web/lib/api/chat-results.ts @@ -33,3 +33,18 @@ export const getConversationToolResult = ( `/api/chat/conversations/${encodeURIComponent(conversationId)}/results/${encodeURIComponent(resultId)}${query ? `?${query}` : ""}`, ); }; + +/** The same page for a shared chat, scoped by the share grant. */ +export const getSharedToolResult = ( + token: string, + resultId: string, + opts: { offset?: number; limit?: number } = {}, +) => { + const params = new URLSearchParams(); + if (opts.offset !== undefined) params.set("offset", String(opts.offset)); + if (opts.limit !== undefined) params.set("limit", String(opts.limit)); + const query = params.toString(); + return request( + `/api/chat/shared/${encodeURIComponent(token)}/results/${encodeURIComponent(resultId)}${query ? `?${query}` : ""}`, + ); +}; diff --git a/signalpilot/web/lib/api/standalone-chat.ts b/signalpilot/web/lib/api/standalone-chat.ts index 4b89d7a4b..9f39800cb 100644 --- a/signalpilot/web/lib/api/standalone-chat.ts +++ b/signalpilot/web/lib/api/standalone-chat.ts @@ -1,5 +1,6 @@ // Chat traces and the standalone data chat. +import type { ConversationFileInfo } from "./chat-files"; import { GATEWAY_URL, getAuthHeaders, request } from "./client"; // The following functions support chat traces on the /chats page. @@ -200,26 +201,31 @@ export type StandaloneConversationDetail = { run_events: StandaloneChatEvent[]; }; -export type SharedConversationDetail = { - conversation: { - title: string; - project_name: string | null; - created_at: number; - updated_at: number; - /** How the conversation was started; "improvement" means an automated improvement run. */ - origin?: string; - }; - messages: Array>; - shared_at: string; +/** Share-safe header of a shared chat: no owner ids, budgets, or spend. */ +export type SharedConversation = { + title: string; + project_name: string | null; + /** How the conversation was started; "improvement" means an automated improvement run. */ + origin: string; + model: StandaloneChatModel; + effort: StandaloneChatEffort; + commit_sha: string | null; + branch: string; + created_at: number; + updated_at: number; }; -export type StandaloneForkPreview = { - project_id: string; - project_name: string; - commit_sha: string; - per_query_budget_usd: number; - chat_budget_usd: number; - warehouse_cost_notice: string; +/** + * Read-only snapshot of a shared chat. Messages and events have the owner + * shapes so the shared page renders through the live chat components; + * `files` is the share-safe manifest. Only finished runs are included. + */ +export type SharedConversationDetail = { + conversation: SharedConversation; + messages: StandaloneChatMessage[]; + run_events: StandaloneChatEvent[]; + files: ConversationFileInfo[]; + shared_at: string; }; export const getStandaloneChatBootstrap = () => @@ -334,25 +340,12 @@ export const getSharedStandaloneConversation = (token: string) => request( `/api/chat/shared/${encodeURIComponent(token)}`, ); -export const getSharedStandaloneForkPreview = (token: string) => - request( - `/api/chat/shared/${encodeURIComponent(token)}/fork-preview`, - ); -export const forkSharedStandaloneConversation = ( - token: string, - perQueryBudgetUsd: number, - chatBudgetUsd: number, -) => +/** Copy the whole shared chat into the caller's chats. Budgets come from + * the caller's saved defaults; there is nothing to confirm. */ +export const forkSharedStandaloneConversation = (token: string) => request<{ id: string }>( `/api/chat/shared/${encodeURIComponent(token)}/fork`, - { - method: "POST", - body: JSON.stringify({ - confirmed: true, - per_query_budget_usd: perQueryBudgetUsd, - chat_budget_usd: chatBudgetUsd, - }), - }, + { method: "POST", body: JSON.stringify({}) }, ); export const createStandaloneRun = ( conversationId: string, diff --git a/signalpilot/web/lib/chat-artifact-cards.ts b/signalpilot/web/lib/chat-artifact-cards.ts index 00bbde2d8..3889e3429 100644 --- a/signalpilot/web/lib/chat-artifact-cards.ts +++ b/signalpilot/web/lib/chat-artifact-cards.ts @@ -110,7 +110,7 @@ type PathTouch = { }; /** Paths a tool_started event touches: one for Write/Edit tools. */ -function toolTouchPaths(event: StandaloneChatEvent): string[] { +export function toolTouchPaths(event: StandaloneChatEvent): string[] { const tool = text(event.payload.tool); if (!tool || (!WRITE_TOOLS.has(tool) && !EDIT_TOOLS.has(tool))) return []; const input = @@ -139,6 +139,23 @@ function filesChangedTouchPaths(event: StandaloneChatEvent): string[] { return paths; } +/** Every path a `files_changed` event names, in either payload shape: the + * runtime capture (`files: [{path}]`, deleted entries skipped) or the legacy + * content-free mirror (`changed: [path]`). Unlike `filesChangedTouchPaths` + * this carries no anchor semantics; it answers "did the mirror confirm this + * path yet", which is what the replay needs to reveal a manifest row. */ +export function filesChangedNamedPaths(event: StandaloneChatEvent): string[] { + const paths = filesChangedTouchPaths(event); + const changed = event.payload.changed; + if (Array.isArray(changed)) { + for (const entry of changed) { + const path = text(entry); + if (path) paths.push(path); + } + } + return paths; +} + function collectTouches( events: StandaloneChatEvent[], runId: string, diff --git a/signalpilot/web/lib/chat-replay.test.ts b/signalpilot/web/lib/chat-replay.test.ts index 3d158c77c..c409ad265 100644 --- a/signalpilot/web/lib/chat-replay.test.ts +++ b/signalpilot/web/lib/chat-replay.test.ts @@ -1,52 +1,101 @@ -import { describe, expect, it } from "vitest"; -import type { StandaloneChatEvent } from "~/lib/api"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConversationFileInfo, StandaloneChatEvent } from "~/lib/api"; +import type { UiMessage } from "~/components/chat/chat-ui-context"; import { buildReplaySchedule, + canReplayConversation, + DEFAULT_REPLAY_SPEED, + deriveReplayFrame, REPLAY_MAX_GAP_MS, - REPLAY_SPEED, + remapReplayElapsed, + replayInstantFor, replayOffsetFor, + replayVisibleFiles, + useReplayArtifactAutoOpen, } from "~/lib/chat-replay"; const BASE = Date.UTC(2026, 0, 1, 12, 0, 0); +const SPEED = DEFAULT_REPLAY_SPEED; function event( sequence: number, offsetMs: number, type: StandaloneChatEvent["type"] = "progress", runId = "run-1", + payload: StandaloneChatEvent["payload"] = {}, ): StandaloneChatEvent { return { run_id: runId, sequence, type, - payload: {}, + payload, created_at: new Date(BASE + offsetMs).toISOString(), }; } +function user(id: string, offsetMs: number, content = "q"): UiMessage { + return { + id, + role: "user", + content, + sequence: 0, + created_at: (BASE + offsetMs) / 1_000, + metadata: {}, + }; +} + +function assistant( + id: string, + runId: string, + offsetMs: number, + content = "answer", + runStatus: UiMessage["runStatus"] = "completed", +): UiMessage { + return { + id, + role: "assistant", + content, + sequence: 0, + created_at: (BASE + offsetMs) / 1_000, + metadata: { run_id: runId }, + runId, + runStatus, + }; +} + +const eventsOnly = (events: StandaloneChatEvent[]) => ({ messages: [], events }); + describe("buildReplaySchedule", () => { - it("scales gaps by 4x and preserves order", () => { + it("scales gaps by the speed and preserves order", () => { const schedule = buildReplaySchedule( - [event(1, 0), event(2, 2_000), event(3, 10_000)], - "run-1", + eventsOnly([event(1, 0), event(2, 2_000), event(3, 10_000)]), + SPEED, ); expect(schedule.items.map((item) => item.at)).toEqual([ 0, - 2_000 / REPLAY_SPEED, - 2_000 / REPLAY_SPEED + 8_000 / REPLAY_SPEED, + 2_000 / SPEED, + 2_000 / SPEED + 8_000 / SPEED, ]); + expect(schedule.speed).toBe(SPEED); }); - it("caps any single wait at 10 seconds", () => { - // A 15-minute tool call still replays in 10s. - const schedule = buildReplaySchedule( - [event(1, 0, "tool_started"), event(2, 15 * 60_000, "tool_completed")], - "run-1", - ); - expect(schedule.items[1].at).toBe(REPLAY_MAX_GAP_MS); + it("caps any single wait at 10 seconds regardless of speed", () => { + // A 15-minute tool call still replays in 10s, at 2x and at 10x. + for (const speed of [2, 10]) { + const schedule = buildReplaySchedule( + eventsOnly([ + event(1, 0, "tool_started"), + event(2, 15 * 60_000, "tool_completed"), + ]), + speed, + ); + expect(schedule.items[1].at).toBe(REPLAY_MAX_GAP_MS); + } }); - it("ignores events from other runs and unparsable timestamps fall back", () => { + it("unparsable timestamps fall back to a small gap", () => { const broken: StandaloneChatEvent = { run_id: "run-1", sequence: 2, @@ -55,48 +104,395 @@ describe("buildReplaySchedule", () => { created_at: "not-a-date", }; const schedule = buildReplaySchedule( - [event(1, 0), broken, event(3, 1_000), event(4, 0, "progress", "other")], - "run-1", + eventsOnly([event(1, 0), broken, event(3, 1_000)]), + SPEED, ); expect(schedule.items).toHaveLength(3); - // Broken timestamp uses the small fallback gap; the next parsable event - // resumes scaling from the last good anchor. expect(schedule.items[1].at).toBeGreaterThan(0); - expect(schedule.items[2].at).toBeGreaterThanOrEqual( - 1_000 / REPLAY_SPEED, - ); + expect(schedule.items[2].at).toBeGreaterThanOrEqual(1_000 / SPEED); + }); + + it("orders a conversation as it happened: user, run, user, run", () => { + const messages = [ + user("u1", 0), + assistant("a1", "run-1", 5_000), + // Two hours later. + user("u2", 7_200_000), + assistant("a2", "run-2", 7_205_000), + ]; + const events = [ + event(1, 1_000, "progress", "run-1"), + event(2, 4_000, "text_delta", "run-1"), + event(1, 7_201_000, "progress", "run-2"), + event(2, 7_204_000, "text_delta", "run-2"), + ]; + const schedule = buildReplaySchedule({ messages, events }, SPEED); + expect( + schedule.items.map((item) => item.message?.id ?? item.event?.run_id), + ).toEqual(["u1", "run-1", "run-1", "u2", "run-2", "run-2"]); + // The pause between turns collapses to the cap. + expect(schedule.items[3].at - schedule.items[2].at).toBe(REPLAY_MAX_GAP_MS); + expect(schedule.items[1].at).toBe(1_000 / SPEED); + // Assistant messages with events are represented by their run, not + // scheduled themselves. + expect(schedule.items.some((item) => item.message?.id === "a1")).toBe(false); }); - it("a compressed 15-minute run fits in a demo window", () => { - // 30 events spread evenly across 15 minutes: each 31s gap → capped well - // below 10s each, so total is under ~4 minutes. - const events = Array.from({ length: 30 }, (_, index) => - event(index + 1, index * 31_000), + it("schedules an assistant message with no events on its own timestamp", () => { + const messages = [user("u1", 0), assistant("a1", "run-1", 3_000)]; + const schedule = buildReplaySchedule({ messages, events: [] }, SPEED); + expect(schedule.items.map((item) => item.message?.id)).toEqual(["u1", "a1"]); + expect(schedule.items[1].at).toBe(3_000 / SPEED); + }); + + it("keeps events of runs no message claims", () => { + const schedule = buildReplaySchedule( + { messages: [user("u1", 0)], events: [event(1, 500, "progress", "orphan")] }, + SPEED, ); - const schedule = buildReplaySchedule(events, "run-1"); - expect(schedule.totalMs).toBeLessThan(4 * 60_000); - expect(schedule.totalMs).toBeGreaterThan(60_000); + expect(schedule.items).toHaveLength(2); }); }); -describe("replayOffsetFor", () => { +describe("canReplayConversation", () => { + it("needs at least one non-status event", () => { + expect(canReplayConversation([])).toBe(false); + expect(canReplayConversation([event(1, 0, "status")])).toBe(false); + expect(canReplayConversation([event(1, 0, "status"), event(2, 1, "progress")])).toBe(true); + }); +}); + +describe("replayOffsetFor / replayInstantFor", () => { const schedule = buildReplaySchedule( - [event(1, 0), event(2, 60_000), event(3, 62_000)], - "run-1", + eventsOnly([event(1, 0), event(2, 60_000), event(3, 62_000)]), + SPEED, ); it("maps instants between events onto the compressed clock", () => { - // First gap is capped at 10s; an artifact created 30s in maps inside it. expect(replayOffsetFor(schedule, BASE)).toBe(0); expect(replayOffsetFor(schedule, BASE + 61_000)).toBe( - REPLAY_MAX_GAP_MS + 1_000 / REPLAY_SPEED, + REPLAY_MAX_GAP_MS + 1_000 / SPEED, ); + expect(replayOffsetFor(schedule, BASE - 5_000)).toBe(0); + expect(replayOffsetFor(schedule, BASE + 10 * 60_000)).toBe(schedule.totalMs); }); - it("clamps before the run and after its end", () => { - expect(replayOffsetFor(schedule, BASE - 5_000)).toBe(0); - expect(replayOffsetFor(schedule, BASE + 10 * 60_000)).toBe( - schedule.totalMs, + it("returns the original instant at every anchor (inverse of replayOffsetFor)", () => { + for (const anchor of schedule.anchors) { + expect(replayInstantFor(schedule, anchor.at)).toBe(anchor.originalMs); + expect(replayOffsetFor(schedule, anchor.originalMs)).toBe(anchor.at); + } + }); + + it("interpolates at the schedule speed between anchors", () => { + const secondAt = schedule.anchors[1].at; + expect(replayInstantFor(schedule, secondAt + 200)).toBe( + BASE + 60_000 + 200 * SPEED, + ); + expect(replayOffsetFor(schedule, BASE + 61_000)).toBe(secondAt + 200); + }); + + it("never overtakes the next anchor inside a capped wait", () => { + expect(replayInstantFor(schedule, 9_900)).toBe(BASE + 9_900 * SPEED); + expect(replayInstantFor(schedule, REPLAY_MAX_GAP_MS)).toBe(BASE + 60_000); + }); + + it("clamps before the start and runs through the tail after the last event", () => { + expect(replayInstantFor(schedule, -500)).toBe(BASE); + const lastAt = schedule.anchors[2].at; + expect(replayInstantFor(schedule, lastAt + 100)).toBe( + BASE + 62_000 + 100 * SPEED, + ); + expect(replayInstantFor(schedule, schedule.totalMs + 5_000)).toBe( + BASE + 62_000 + (schedule.totalMs - lastAt) * SPEED, + ); + }); + + it("is null when nothing has a parsable timestamp", () => { + const schedule = buildReplaySchedule( + eventsOnly([{ ...event(1, 0), created_at: "nope" }]), + SPEED, ); + expect(replayInstantFor(schedule, 0)).toBeNull(); + }); +}); + +describe("remapReplayElapsed", () => { + const source = eventsOnly([ + event(1, 0), + event(2, 4_000, "tool_started"), + event(3, 8_000, "tool_completed"), + event(4, 12_000, "text_delta"), + ]); + const at5 = buildReplaySchedule(source, 5); + const at10 = buildReplaySchedule(source, 10); + const at2 = buildReplaySchedule(source, 2); + + it("keeps the same original instant when the speed changes", () => { + // 6s into the run at 5x = 1.2s of replay; at 10x the same instant is 0.6s. + expect(replayInstantFor(at5, 1_200)).toBe(BASE + 6_000); + expect(remapReplayElapsed(at5, at10, 1_200)).toBe(600); + expect(replayInstantFor(at10, 600)).toBe(BASE + 6_000); + // And back out to 2x: 3s of replay. + expect(remapReplayElapsed(at10, at2, 600)).toBe(3_000); + expect(replayInstantFor(at2, 3_000)).toBe(BASE + 6_000); + }); + + it("keeps the same visible items across a speed change", () => { + const visibleAt = (schedule: typeof at5, elapsed: number) => + schedule.items.filter((item) => item.at <= elapsed).length; + for (const elapsed of [0, 700, 1_650, 2_400]) { + expect(visibleAt(at10, remapReplayElapsed(at5, at10, elapsed))).toBe( + visibleAt(at5, elapsed), + ); + } + }); + + it("maps the end to the end", () => { + expect(remapReplayElapsed(at5, at10, at5.totalMs)).toBe(at10.totalMs); + expect(remapReplayElapsed(at5, at10, 0)).toBe(0); + }); +}); + +describe("deriveReplayFrame", () => { + const messages = [ + user("u1", 0, "first question"), + assistant("a1", "run-1", 5_000, "persisted answer"), + user("u2", 60_000, "second question"), + assistant("a2", "run-2", 65_000, "final answer"), + assistant("a3", "run-3", 70_000, "no events"), + ]; + const events = [ + event(1, 1_000, "progress", "run-1"), + event(2, 4_000, "progress", "run-1"), + event(1, 61_000, "progress", "run-2"), + event(2, 64_000, "text_delta", "run-2", { delta: "final" }), + ]; + const schedule = buildReplaySchedule({ messages, events }, SPEED); + const ids = (elapsed: number) => + deriveReplayFrame(schedule, elapsed).messages.map((m) => m.id); + const status = (elapsed: number, id: string) => + deriveReplayFrame(schedule, elapsed).messages.find((m) => m.id === id)?.runStatus; + + it("shows a user message at its anchor and its answer as queued right after", () => { + expect(ids(0)).toEqual(["u1", "a1"]); + expect(status(0, "a1")).toBe("queued"); + }); + + it("runs a turn while its events stream and settles to the real status", () => { + const first = schedule.items[1].at; + expect(status(first, "a1")).toBe("running"); + expect(deriveReplayFrame(schedule, first).finishedRunIds.has("run-1")).toBe(false); + const last = schedule.items[2].at; + expect(status(last, "a1")).toBe("completed"); + expect(deriveReplayFrame(schedule, last).finishedRunIds.has("run-1")).toBe(true); + }); + + it("reveals persisted content on completion only for runs that did not stream text", () => { + const first = schedule.items[1].at; + const done1 = schedule.items[2].at; + const content = (elapsed: number, id: string) => + deriveReplayFrame(schedule, elapsed).messages.find((m) => m.id === id)?.content; + expect(content(first, "a1")).toBe(""); + expect(content(done1, "a1")).toBe("persisted answer"); + // run-2 streamed deltas: the blocks rebuild it, the message stays empty. + expect(content(schedule.totalMs, "a2")).toBe(""); + }); + + it("hides a later turn until its anchor, then shows it", () => { + const u2At = schedule.items.find((item) => item.message?.id === "u2")!.at; + expect(ids(u2At - 1)).toEqual(["u1", "a1"]); + expect(ids(u2At)).toEqual(["u1", "a1", "u2", "a2"]); + expect(status(u2At, "a2")).toBe("queued"); + }); + + it("shows an eventless assistant message at its own anchor, as is", () => { + const a3At = schedule.items.find((item) => item.message?.id === "a3")!.at; + expect(ids(a3At - 1)).not.toContain("a3"); + const frame = deriveReplayFrame(schedule, a3At); + expect(frame.messages.find((m) => m.id === "a3")?.content).toBe("no events"); + expect(frame.runIds.has("run-3")).toBe(false); + }); +}); + +const fileRow = (path: string, runId = "run-1"): ConversationFileInfo => ({ + id: `id-${path}`, + path, + filename: path.split("/").pop() ?? path, + kind: "data", + mime_type: "text/csv", + byte_size: 10, + content_hash: "h1", + origin_run_id: runId, + origin: "runtime", + status: "active", + created_at: new Date(BASE).toISOString(), + updated_at: new Date(BASE).toISOString(), +}); + +describe("replayVisibleFiles", () => { + const writeReport = event(1, 0, "tool_started", "run-1", { + tool: "Write", + tool_call_id: "c1", + input: { file_path: "/workspace/exports/report.html", content: "x" }, + }); + const mirrorReport = event(2, 900, "files_changed", "run-1", { + changed: ["exports/report.html"], + deleted: [], + }); + const writeNotes = event(3, 2_000, "tool_started", "run-1", { + tool: "Edit", + tool_call_id: "c2", + input: { file_path: "notes.md", old_string: "a", new_string: "b" }, + }); + const runCells = event(4, 3_000, "tool_started", "run-1", { + tool: "mcp__standalone-chat__run_cells", + tool_call_id: "c3", + input: { cells: [] }, + }); + const captureChart = event(5, 4_000, "files_changed", "run-1", { + changed: 1, + files: [{ path: "artifacts/chart.png", deleted: false }], + tool_call_id: "c3", + }); + const done = event(6, 5_000, "status", "run-1", { status: "completed" }); + const otherRun = event(1, 9_000, "progress", "run-2"); + const events = [ + writeReport, + mirrorReport, + writeNotes, + runCells, + captureChart, + done, + otherRun, + ]; + const files = [ + fileRow("exports/report.html"), + fileRow("notes.md"), + fileRow("artifacts/chart.png"), + fileRow("sweep.csv"), + fileRow("later.csv", "run-2"), + fileRow("earlier.csv", "run-0"), + ]; + const visible = (visibleEvents: StandaloneChatEvent[]) => + replayVisibleFiles(files, { events, visibleEvents }).map((file) => file.path); + + it("shows only files of runs the replay does not cover before anything is touched", () => { + expect(visible([])).toEqual(["earlier.csv"]); + }); + + it("keeps a mirrored file hidden through its pending window, then reveals it", () => { + expect(visible([writeReport])).toEqual(["earlier.csv"]); + expect(visible([writeReport, mirrorReport])).toEqual([ + "exports/report.html", + "earlier.csv", + ]); + }); + + it("reveals a file no capture ever confirms at its Write/Edit step", () => { + expect(visible([writeReport, mirrorReport, writeNotes])).toContain("notes.md"); + }); + + it("reveals a runtime capture at its files_changed, not at the tool call", () => { + expect(visible([writeReport, mirrorReport, writeNotes, runCells])).not.toContain( + "artifacts/chart.png", + ); + expect(visible([writeReport, mirrorReport, writeNotes, runCells, captureChart])).toContain( + "artifacts/chart.png", + ); + }); + + it("holds untouched files (the run-end sweep) until the run's last event", () => { + const beforeDone = [writeReport, mirrorReport, writeNotes, runCells, captureChart]; + expect(visible(beforeDone)).not.toContain("sweep.csv"); + expect(visible([...beforeDone, done])).toContain("sweep.csv"); + // The second run has not started: its file stays hidden. + expect(visible([...beforeDone, done])).not.toContain("later.csv"); + expect(visible(events)).toEqual(files.map((file) => file.path)); + }); + + it("hides a file again when the scrub moves before its touch", () => { + expect(visible(events)).toContain("notes.md"); + expect(visible([writeReport, mirrorReport])).not.toContain("notes.md"); + }); +}); + +describe("useReplayArtifactAutoOpen", () => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + let container: HTMLDivElement; + let root: Root; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const runIds = new Set(["run-1"]); + function Probe(props: { + files: ConversationFileInfo[]; + playing: boolean; + session: number; + openArtifact: (fileId: string) => void; + }) { + useReplayArtifactAutoOpen({ ...props, runIds }); + return null; + } + const render = async ( + files: ConversationFileInfo[], + playing: boolean, + session: number, + openArtifact: (fileId: string) => void, + ) => { + await act(async () => { + root.render(createElement(Probe, { files, playing, session, openArtifact })); + }); + }; + + it("opens each covered file once as it becomes ready while playing", async () => { + const open = vi.fn(); + const a = fileRow("a.csv"); + const b = fileRow("b.csv"); + const other = fileRow("z.csv", "run-0"); + await render([other], true, 0, open); + expect(open).not.toHaveBeenCalled(); + await render([other, a], true, 0, open); + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith("id-a.csv"); + await render([other, a], true, 0, open); + await render([other, a, b], true, 0, open); + expect(open).toHaveBeenCalledTimes(2); + expect(open).toHaveBeenLastCalledWith("id-b.csv"); + }); + + it("absorbs the opening frame and files that appear while paused or scrubbed", async () => { + const open = vi.fn(); + const a = fileRow("a.csv"); + // Mounted with a file already visible (e.g. a pass-through row): no pop. + await render([a], true, 0, open); + expect(open).not.toHaveBeenCalled(); + const b = fileRow("b.csv"); + await render([a, b], false, 0, open); + await render([a, b], true, 0, open); + expect(open).not.toHaveBeenCalled(); + await render([], false, 0, open); + await render([a, b], true, 0, open); + expect(open).not.toHaveBeenCalled(); + }); + + it("forgets everything on restart so the next session pops up again", async () => { + const open = vi.fn(); + const a = fileRow("a.csv"); + await render([], true, 0, open); + await render([a], true, 0, open); + expect(open).toHaveBeenCalledTimes(1); + await render([], true, 1, open); + await render([a], true, 1, open); + expect(open).toHaveBeenCalledTimes(2); }); }); diff --git a/signalpilot/web/lib/chat-replay.ts b/signalpilot/web/lib/chat-replay.ts index a7bd51dd3..03d616046 100644 --- a/signalpilot/web/lib/chat-replay.ts +++ b/signalpilot/web/lib/chat-replay.ts @@ -1,66 +1,140 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import type { StandaloneChatEvent } from "~/lib/api"; +import type { + ConversationFileInfo, + StandaloneChatEvent, + StandaloneChatRunStatus, +} from "~/lib/api"; +import type { UiMessage } from "~/components/chat/chat-ui-context"; +import { + filesChangedNamedPaths, + pathsMatch, + toolTouchPaths, +} from "~/lib/chat-artifact-cards"; /** - * Replay of a recorded run: events are rescheduled onto a compressed clock - * that preserves the original rhythm. Every gap between events plays 4x - * faster, and no single wait (a tool call, a long think) exceeds 10 seconds, - * so a 15-minute run demos in a couple of minutes without losing its shape. + * Replay of a whole conversation: every run's events and the user messages + * are rescheduled onto one compressed clock that preserves the original + * rhythm. Gaps play `speed` times faster and no single wait (a tool call, + * a long think, the hours between two turns) exceeds REPLAY_MAX_GAP_MS of + * replay time regardless of speed — the cap bounds the wait the viewer + * sits through, so a faster speed only shortens the gaps under the cap. */ -export const REPLAY_SPEED = 4; +export const REPLAY_SPEEDS = [2, 5, 10] as const; +export type ReplaySpeed = (typeof REPLAY_SPEEDS)[number]; +export const DEFAULT_REPLAY_SPEED: ReplaySpeed = 5; export const REPLAY_MAX_GAP_MS = 10_000; -/** Gap assumed when an event has an unparsable timestamp (already scaled). */ +/** Gap assumed when an item has an unparsable timestamp (already scaled). */ const FALLBACK_GAP_MS = 150; -/** Breathing room after the last event before the replay reports finished. */ +/** Breathing room after the last item before the replay reports finished. */ const TAIL_MS = 400; -export type ScheduledReplayEvent = { - event: StandaloneChatEvent; - /** Milliseconds from replay start at which this event becomes visible. */ +export type ReplaySource = { + messages: UiMessage[]; + events: StandaloneChatEvent[]; +}; + +export type ScheduledReplayItem = { + /** Milliseconds from replay start at which this item becomes visible. */ at: number; + event?: StandaloneChatEvent; + message?: UiMessage; }; export type ReplaySchedule = { - items: ScheduledReplayEvent[]; + items: ScheduledReplayItem[]; totalMs: number; /** Piecewise map from original epoch-ms to replay offsets. */ anchors: { originalMs: number; at: number }[]; + speed: number; + messages: UiMessage[]; }; +export function replayRunId(message: UiMessage): string { + return ( + message.runId ?? + (typeof message.metadata.run_id === "string" ? message.metadata.run_id : "") + ); +} + +/** A conversation is replayable once some run recorded a real event. */ +export function canReplayConversation(events: StandaloneChatEvent[]): boolean { + return events.some((event) => event.type !== "status"); +} + +function messageOriginalMs(message: UiMessage): number { + return message.created_at > 0 ? message.created_at * 1_000 : NaN; +} + +/** + * The items in the order they happened: each user message, then the + * events of the run that answered it (assistant messages whose run has no + * events stand in for themselves); runs no message claims trail in + * first-seen order so nothing recorded is lost. + */ +function replayItemOrder( + source: ReplaySource, +): { originalMs: number; event?: StandaloneChatEvent; message?: UiMessage }[] { + const byRun = new Map(); + for (const event of source.events) { + const list = byRun.get(event.run_id); + if (list) list.push(event); + else byRun.set(event.run_id, [event]); + } + for (const list of byRun.values()) list.sort((a, b) => a.sequence - b.sequence); + const eventItem = (event: StandaloneChatEvent) => ({ + originalMs: Date.parse(event.created_at), + event, + }); + const items: ReturnType = []; + const consumed = new Set(); + for (const message of source.messages) { + const runEvents = + message.role === "assistant" ? byRun.get(replayRunId(message)) : undefined; + if (runEvents?.length) { + consumed.add(replayRunId(message)); + items.push(...runEvents.map(eventItem)); + } else { + items.push({ originalMs: messageOriginalMs(message), message }); + } + } + for (const [runId, runEvents] of byRun) { + if (!consumed.has(runId)) items.push(...runEvents.map(eventItem)); + } + return items; +} + export function buildReplaySchedule( - events: StandaloneChatEvent[], - runId: string, + source: ReplaySource, + speed: number = DEFAULT_REPLAY_SPEED, ): ReplaySchedule { - const runEvents = events - .filter((event) => event.run_id === runId) - .sort((a, b) => a.sequence - b.sequence); - const items: ScheduledReplayEvent[] = []; + const items: ScheduledReplayItem[] = []; const anchors: ReplaySchedule["anchors"] = []; let at = 0; let previousMs: number | null = null; - for (const event of runEvents) { - const originalMs = Date.parse(event.created_at); + for (const { originalMs, event, message } of replayItemOrder(source)) { if (previousMs != null) { const gap = Number.isFinite(originalMs) ? Math.max(0, originalMs - previousMs) : NaN; at += Number.isFinite(gap) - ? Math.min(gap / REPLAY_SPEED, REPLAY_MAX_GAP_MS) + ? Math.min(gap / speed, REPLAY_MAX_GAP_MS) : FALLBACK_GAP_MS; } if (Number.isFinite(originalMs)) { anchors.push({ originalMs, at }); previousMs = originalMs; } - items.push({ event, at }); + items.push({ at, event, message }); } return { items, totalMs: items.length ? items[items.length - 1].at + TAIL_MS : 0, anchors, + speed, + messages: source.messages, }; } @@ -69,7 +143,7 @@ export function replayOffsetFor( schedule: ReplaySchedule, originalMs: number, ): number { - const { anchors, totalMs } = schedule; + const { anchors, totalMs, speed } = schedule; if (!anchors.length || !Number.isFinite(originalMs)) return totalMs; if (originalMs <= anchors[0].originalMs) return 0; let last = anchors[0]; @@ -79,17 +153,214 @@ export function replayOffsetFor( } return Math.min( last.at + - Math.min((originalMs - last.originalMs) / REPLAY_SPEED, REPLAY_MAX_GAP_MS), + Math.min((originalMs - last.originalMs) / speed, REPLAY_MAX_GAP_MS), totalMs, ); } -export type ChatReplayState = { +/** + * Inverse of `replayOffsetFor`: the original wall-clock instant (epoch ms) + * the replay is showing at `elapsed`. Between anchors the clock advances at + * the schedule's speed from the last anchor and never overtakes the next + * one (a capped gap fast-forwards to it); past the last item it runs on + * through the tail. Null when nothing carries a parsable timestamp. + */ +export function replayInstantFor( + schedule: ReplaySchedule, + elapsed: number, +): number | null { + const { anchors, totalMs, speed } = schedule; + if (!anchors.length) return null; + const clamped = Math.min(Math.max(elapsed, 0), totalMs); + let last = anchors[0]; + let next: ReplaySchedule["anchors"][number] | null = null; + for (const anchor of anchors) { + if (anchor.at <= clamped) { + last = anchor; + } else { + next = anchor; + break; + } + } + const projected = Math.max( + last.originalMs, + last.originalMs + (clamped - last.at) * speed, + ); + return next ? Math.min(projected, next.originalMs) : projected; +} + +/** + * The elapsed offset on `to` that shows the same original instant `from` + * shows at `elapsed` — how a speed change keeps its place in the run. + */ +export function remapReplayElapsed( + from: ReplaySchedule, + to: ReplaySchedule, + elapsed: number, +): number { + if (from.totalMs > 0 && elapsed >= from.totalMs) return to.totalMs; + const instant = replayInstantFor(from, elapsed); + if (instant === null) { + return from.totalMs > 0 ? (elapsed / from.totalMs) * to.totalMs : 0; + } + return replayOffsetFor(to, instant); +} + +export type ReplayFrame = { + visibleEvents: StandaloneChatEvent[]; + /** The transcript at this instant: message status and content follow + * the frame, so each turn renders as it did live. */ + messages: UiMessage[]; + /** Runs the replay covers (those with events in the schedule). */ + runIds: Set; + /** Runs whose every event is visible. */ + finishedRunIds: Set; +}; + +function terminalStatus(message: UiMessage): StandaloneChatRunStatus { + return ( + message.runStatus ?? + (typeof message.metadata.status === "string" + ? (message.metadata.status as StandaloneChatRunStatus) + : "completed") + ); +} + +/** + * What the transcript shows at `elapsed`. A user message appears at its + * anchor. An assistant turn appears as soon as the message before it is + * visible ("queued", as live), runs while its events stream in, and takes + * its real terminal status once the last one is visible. Runs that + * streamed text rebuild the answer from the replayed deltas (content + * empty); runs that only produced a final message reveal it on completion. + */ +export function deriveReplayFrame( + schedule: ReplaySchedule, + elapsed: number, +): ReplayFrame { + const visibleEvents: StandaloneChatEvent[] = []; + const visibleMessageIds = new Set(); + const total = new Map(); + const seen = new Map(); + const streamed = new Set(); + for (const item of schedule.items) { + if (item.event) { + const runId = item.event.run_id; + total.set(runId, (total.get(runId) ?? 0) + 1); + if (item.event.type === "text_delta") streamed.add(runId); + if (item.at <= elapsed) { + visibleEvents.push(item.event); + seen.set(runId, (seen.get(runId) ?? 0) + 1); + } + } else if (item.message && item.at <= elapsed) { + visibleMessageIds.add(item.message.id); + } + } + const runIds = new Set(total.keys()); + const finishedRunIds = new Set(); + for (const [runId, count] of total) { + if ((seen.get(runId) ?? 0) >= count) finishedRunIds.add(runId); + } + const messages: UiMessage[] = []; + schedule.messages.forEach((message, index) => { + const runId = message.role === "assistant" ? replayRunId(message) : ""; + if (!runId || !runIds.has(runId)) { + if (visibleMessageIds.has(message.id)) messages.push(message); + return; + } + const started = (seen.get(runId) ?? 0) > 0; + const previousVisible = + index > 0 && messages[messages.length - 1]?.id === schedule.messages[index - 1].id; + if (!started && !previousVisible) return; + const finished = finishedRunIds.has(runId); + messages.push({ + ...message, + runId, + runStatus: finished ? terminalStatus(message) : started ? "running" : "queued", + content: !streamed.has(runId) && finished ? message.content : "", + }); + }); + return { visibleEvents, messages, runIds, finishedRunIds }; +} + +/** + * The slice of the file manifest a replay frame may show. A file appears + * the way it did live: once a visible `files_changed` of its run names its + * path (the mirror confirmed it) or, for a path no capture event ever + * confirms, once the visible Write/Edit step that produced it lands. Files + * nothing in the run touches (the run-end sweep) appear when the run's + * last event is visible. Files of runs the replay does not cover pass + * through. Derived from `visibleEvents`, so scrubbing back hides files. + */ +export function replayVisibleFiles( + files: ConversationFileInfo[], + opts: { + /** Every event of the conversation, for "was this path ever confirmed". */ + events: StandaloneChatEvent[]; + visibleEvents: StandaloneChatEvent[]; + }, +): ConversationFileInfo[] { + const { events, visibleEvents } = opts; + const total = new Map(); + const confirmedEver = new Map(); + for (const event of events) { + total.set(event.run_id, (total.get(event.run_id) ?? 0) + 1); + if (event.type !== "files_changed") continue; + const list = confirmedEver.get(event.run_id) ?? []; + list.push(...filesChangedNamedPaths(event)); + confirmedEver.set(event.run_id, list); + } + const seen = new Map(); + const confirmedNow = new Map(); + const writtenNow = new Map(); + for (const event of visibleEvents) { + seen.set(event.run_id, (seen.get(event.run_id) ?? 0) + 1); + const target = + event.type === "files_changed" + ? confirmedNow + : event.type === "tool_started" + ? writtenNow + : null; + if (!target) continue; + const paths = + event.type === "files_changed" + ? filesChangedNamedPaths(event) + : toolTouchPaths(event); + if (!paths.length) continue; + const list = target.get(event.run_id) ?? []; + list.push(...paths); + target.set(event.run_id, list); + } + const matches = (paths: string[] | undefined, path: string) => + (paths ?? []).some( + (candidate) => candidate === path || pathsMatch(candidate, path), + ); + return files.filter((file) => { + const runId = file.origin_run_id; + if (!runId || !total.has(runId)) return true; + if ((seen.get(runId) ?? 0) >= (total.get(runId) ?? 0)) return true; + if (matches(confirmedNow.get(runId), file.path)) return true; + if (matches(confirmedEver.get(runId), file.path)) return false; + return matches(writtenNow.get(runId), file.path); + }); +} + +export type ConversationReplayState = { elapsed: number; totalMs: number; playing: boolean; finished: boolean; - visibleEvents: StandaloneChatEvent[]; + speed: ReplaySpeed; + setSpeed: (speed: ReplaySpeed) => void; + frame: ReplayFrame; + /** The original wall-clock instant the frame shows; undefined when + * nothing carries a parsable timestamp (callers fall back to live time). */ + nowMs: number | undefined; + /** Bumps every time the replay starts over from the beginning. */ + session: number; + /** True when the frame was not reached by playback (paused or scrubbed): + * every visible text block renders complete, with no caret. */ + textInstant: boolean; togglePlay: () => void; restart: () => void; scrub: (ms: number) => void; @@ -97,62 +368,114 @@ export type ChatReplayState = { const TICK_MS = 50; -export function useChatReplay( - events: StandaloneChatEvent[], - runId: string, -): ChatReplayState { +export function useConversationReplay( + source: ReplaySource, +): ConversationReplayState { + const { messages, events } = source; + const [speed, setSpeedState] = useState(DEFAULT_REPLAY_SPEED); const schedule = useMemo( - () => buildReplaySchedule(events, runId), - [events, runId], + () => buildReplaySchedule({ messages, events }, speed), + [messages, events, speed], ); const [elapsed, setElapsed] = useState(0); const [playing, setPlaying] = useState(true); - const totalRef = useRef(schedule.totalMs); - totalRef.current = schedule.totalMs; + const [session, setSession] = useState(0); + const totalMs = schedule.totalMs; + const finished = elapsed >= totalMs; + // The clock runs only while playing and not yet at the end; reaching the + // end stops it without a separate state write. + const ticking = playing && !finished; useEffect(() => { - if (!playing) return; + if (!ticking) return; const interval = window.setInterval(() => { - setElapsed((value) => { - const next = value + TICK_MS; - if (next >= totalRef.current) { - window.clearInterval(interval); - return totalRef.current; - } - return next; - }); + setElapsed((value) => Math.min(value + TICK_MS, totalMs)); }, TICK_MS); return () => window.clearInterval(interval); - }, [playing]); - const finished = elapsed >= schedule.totalMs; - useEffect(() => { - if (finished && playing) setPlaying(false); - }, [finished, playing]); - - const visibleEvents = useMemo( - () => - schedule.items - .filter((item) => item.at <= elapsed) - .map((item) => item.event), + }, [ticking, totalMs]); + + const frame = useMemo( + () => deriveReplayFrame(schedule, elapsed), + [schedule, elapsed], + ); + const nowMs = useMemo( + () => replayInstantFor(schedule, elapsed) ?? undefined, [schedule, elapsed], ); + const restart = () => { + setElapsed(0); + setSession((value) => value + 1); + setPlaying(true); + }; return { elapsed, - totalMs: schedule.totalMs, - playing, + totalMs, + playing: ticking, finished, - visibleEvents, - togglePlay: () => { - if (elapsed >= totalRef.current) setElapsed(0); - setPlaying((value) => !value); + speed, + setSpeed: (next) => { + if (next === speed) return; + // Stay at the same point of the original run: map the current + // offset back to its instant and forward onto the new clock. + const nextSchedule = buildReplaySchedule({ messages, events }, next); + setElapsed(remapReplayElapsed(schedule, nextSchedule, elapsed)); + setSpeedState(next); }, - restart: () => { - setElapsed(0); - setPlaying(true); + frame, + nowMs, + session, + textInstant: !ticking, + togglePlay: () => { + // Play from the end starts the run over. + if (finished) restart(); + else setPlaying((value) => !value); }, + restart, scrub: (ms: number) => { setPlaying(false); - setElapsed(Math.min(Math.max(ms, 0), totalRef.current)); + setElapsed(Math.min(Math.max(ms, 0), totalMs)); }, }; } + +/** + * The replay's "pop up": the first time a file of a replayed run becomes + * ready while the replay is playing, open the artifacts panel on it, once + * per file per replay session. Files already visible when a session starts + * or when playback resumes after a pause or a scrub are absorbed silently, + * so scrubbing never opens the panel; a restart clears the memory. + */ +export function useReplayArtifactAutoOpen({ + files, + runIds, + playing, + session, + openArtifact, +}: { + /** The frame's visible manifest (see `replayVisibleFiles`). */ + files: ConversationFileInfo[]; + /** Runs the replay covers; files of other runs never pop. */ + runIds: ReadonlySet; + playing: boolean; + session: number; + openArtifact: (fileId: string) => void; +}): void { + const seenRef = useRef<{ session: number; ids: Set } | null>(null); + // Latest opener without re-running the reveal effect on identity churn. + const openRef = useRef(openArtifact); + useEffect(() => { + openRef.current = openArtifact; + }, [openArtifact]); + useEffect(() => { + // A fresh session absorbs its opening frame silently. + const fresh = seenRef.current === null || seenRef.current.session !== session; + if (fresh) seenRef.current = { session, ids: new Set() }; + const seen = seenRef.current!.ids; + for (const file of files) { + if (!file.origin_run_id || !runIds.has(file.origin_run_id)) continue; + if (file.status !== "active" || seen.has(file.id)) continue; + seen.add(file.id); + if (playing && !fresh) openRef.current(file.id); + } + }, [files, runIds, playing, session]); +} diff --git a/signalpilot/web/lib/standalone-chat-ui-messages.test.ts b/signalpilot/web/lib/standalone-chat-ui-messages.test.ts new file mode 100644 index 000000000..12b700ca7 --- /dev/null +++ b/signalpilot/web/lib/standalone-chat-ui-messages.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { + StandaloneChatEvent, + StandaloneChatMessage, + StandaloneChatRun, +} from "~/lib/api"; +import { buildStandaloneUiMessages } from "./standalone-chat-ui-messages"; + +const user: StandaloneChatMessage = { + id: "m1", + role: "user", + content: "What changed in revenue?", + sequence: 1, + created_at: 1, + metadata: { surface: "standalone" }, +}; +const assistant: StandaloneChatMessage = { + id: "m2", + role: "assistant", + content: "Revenue rose 12%.", + sequence: 2, + created_at: 2, + metadata: { surface: "standalone", run_id: "run-1", status: "completed" }, +}; +const run = (status: StandaloneChatRun["status"]): StandaloneChatRun => ({ + id: "run-1", + conversation_id: "c1", + status, + retry_of_run_id: null, + public_error_code: null, + public_error_message: null, + cancellation_requested_at: null, + created_at: "2026-09-08T00:00:00Z", + started_at: null, + terminal_at: null, + last_event_sequence: 0, +}); +const delta = (text: string, sequence: number): StandaloneChatEvent => ({ + run_id: "run-1", + sequence, + type: "text_delta", + payload: { delta: text }, + created_at: "2026-09-08T00:00:01Z", +}); + +describe("buildStandaloneUiMessages", () => { + it("returns finished history unchanged when there is no run to follow", () => { + const messages = buildStandaloneUiMessages({ + detailMessages: [user, assistant], + events: [delta("Revenue", 1)], + }); + expect(messages).toEqual([user, assistant]); + expect(messages.every((message) => !message.synthetic)).toBe(true); + }); + + it("adds no synthetic row once the run's terminal message is persisted", () => { + const messages = buildStandaloneUiMessages({ + detailMessages: [user, assistant], + currentRun: run("completed"), + events: [], + }); + expect(messages.map((message) => message.id)).toEqual(["m1", "m2"]); + }); + + it("streams the current run into a synthetic assistant row", () => { + const messages = buildStandaloneUiMessages({ + detailMessages: [user], + currentRun: run("running"), + events: [delta("Revenue", 1), delta(" rose", 2)], + }); + expect(messages).toHaveLength(2); + const streamed = messages[1]; + expect(streamed.synthetic).toBe(true); + expect(streamed.runId).toBe("run-1"); + expect(streamed.runStatus).toBe("running"); + expect(streamed.content).toBe("Revenue rose"); + }); + + it("appends the optimistic submission and a queued placeholder", () => { + const messages = buildStandaloneUiMessages({ + detailMessages: [user, assistant], + currentRun: run("completed"), + events: [], + isSubmitting: true, + pendingSubmission: { id: "p1", content: "And by region?", createdAt: 3 }, + }); + expect(messages.map((message) => message.id)).toEqual([ + "m1", + "m2", + "p1", + "pending-assistant-p1", + ]); + expect(messages[3].runStatus).toBe("queued"); + }); +}); diff --git a/signalpilot/web/lib/standalone-chat-ui-messages.ts b/signalpilot/web/lib/standalone-chat-ui-messages.ts new file mode 100644 index 000000000..3a3fe20a9 --- /dev/null +++ b/signalpilot/web/lib/standalone-chat-ui-messages.ts @@ -0,0 +1,161 @@ +// Pure projection from the gateway's conversation detail to the rendered +// message list. The live chat page and the read-only shared page both build +// their transcript through this one function. + +import type { + StandaloneChatEvent, + StandaloneChatMessage, + StandaloneChatRun, +} from "~/lib/api"; +import type { UiMessage } from "~/components/chat/chat-ui-context"; +import { + assembleStandaloneRunText, + containsStandaloneSubmission, + deriveStandaloneRunActivity, + type OptimisticUserMessage, +} from "~/lib/standalone-chat-state"; + +function eventText( + event: StandaloneChatEvent | null | undefined, + key: string, +): string { + const value = event?.payload?.[key]; + return typeof value === "string" ? value : ""; +} + +const TERMINAL = new Set(["completed", "failed", "cancelled"]); + +/** + * The synthetic assistant row for a run the gateway has not yet persisted a + * final message for. Null when the transcript already carries the run's + * terminal (or awaited clarification) message. + */ +export function syntheticRunMessage( + currentRun: StandaloneChatRun, + messages: StandaloneChatMessage[], + events: StandaloneChatEvent[], +): UiMessage | null { + const runMessages = messages.filter( + (message) => message.metadata.run_id === currentRun.id, + ); + const hasTerminalMessage = runMessages.some( + (message) => + message.role === "assistant" && + TERMINAL.has( + typeof message.metadata.status === "string" + ? message.metadata.status + : "", + ), + ); + const hasWaitingMessage = runMessages.some( + (message) => + message.role === "assistant" && + message.metadata.status === "waiting_for_user", + ); + if ( + hasTerminalMessage || + (currentRun.status === "waiting_for_user" && hasWaitingMessage) + ) { + return null; + } + const runEvents = events.filter((event) => event.run_id === currentRun.id); + const resetSequence = runEvents.reduce( + (latest, event) => + event.type === "status" && event.payload?.reset_text === true + ? Math.max(latest, event.sequence) + : latest, + 0, + ); + const streamed = assembleStandaloneRunText( + runEvents, + currentRun.id, + resetSequence, + ); + const clarification = [...runEvents] + .reverse() + .find((event) => event.type === "clarification_requested"); + const error = [...runEvents].reverse().find((event) => event.type === "error"); + const content = + (clarification && eventText(clarification, "message")) || + streamed || + (error && eventText(error, "message")) || + (currentRun.status === "cancelled" + ? "This run was stopped." + : currentRun.status === "completed" + ? "Finalizing your answer…" + : ""); + return { + id: `run-${currentRun.id}`, + role: "assistant", + content, + sequence: Number.MAX_SAFE_INTEGER, + created_at: Date.parse(currentRun.created_at) / 1_000, + metadata: { + run_id: currentRun.id, + optimistic: true, + ...(currentRun.usage ? { token_usage: currentRun.usage } : {}), + }, + runId: currentRun.id, + runStatus: currentRun.status, + activity: deriveStandaloneRunActivity(runEvents, currentRun.id), + synthetic: true, + }; +} + +/** + * Build the rendered message list. + * + * Finished history is the persisted messages as they are: every assistant + * row carries its run id in metadata and the timeline folds from `events` + * at render time. On top of that the live page adds the current run's + * streaming row, the optimistic user submission, and a queued placeholder. + * The shared page passes no run and no submission and gets the history. + */ +export function buildStandaloneUiMessages({ + detailMessages, + currentRun = null, + events, + isSubmitting = false, + pendingSubmission = null, +}: { + detailMessages: StandaloneChatMessage[] | undefined; + currentRun?: StandaloneChatRun | null; + events: StandaloneChatEvent[]; + isSubmitting?: boolean; + pendingSubmission?: OptimisticUserMessage | null; +}): UiMessage[] { + const messages: UiMessage[] = [...(detailMessages ?? [])]; + if (currentRun) { + const synthetic = syntheticRunMessage(currentRun, messages, events); + if (synthetic) messages.push(synthetic); + } + if ( + pendingSubmission && + !containsStandaloneSubmission(messages, pendingSubmission) + ) { + messages.push({ + id: pendingSubmission.id, + role: "user", + content: pendingSubmission.content, + sequence: Number.MAX_SAFE_INTEGER - 1, + created_at: pendingSubmission.createdAt, + metadata: { optimistic: true }, + }); + } + const streaming = + currentRun?.status === "queued" || currentRun?.status === "running"; + if (pendingSubmission && isSubmitting && !streaming) { + messages.push({ + id: `pending-assistant-${pendingSubmission.id}`, + role: "assistant", + content: "", + sequence: Number.MAX_SAFE_INTEGER, + created_at: pendingSubmission.createdAt, + metadata: { optimistic: true }, + runStatus: "queued", + activity: deriveStandaloneRunActivity([], ""), + synthetic: true, + }); + } + return messages; +}