diff --git a/SESSIONS_BACKEND_QUESTIONS.md b/SESSIONS_BACKEND_QUESTIONS.md new file mode 100644 index 0000000000..0a2959bbb4 --- /dev/null +++ b/SESSIONS_BACKEND_QUESTIONS.md @@ -0,0 +1,109 @@ +# Sessions FE ↔ Backend β€” points to align on + +Tags: **πŸ”΄ BLOCKER** (FE can't proceed) Β· **🟑 CONFIRM** (FE can build once the contract is agreed). +Ordered by how much FE work each unblocks. + +## Framing + +PR #4916 shipped the durable **read** plane (transcripts) live β€” FE step 1 (server-backed +history) is wired and working. The **control** planes (interactions/HITL, streams/live-run) +are scaffolded in the API but **not wired to the runner**, so the FE-side API surface is +built and parked. The points below are what it takes to light those up. + +## A. Transcripts (read plane) β€” LIVE 🟑 confirm contract + +1. **Payload stability** β€” is `payload` guaranteed 1:1 with the ACP `AgentEvent` union + (`services/agent/src/protocol.ts`)? Any event types the FE should expect that aren't in + that union? +2. **Ordering & pagination** β€” is `queryTranscripts` always ordered ascending by uuid7 + `id`? `SessionTranscriptQueryRequest` only has `session_id` β€” is there a result cap / + cursor for long sessions, or does it return the whole log? +3. **`sender` values** β€” confirm the exact set (`user` / `runner` / `system`?). FE groups + messages by this. +4. **User-turn persistence** β€” is the user's *own* message persisted as a transcript event + (`sender=user`), and with what payload shape (plain text? multimodal/files)? Determines + how we replay user turns. +5. **Deltas vs coalesced** β€” does the transcript store `message_start/delta/end` rows or + only the coalesced `message`? (FE handles both; just want to know.) +6. **64KB truncation** β€” payloads over the cap become `{"_truncated": true}`. How should + the FE render a truncated event? + +## B. State / resume β€” runner-owned 🟑 confirm division of labor + +7. Confirm FE treats `/sessions/states` as **read-only** (only `getState` for sandbox + awareness) and must **never** `setState(data)` (it'd clobber the runner's + `SessionRecord`). Correct? +8. Is sandbox **resume fully automatic** by `session_id` once a run goes through the runner + β€” i.e. the FE does nothing? Or is there anything FE-side (e.g. `setStateSandboxId`)? +9. Is there a state value worth surfacing in the UI (e.g. "sandbox alive")? + +## C. Interactions / HITL β€” INCOMPLETE πŸ”΄ blockers + +10. **πŸ”΄ Rows aren't created** β€” the runner emits `interaction_request` to the transcript + but does **not** create a `SessionInteraction` row, so `queryInteractions` is empty for + real runs. Who creates the row, and when (should be on park)? +11. **πŸ”΄ Respond doesn't transition status** β€” `respondInteraction` enqueues a re-invoke but + leaves `status.code = pending` forever (only the admin `transition` endpoint flips it). + Should respond transition pendingβ†’resolved/denied? +12. **ID correlation** β€” transcript `interaction_request.id` appears to map to + `SessionInteraction.token`, not `.id` (unique on `project+session+token`). Confirm the + intended FE flow: query by token β†’ respond by row id? Or will the transcript event carry + the interaction row id directly? +13. **Answer shape** β€” `respondInteraction.answer` is an unvalidated `Dict`. What exact + shape for approve vs deny (`{approved: true/false}`?), and is there an **"always allow"** + option β€” where is it stored/honored on resume? +14. **Resume-after-respond** β€” responding does **not** continue the run inline; does the FE + then call `invokeStream`? Confirm the intended sequence (and whether it only applies to + the streams path). +15. **Scope** β€” confirm v1 = `user_approval` only; FE keeps `user_input`/`tool_call` gated + off. + +## D. Streams / live run β€” STUB πŸ”΄ biggest blocker + +16. **πŸ”΄ invoke is a stub** β€” `SessionStreamsService.invoke`/`_start_run` + (`api/oss/src/core/sessions/streams/service.py`) only acquires Redis locks + writes a + `SessionStream`/`session_runners` row; it **does not invoke the runner** and streams + nothing. +17. **πŸ”΄ No stream-return path** β€” there's **no SSE/attach endpoint** delivering v6 chunks. + The "watch live run" path is unspecified + NOT STARTED + (`docs/designs/sessions/streams/tasks.md`). **What's the planned transport** β€” a + `GET /sessions/streams/attach` returning `text/event-stream` v6 chunks? WebSocket? The + FE `useChat` transport needs a `request β†’ streamed-response`, so we need the endpoint + + content-type. +18. **Replace vs complement** β€” the FE currently streams fine from the **runner** service + (`/api/agent/chat`). Does the streams model **replace** that, or is it + **invoke = coordination** layered on top of the existing runner stream? This decides + whether step 4 is a transport rewire or just adding force/attach/detach/liveness around + the current path. +19. **Mode semantics** β€” when wired, what should the FE do per + `SessionInvokeResponseModel.mode` (`send`/`steer`/`cancel`/`attach`/`detach`) and for + `force`/`detached`? +20. **Timeline** β€” the brief frames streams as the multi-replica-correct path; what's the + rough sequencing for 16–18? + +## E. Cross-device session discovery 🟑 + +21. FE history (the picker) is localStorage-only, so a session this browser never ran won't + appear. Transcripts is per-session (no list). Is there / will there be a + **sessions-list endpoint** (by project/app), or should FE keep using the observability + `ag.session.id`-off-traces aggregation? Align on the canonical "list sessions" source. + +## F. Mounts (step 5) 🟑 + +22. Is `client.mounts.*` (agent working-dir file CRUD) actually live/wired, and what's the + priority? (FE panel is optional/last.) + +## G. Process / verification 🟑 + +23. What's the canonical way to get a **populated local env** β€” which compose profile + + entrypoints (e.g. `worker_transcripts`) must be running for `queryTranscripts` to return + rows? Several endpoints depend on a runner/worker rebuild. +24. The design docs are labelled "draft / not implemented." Can they be marked with **what's + actually live** so the FE has a contract source of truth? + +## What the FE already has ready + +The full `@agenta/entities/session` surface β€” `querySessionTranscripts` (in use), plus +parked `getSessionState`, `queryInteractions` / `fetchInteraction` / `respondInteraction`, +and `queryStreams` / `getStreamLiveness` / `invokeSessionStream` β€” all matching the +generated client, ready to wire the day each plane lands. diff --git a/SESSIONS_FE_BRIEF.md b/SESSIONS_FE_BRIEF.md new file mode 100644 index 0000000000..b68098db4a --- /dev/null +++ b/SESSIONS_FE_BRIEF.md @@ -0,0 +1,81 @@ +# Frontend brief β€” wire the durable Sessions API into agent chat + +## 0. Where you are +- **Worktree:** `/Users/ardaerzin/Documents/GitHub/agenta_open_source/.claude/worktrees/big-agents-sessions` +- **Branch:** `big-agents-add-sessions` = `origin/big-agents` + a clean merge of PR #4916 (`feat/add-sessions`). +- Read `web/AGENTS.md` and the `agenta-package-practices` skill before writing code. Frontend is a pnpm monorepo (Node β‰₯ 22.13; use Node 24). Run `pnpm lint-fix` in `web/` before committing. + +## 1. What PR #4916 actually shipped (read this first) +It is ~95% **backend + generated client**, almost no FE feature code: +- New API domains under `api/oss/src/.../sessions/{states,streams,transcripts,interactions}` + standalone `mounts`. +- The **Fern-generated TS client** for them in `@agentaai/api-client` β€” this is your entire FE-facing surface. Nothing consumes it yet. +- Design docs in `docs/designs/sessions/` (states / streams / transcripts / interactions) and `docs/designs/mounts/`. **These docs are labelled "draft for discussion / not implemented"** β€” treat them as intent, and **verify the actual backend wiring is live before building UI on an endpoint** (esp. transcripts ingest worker, interactions beyond `user_approval`). v1 of interactions implements **`user_approval` only**; `user_input` and `tool_call` exist in the schema but are not wired. + +So your job is **integration**, not greenfield: connect the existing agent-chat FE (today localStorage-only) to these durable endpoints. + +## 2. The new API surface (`@agentaai/api-client` via `getAgentaSdkClient()`) +Get the client the standard way (mirror `web/packages/agenta-entities/src/trace/api/client.ts`): +```ts +import {getAgentaSdkClient} from "@agenta/sdk" +const sessions = getAgentaSdkClient().sessions +const mounts = getAgentaSdkClient().mounts +``` +**Project/app scope ALWAYS rides queryParams, never the body** (standing rule, see `projectScopedRequest` in the trace client): pass `{queryParams: {project_id, application_id}, abortSignal}` as the per-request options arg. Fern methods **throw `AgentaApiError` on non-2xx** β€” wrap at the boundary. + +### `client.sessions.*` +| Method | Endpoint | Purpose | +|---|---|---| +| `invokeStream({session_id, prompt?, force?, detached?})` | POST `/sessions/streams/invoke` | **Start/resume a live run.** `force` steals the run lock; `detached` = fire-and-forget (no held connection). | +| `queryStreams({session_id?, sandbox_live?})` | POST `/sessions/streams/query` | List live stream handles (who's running / attached / sandbox alive). | +| `getLiveness(...)` | GET `/sessions/streams/liveness` | Is a session's run currently alive. | +| `queryTranscripts({session_id})` | POST `/sessions/transcripts/query` | **Durable, append-only event log = the replay source for rendering a conversation.** | +| `getTranscriptEvent({event_id})` | GET `/sessions/transcripts/{event_id}` | One event by id. | +| `getState({session_id})` / `setState({session_id, data, sandbox_id})` | GET/POST `/sessions/states/{session_id}` | Durable SDK `SessionRecord` (opaque JSON) + sandbox resume pointer. | +| `setStateSandboxId(...)` | POST `/sessions/states/{session_id}/sandbox-id` | Update just the resume pointer. | +| `queryInteractions({query, windowing})` | POST `/sessions/interactions/query` | List HITL requests (pending approvals etc.). | +| `fetchInteraction({interaction_id})` | GET `/sessions/interactions/{interaction_id}` | One interaction. | +| `respondInteraction({interaction_id, answer})` | POST `/sessions/interactions/{interaction_id}/respond` | **Resolve a HITL request** (approve/deny/input/tool-result). | +| `querySessionMounts({...})` | POST `/sessions/mounts/query` | Session-scoped view of mounts. | + +### `client.mounts.*` (agent working-directory files) +`queryMounts`, `createMount`, `fetchMount`, `editMount`, `archiveMount`/`unarchiveMount`, plus file ops `getMountFiles`, `uploadMountFile`, `writeMountFile`, `deleteMountFile`, `createMountFolder`. + +### Key entity shapes (all under `AgentaApi.*` from the client) +- **`SessionTranscript`**: `{id, session_id, project_id, event_index?, sender?, session_update?, payload?, created_at?}` β€” `payload` is the ACP event (text / tool_call / result). This is what you map β†’ chat messages. +- **`SessionState`**: `{session_id, data (opaque SessionRecord JSON), sandbox_id?}`. +- **`SessionStream`**: `{id, session_id, attached?, sandbox_live?, last_seen_at?, status:{code, message}}`. `StreamStatusCode = Running | Detached | Idle | Ended`. +- **`SessionInteraction`**: `{id, session_id, run_id?, token, kind, status?, data}`. `kind = user_approval | user_input | tool_call`. `status = pending | resolved | denied | cancelled`. `data = {request, references, selector, resolution}`. +- **`SessionMount` / `Mount`**: `{id, session_id, project_id, data: MountData, flags}`. + +**Consistency model to respect:** the **transcript renders the question** (append-only, replayable); the **interaction record holds the answer-state** (is it still actionable + what was answered). Don't render HITL from the interactions table β€” render from the transcript, and use interactions to know if it's still pending/resolved. + +## 3. The existing FE you're wiring into β€” `web/oss/src/components/AgentChatSlice/` +Today this slice is **localStorage-only** with an **ephemeral runner transport**. Files that matter: +- `state/sessions.ts` β€” the multi-session model. HISTORY (`sessionsByAppAtom`) vs OPEN TABS (`openIdsByAppAtom`) vs messages (`sessionMessagesAtom`), all `atomWithStorage` (localStorage), **scope-keyed** (app id, or `drawer:` for the create/edit drawer β€” see `state/scope.tsx`). Messages keyed by globally-unique session id. +- `assets/loadSession.ts` β€” **the hydration seam.** `loadSessionMessages(sessionId)` returns `null` today. Its docstring still points at the **old** `/services/agent/v0/load-session` endpoint β€” that is now **superseded** by `client.sessions.queryTranscripts({session_id})`. This is the single cleanest place to light up server-backed history. +- `assets/transport.ts` + `assets/AgentChatTransport.ts` β€” the `useChat` (AI SDK v6) transports. Today they POST the agent-protocol envelope to the runner and stream a v6 UI Message Stream; `AgentChatTransport` also handles a batchβ†’one-shot replay. The live run currently does **not** go through `/sessions/streams/invoke`. +- `assets/toAgentaMessage.ts`, `assets/trace.ts`, `assets/rewind.ts`, `assets/files.ts`, `assets/attachments.ts` β€” message adaptation, trace stamping, edit/rewind, inline `data:` file attachments. +- `components/SessionHistoryMenu.tsx` β€” the clock-icon history picker (already shipped, localStorage-backed). `components/AgentChatConversation.tsx`, `AgentMessage.tsx`, `ToolActivity.tsx`, `QueuedMessages.tsx` β€” render surface. `hooks/useAgentChatQueue.ts` β€” send queue. +- Pages: `web/oss/src/pages/.../apps/[app_id]/agent-chat/index.tsx` (standalone) and the playground panel `AgentChatPanel.tsx`. + +Separately, observability already has a **read-only** session viewer (`web/oss/src/components/SharedDrawers/SessionDrawer/`, `components/pages/observability/components/SessionsTable/`) that lists sessions by aggregating `ag.session.id` off traces. That's a different surface β€” don't conflate it; but the new transcripts API could eventually back its message panel too. + +## 4. Suggested work order (each is independently shippable) +1. **Server-backed history (highest value, smallest change).** Implement `loadSessionMessages` against `client.sessions.queryTranscripts({session_id})`: map `SessionTranscript.payload` events β†’ v6 `UIMessage[]` (reuse the vercel/`toAgentaMessage` adapters). Caller already writes the result into `sessionMessagesAtom` before opening the tab. This makes open-from-deep-link / open-from-trace render conversations this browser never ran. **Confirm the transcript ingest worker is actually populating rows first** β€” if empty, this is a no-op. +2. **Session state / resume.** On session open, `getState` to restore the SDK record + `sandbox_id`; persist via `setState` so a run can resume the same sandbox across reloads/devices. Decide the localStorage-vs-server source-of-truth merge (server wins when present; localStorage is the offline fallback β€” keep the existing fallback semantics). +3. **HITL interactions.** When the transcript renders an `interaction_request`, cross-check `queryInteractions`/`fetchInteraction` for live status, and wire the approve/deny control to `respondInteraction({interaction_id, answer})`. Scope v1 to `user_approval` (deny/allow, maybe "always"); leave `user_input`/`tool_call` stubbed behind a flag. Build it as a derived view over the transcript, not a second source of truth. +4. **Live run via streams (bigger).** Move the live send from the current runner transport onto `/sessions/streams/invoke` (+ `queryStreams`/`getLiveness` for attach/detach/β€œsomeone else is running this” state, and `force` to steal). This is the multi-replica-correct path; verify the backend stream endpoint is live and returns the v6 chunk stream the transport expects before committing to it. +5. **Mounts / files (optional, last).** A file panel over `client.mounts.*` for the agent working directory. + +## 5. Conventions (enforced β€” see memory/AGENTS) +- **Fern client only** (`@agentaai/api-client` via `getAgentaSdkClient()`), never axios, for all new API code. +- Package vs app placement per `agenta-package-practices`. New shared session entity logic likely belongs in a `@agenta/entities`-style package (mirror `trace/api/`), not in `web/oss`. OSS lint **blocks re-exporting `@agenta/*` from app barrels**. +- **Text size 12px (`text-xs`)**, never `text-sm`. **No `size="small"`** on antd controls. Dark mode: use **antd semantic tokens** (`--ag-colorText`, `--ag-colorBorder`, …); `dark:` / `bg-gray-*` / fixed hex are no-ops here. +- Thin-references pattern: query caches store IDs, the molecule/atom is the source of truth. +- Don't put `project_id`/`application_id` in request bodies β€” queryParams only. +- Before commit: `pnpm lint-fix` in `web/`; run the **oss tsc** manually (relocations can mask new errors β€” gate on an error-signature diff, not a count). +- Commit messages: no "Claude"/Anthropic/`Co-Authored-By`. + +## 6. Verify before asserting done +- The fastest reality check is whether the endpoints return data: hit `queryTranscripts` / `queryStreams` / `queryInteractions` for a real session id and confirm non-empty, correctly-shaped responses. Several of these depend on a runner/worker rebuild that may not be running locally. +- Use the `verify` skill / run the app rather than trusting types β€” the generated client compiles regardless of whether the backend route is wired. diff --git a/api/oss/src/core/mounts/dtos.py b/api/oss/src/core/mounts/dtos.py index cf625f8d19..c3d9309ab0 100644 --- a/api/oss/src/core/mounts/dtos.py +++ b/api/oss/src/core/mounts/dtos.py @@ -60,6 +60,9 @@ class MountFile(BaseModel): path: str size: int = 0 is_folder: bool = False + # Object-store LastModified as epoch milliseconds; None when the store omits it. Lets the UI + # order files by recency regardless of how they were created (bash, Write tool, upload). + mtime: Optional[int] = None class MountFileList(BaseModel): diff --git a/api/oss/src/core/mounts/service.py b/api/oss/src/core/mounts/service.py index 5488863135..c6f13656b1 100644 --- a/api/oss/src/core/mounts/service.py +++ b/api/oss/src/core/mounts/service.py @@ -451,7 +451,8 @@ async def list_files( mount_base = base + "/" files: List[MountFile] = [] folders: set[str] = set() - for key, size in objects: + for obj in objects: + key = obj.key rel = key[len(mount_base) :] if key.startswith(mount_base) else key # Hide bare trailing-slash marker objects (empty-folder markers). if key.endswith("/"): @@ -459,7 +460,7 @@ async def list_files( if folder_rel: folders.add(folder_rel) continue - files.append(MountFile(path=rel, size=size)) + files.append(MountFile(path=rel, size=obj.size, mtime=obj.mtime)) existing = {f.path for f in files} for folder_rel in sorted(folders): @@ -559,14 +560,14 @@ async def delete_path( bucket=bucket, prefix=folder_prefix, ) - keys = [key for key, _ in objects] + keys = [obj.key for obj in objects] # The exact file (or the folder marker) may also exist alongside contents. single = await self.mounts_store.list_objects_v2( bucket=bucket, prefix=exact_key, ) - if any(key == exact_key for key, _ in single): + if any(obj.key == exact_key for obj in single): keys.append(exact_key) unique_keys = list(dict.fromkeys(keys)) diff --git a/api/oss/src/core/store/dtos.py b/api/oss/src/core/store/dtos.py new file mode 100644 index 0000000000..cb66ca4c41 --- /dev/null +++ b/api/oss/src/core/store/dtos.py @@ -0,0 +1,12 @@ +from typing import Optional + +from pydantic import BaseModel + + +class StoreObject(BaseModel): + """One object listed from the store: its key, byte size, and LastModified as epoch + milliseconds (None when the store omits it).""" + + key: str + size: int = 0 + mtime: Optional[int] = None diff --git a/api/oss/src/core/store/storage.py b/api/oss/src/core/store/storage.py index 58b95781e7..789f737ec2 100644 --- a/api/oss/src/core/store/storage.py +++ b/api/oss/src/core/store/storage.py @@ -14,6 +14,7 @@ from miniopy_async.error import S3Error from oss.src.core.store import webidentity +from oss.src.core.store.dtos import StoreObject from oss.src.core.mounts.types import MountFileNotFound, MountStorageUnavailable # STS responses are SOAP-ish XML under the 2011-06-15 namespace; strip it for tag lookups. @@ -316,17 +317,22 @@ async def list_objects_v2( *, bucket: str, prefix: str, - ) -> List[Tuple[str, int]]: - """Return (key, size) for every object under `prefix`.""" + ) -> List[StoreObject]: + """Return a StoreObject (key, size, mtime) for every object under `prefix`. `mtime` is the + object store's LastModified as epoch milliseconds, or None when the store omits it.""" client = self._client() - results: List[Tuple[str, int]] = [] + results: List[StoreObject] = [] # list_objects returns an async iterator directly (not a coroutine) and manages its # own http session; iterate it, do not `await` or `async with` the client. It can yield # a trailing None on an empty/last page, so skip falsy entries. async for obj in client.list_objects(bucket, prefix=prefix, recursive=True): if obj is None: continue - results.append((obj.object_name, obj.size or 0)) + last_modified = getattr(obj, "last_modified", None) + mtime = int(last_modified.timestamp() * 1000) if last_modified else None + results.append( + StoreObject(key=obj.object_name, size=obj.size or 0, mtime=mtime) + ) return results async def get_object( @@ -388,5 +394,5 @@ async def delete_prefix( ) -> int: """Delete every key under `prefix` (cascades a folder). Returns count.""" objects = await self.list_objects_v2(bucket=bucket, prefix=prefix) - keys = [key for key, _ in objects] + keys = [obj.key for obj in objects] return await self.delete_keys(bucket=bucket, keys=keys) diff --git a/api/oss/tests/pytest/unit/test_mounts_file_ops.py b/api/oss/tests/pytest/unit/test_mounts_file_ops.py index 0de0ad6005..c85e9a77d5 100644 --- a/api/oss/tests/pytest/unit/test_mounts_file_ops.py +++ b/api/oss/tests/pytest/unit/test_mounts_file_ops.py @@ -19,6 +19,7 @@ from oss.src.core.mounts.dtos import Mount from oss.src.core.mounts.service import MountsService, validate_file_path +from oss.src.core.store.dtos import StoreObject from oss.src.core.mounts.types import ( MountFileNotFound, MountPathInvalid, @@ -71,11 +72,13 @@ def __init__(self): # {bucket: {key: bytes}} self._store: dict[str, dict[str, bytes]] = {} - async def list_objects_v2( - self, *, bucket: str, prefix: str - ) -> List[Tuple[str, int]]: + async def list_objects_v2(self, *, bucket: str, prefix: str) -> List[StoreObject]: b = self._store.get(bucket, {}) - return [(k, len(v)) for k, v in b.items() if k.startswith(prefix)] + return [ + StoreObject(key=k, size=len(v)) + for k, v in b.items() + if k.startswith(prefix) + ] async def get_object(self, *, bucket: str, key: str) -> bytes: b = self._store.get(bucket, {}) @@ -98,7 +101,7 @@ async def delete_keys(self, *, bucket: str, keys: List[str]) -> int: async def delete_prefix(self, *, bucket: str, prefix: str) -> int: objects = await self.list_objects_v2(bucket=bucket, prefix=prefix) - return await self.delete_keys(bucket=bucket, keys=[k for k, _ in objects]) + return await self.delete_keys(bucket=bucket, keys=[o.key for o in objects]) _BUCKET = "agenta-test" diff --git a/docs/designs/sessions/README.md b/docs/designs/sessions/README.md index d677f2091f..cd068cdb95 100644 --- a/docs/designs/sessions/README.md +++ b/docs/designs/sessions/README.md @@ -12,6 +12,12 @@ one `apis/fastapi/sessions` router), plus the standalone `mounts` domain it dele | **interactions** | Human-in-the-loop requests raised by running agents (approvals, inputs, tool confirmations) | core DB `interactions` | [interactions/](./interactions/) | | **mounts** (standalone) | Durable object-store mounts for agent working directories; `sessions/mounts` is a thin session-scoped layer over it | core DB `mounts` | [../mounts/](../mounts/) | +## Frontend + +- [frontend-integration.md](./frontend-integration.md) β€” status + follow-ups for the agent-chat + FE integration (records-replay, liveness badge, SWR cache, request priority). **Read its + "Blocked / waiting" and "Follow-ups" tables before picking up remaining FE work.** + ## Cross-cutting - **RBAC**: one permission family `VIEW_SESSIONS` / `EDIT_SESSIONS` / `RUN_SESSIONS` across all diff --git a/docs/designs/sessions/frontend-integration.md b/docs/designs/sessions/frontend-integration.md new file mode 100644 index 0000000000..c690cb727e --- /dev/null +++ b/docs/designs/sessions/frontend-integration.md @@ -0,0 +1,87 @@ +# Sessions β€” frontend integration status & follow-ups + +Living status of the agent-chat frontend's integration with the durable Sessions API. It is the +hand-off surface for any agent picking up the remaining work β€” **check the "Blocked / waiting" +and "Follow-ups" tables before starting**, and update this doc as items land. + +The frontend lives in `web/oss/src/components/AgentChatSlice/` (the agent chat) and +`web/packages/agenta-entities/src/session/` (the API + zod boundary + liveness derivation). The +debug drawer is `web/oss/src/components/SessionInspector/`. + +## Mental model (why the FE is shaped the way it is) + +- **localStorage is the session index + display cache; the server is authoritative per-session.** + There is intentionally **no** server "list my sessions" endpoint β€” `query_session_streams` is a + liveness index, not a conversation list. The runner mints the `session_id`; the browser persists + it (as the chat tab id) and treats server records/state/streams as per-session truth. See + `poc-show-sessions-in-web/specs.md` Β§"The session_id the inspector keys off". +- **Records are the durable conversation.** New sessions persist BOTH user and agent turns as + records (`record_source` `user`/`agent`), so records-replay reconstructs full history. The + `done` record terminates a turn (the FE splits assistant bubbles on it). +- **Liveness rides stream flags** (`is_alive βŠ‡ is_running βŠ‡ is_attached`). `resumable`/ + `reattachable` and the lifecycle label are derived client-side (`session/core/liveness.ts`). + +## Done (shipped in the FE, may be uncommitted) + +| Area | What | Where | +|---|---|---| +| Records replay | `AgentEvent` records β†’ v6 `UIMessage[]`, `done`-turn split, user/agent role mapping | `AgentChatSlice/assets/transcriptToMessages.ts` | +| Cache-miss hydration | Empty-cache session hydrates from records; skeleton (not empty-state) while loading; `isSessionFresh` guard skips never-run sessions | `AgentChatSlice/AgentConversation.tsx`, `state/sessionEphemera.ts` | +| SWR revalidate-on-open | Cached session refetches records once (low-pri), adopts only if server strictly ahead + not busy | `AgentConversation.tsx` (`revalidatedRef` effect) | +| Liveness derivation | `deriveStreamNest` / `deriveSessionLifecycle` / `refineLifecycleWithSandbox` (+ 11 unit tests) | `session/core/liveness.ts` | +| Liveness badge | Tab-dot effective status = local run-state, else backend liveness (`running`/`alive`); ONE shared project-wide `is_alive` query for all dots | `AgentChatSlice/state/liveness.ts`, `components/SessionTagBar.tsx` | +| Request priority | Low-priority Fern client for secondary reads (records hydration, liveness) | `agenta-sdk/src/resources.ts`, `session/api/{client,api}.ts` | +| Debug inspector | Streams/Records/States/Mounts/Interactions tabs on the real endpoints | `SessionInspector/` | +| Mount file browser (read-only) | Navigable folder/file tree + text preview in the inspector Mounts tab; whole-tree fetched once, `deriveMountRows` folds it to a one-level view client-side (7 unit tests) | `session/core/mountBrowser.ts`, `session/api` (`queryMountFiles`/`readMountFile`), `SessionInspector/tabs/MountsTab.tsx` | +| Unified right panel | One resizable panel beside the chat (nested `Splitter`, min/max + chat floor, persisted width) that adapts by context: **Turn** view (inspect a turn) or **Session** view (Mounts/State/Interactions). Replaces the old fixed-width TurnInspector | `AgentChatSlice/components/RightPanel/*`, `state/rightPanel.ts` | + +## Blocked / waiting on others β€” wire when the dependency lands + +| Item | Blocked on | Exact FE seam to wire | +|---|---|---| +| **Warm/cold/dead lifecycle split** | #5197 (native resume; exposes sandbox/`session_states.data` liveness) | `refineLifecycleWithSandbox(lifecycle, sandbox)` already exists in `session/core/liveness.ts` β€” feed it a sandbox-liveness signal from `getSessionState`, then the dot/`sessionDotStatusAtomFamily` can show warm vs cold vs dead. Grep `FOLLOWUP(sessions,#5197)`. | +| **Resume a NOT-alive (cold/dead) session** | #5197 (runner respawn + remount) MERGED + #5225 (warm/reused sandboxes) MERGED; runner resurrects the sandbox and remounts the object-store cwd/agent prefixes (verified in runner logs β€” requires the store configured, else cwd runs ephemeral) | Send path resends history and the runner resumes. Remaining FE work: hang a "Resume" affordance off `resumable`/lifecycle in `SessionTagBar`/dot. | +| **Durable session naming (titles cross-device)** | Ashraf's BE (session name field/endpoint β€” #5202) | Today `renameSessionAtomFamily` writes localStorage only. When the BE field exists, write-through on rename and read it into the session index (label falls back to first-user-message from records). | +| **Durable interactions (`respondInteraction`)** | Backend (runner doesn't auto-create rows; respond doesn't transition status) | Package `queryInteractions`/`respondInteraction` are ready; live approvals currently flow through messages. Switch when the durable path is wired. | +| **Durable session archive / delete** | Backend β€” endpoint does not exist (see "Backend asks" below) | FE `deleteSessionAtomFamily` is localStorage-only; there is no way to remove/archive a session's durable data. When a cascade endpoint lands, wire the history-row Delete to call it (and offer archive/unarchive). | +| **Agent mounts (durable per-agent workspace)** | #5215 slice 1 (`POST /mounts/agents/query` β€” design PR, no code yet) | The config panel's **"App drive" section is already stubbed** (`AgentOperationsSections`, Storage region β€” user-facing naming is drive/Storage, not "mount") and our `queryMountFiles`/`readMountFile` are mount-id-generic. Wire-up = add `queryAgentMount(artifactId)` to `session/api` (or a mounts module) β†’ render the mount browser with the returned mount id; empty β†’ "no files yet". Note: #5215's D4/research reference the superseded #5204 FE shape (`MountFilesPanel` in `MountsTab.tsx`, OSS-local fetchers) β€” our package-level browser is the reuse target, and the user-facing home is the config-panel section, not the (env-gate-bound) inspector. | + +## Backend asks (not FE work β€” capture for the backend team) + +- **Durable session archive/delete cascade.** There is no session-level delete or archive today. The + only `DELETE` in `apis/fastapi/sessions/router.py` is `delete_session_stream` (the *kill* β€” soft- + deletes the stream row + tears down the sandbox + cancels pending gates), which leaves + **records / states / mounts / interactions intact**. Records/states/interactions have no + delete/archive endpoint at all; only `mounts` carries `include_archived`. Needed: a + `POST /sessions/{id}/archive` + `/unarchive` (and/or a hard `DELETE`) that cascades the + `LifecycleDBA` soft-delete across all facets and is honored by the `*/query` reads via + `include_archived` β€” following the archive/unarchive convention in `api/CLAUDE.md`. Until it + exists, the FE "Delete" is local-only and durable data persists on the backend forever. + +## Follow-ups (FE-only, deferrable β€” not blocked) + +| Item | Note | Grep marker | +|---|---|---| +| SWR revalidate on focus/interval | On-open revalidation shipped; focus/interval is a bonus for long-lived tabs | `FOLLOWUP(sessions,swr)` | +| Same-length content reconciliation | Revalidation adopts only when server is strictly LONGER (count-based). A same-length server-side edit/regenerate won't reconcile β€” content-diffing across live-vs-replay id-spaces is unreliable | `FOLLOWUP(sessions,swr)` | +| id-only cache slim | Drop the `:messages` store (keep the index), derive labels from records β€” needs a first-user-message preview stamped on the index at send. Trades instant-open for a fetch-per-open | β€” | +| Read-write mounts | The browser is read-only; the backend also has write/upload/delete/create-folder + archive/unarchive. Add file management once the agent-mounts surface (#5215, direction now landed as a design PR) is wired | β€” | +| Records-driven Turn view | The right panel's Turn view reads the live `useChat` messages, so it only works inside a mounted chat. Make it records-backed (queryRecords β†’ group by turn) to inspect a turn cross-device / outside the live chat | `FOLLOWUP(sessions,turn-records)` | +| Env-gate the debug `SessionInspector` | It's a debug drawer; the user-facing liveness/history/files surfaces are separate | β€” | +| Steer / cancel / attach in chat | These control-plane calls only edit Redis locks on the product path (no runner cooperation, no live-turn re-watch) β†’ surfacing them would be no-op stubs. Deliberately NOT wired. Revisit when the runner cooperates | `FOLLOWUP(sessions,lifecycle)` | + +## Gotchas (don't relearn these) + +- **Shared EE-dev Postgres volume drifts.** In-place migration edits don't re-run on an existing + volume, so session tables can lag the DBEs (records envelope, `session_interactions` status/tags/ + meta, `session_states` flags/tags/meta all drifted once). Migrations themselves are correct β€” a + fresh `--nuke` is clean. Re-verify against a real row; tsc goes false-green (zod strips unknowns). +- **Mount file ops need the object store configured.** `get_mount_files` (and write/upload) 503 + with "Mount storage backend is not configured" unless `AGENTA_STORE_ACCESS_KEY` / + `AGENTA_STORE_SECRET_KEY` are set (endpoint defaults to `http://seaweedfs:8333`). Mount *rows* + (create/query) work without it; only file contents need it. The browser renders a "couldn't load + β€” store may not be configured" notice in that case (it distinguishes fetch-failed `null` from an + empty `[]` mount). +- **The zod boundary is the drift check.** `session/core/schema.ts` `.transform()`s the record + envelope back to consumer names. Fern's compile-time types under-declare `extra="allow"` fields + (and lag renames like `status` objectβ†’string), so always validate through the package functions. diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_result.ok.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_result.ok.json index 0943d2d047..6d0fbc9d02 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_result.ok.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_result.ok.json @@ -7,7 +7,7 @@ "events": [ {"type": "message", "text": "Hello!"}, {"type": "usage", "input": 10, "output": 5, "total": 15, "cost": 0.001}, - {"type": "done", "stopReason": "end_turn"}, + {"type": "done", "stopReason": "end_turn", "traceId": "trace-abc"}, {"text": "an event with no type, dropped on parse"} ], "usage": {"input": 10, "output": 5, "total": 15, "cost": 0.001}, diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index 1bd083a4bb..0c73b519c4 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py @@ -566,6 +566,8 @@ def test_result_from_wire_parses_ok(golden): # The event with no `type` is dropped on parse; the other three survive. assert [e.type for e in result.events] == ["message", "usage", "done"] assert result.events[0].data == {"type": "message", "text": "Hello!"} + # The terminal `done` event carries the run's trace id (durable-replay link to the trace). + assert result.events[2].data.get("traceId") == "trace-abc" assert result.usage == {"input": 10, "output": 5, "total": 15, "cost": 0.001} assert result.stop_reason == "end_turn" assert result.session_id == "sess-42" diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index 760ea7e58e..fd72dcecaa 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -369,7 +369,10 @@ export type AgentEvent = cost?: number; } | { type: "error"; message: string } - | { type: "done"; stopReason?: string }; + // `traceId` is the run's observability trace id, stamped on the turn's terminal event so a + // persisted transcript can link a replayed turn back to its trace (latency, full-trace view). + // Live streams carry it via `messageMetadata`; this is the durable-replay channel. + | { type: "done"; stopReason?: string; traceId?: string }; /** A live event sink the engines call as each event is built. */ export type EmitEvent = (event: AgentEvent) => void; diff --git a/services/runner/src/tracing/otel.ts b/services/runner/src/tracing/otel.ts index 921b128511..295b73ecc9 100644 --- a/services/runner/src/tracing/otel.ts +++ b/services/runner/src/tracing/otel.ts @@ -1552,7 +1552,9 @@ export function createSandboxAgentOtel( const reasoning = reasoningAccumulated.trim(); if (reasoning) record({ type: "thought", text: reasoning }); } - record({ type: "done" }); + // Stamp the run's trace id on the turn's terminal event so a persisted transcript can link a + // replayed turn back to its trace (undefined only in span-less mode with no valid traceparent). + record({ type: "done", ...(runTraceId ? { traceId: runTraceId } : {}) }); if (!emitSpans) return text; if (llmSpan) { emitMessages( diff --git a/services/runner/tests/unit/wire-contract.test.ts b/services/runner/tests/unit/wire-contract.test.ts index c9700b45e2..273f273876 100644 --- a/services/runner/tests/unit/wire-contract.test.ts +++ b/services/runner/tests/unit/wire-contract.test.ts @@ -262,6 +262,11 @@ describe("wire contract: results (vs Python golden)", () => { typed.map((e) => e.type), ["message", "usage", "done"], ); + // The terminal `done` event carries the run's trace id (durable-replay link to the trace). + const doneEvent = typed.find((e) => e.type === "done") as { + traceId?: string; + }; + assert.equal(doneEvent.traceId, "trace-abc"); assert.deepEqual(res.usage, { input: 10, output: 5, diff --git a/web/oss/.gitignore b/web/oss/.gitignore index d61cb7e83c..7165e8d8eb 100644 --- a/web/oss/.gitignore +++ b/web/oss/.gitignore @@ -37,4 +37,7 @@ next-env.d.ts # tests tests/datalayer/results +# pdfjs worker copied into public/ at dev+build time (see scripts/copy-pdf-worker.mjs) +public/pdf.worker.min.mjs + diff --git a/web/oss/package.json b/web/oss/package.json index 6d78ba192a..6d363b4c2e 100644 --- a/web/oss/package.json +++ b/web/oss/package.json @@ -6,10 +6,10 @@ "node": "24.x" }, "scripts": { - "dev": "next dev --turbopack", - "dev:local": "ENV_FILE=.local.env next dev", - "dev:turbo": "ENV_FILE=.local.env next dev --turbo", - "build": "next build && cp -r public/. .next/standalone/oss/public && cp -r .next/static .next/standalone/oss/.next", + "dev": "node scripts/copy-pdf-worker.mjs && next dev --turbopack", + "dev:local": "node scripts/copy-pdf-worker.mjs && ENV_FILE=.local.env next dev", + "dev:turbo": "node scripts/copy-pdf-worker.mjs && ENV_FILE=.local.env next dev --turbo", + "build": "node scripts/copy-pdf-worker.mjs && next build && cp -r public/. .next/standalone/oss/public && cp -r .next/static .next/standalone/oss/.next", "start": "next start", "lint": "next lint", "lint-fix": "next lint --fix", @@ -102,6 +102,7 @@ "motion": "^12.0.0", "next": "15.5.18", "papaparse": "^5.5.3", + "pdfjs-dist": "^4.10.38", "postcss": "^8.5.10", "posthog-js": "^1.223.3", "prismjs": "^1.30.0", diff --git a/web/oss/scripts/copy-pdf-worker.mjs b/web/oss/scripts/copy-pdf-worker.mjs new file mode 100644 index 0000000000..09103d093a --- /dev/null +++ b/web/oss/scripts/copy-pdf-worker.mjs @@ -0,0 +1,23 @@ +/** + * Copy the pdfjs-dist worker into public/ so the drive PDF thumbnail can load it as a same-origin + * static asset (`workerSrc = "/pdf.worker.min.mjs"`). + * + * Why not `new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url)`: the webpack production + * build (`next build`) externalizes the ESM worker and fails ("ESM packages need to be imported"). + * Dev uses turbopack, which tolerated it β€” so the break only ever showed in CI. Serving it from + * public/ keeps it local (no CDN) for self-hosted deployments and works under both bundlers. + * + * Runs in the `dev` and `build` scripts. The copied file is gitignored (a node_modules artifact). + */ +import {copyFileSync, mkdirSync} from "node:fs" +import {createRequire} from "node:module" +import {dirname, join} from "node:path" +import {fileURLToPath} from "node:url" + +const require = createRequire(import.meta.url) +const source = require.resolve("pdfjs-dist/build/pdf.worker.min.mjs") +const publicDir = join(dirname(fileURLToPath(import.meta.url)), "..", "public") + +mkdirSync(publicDir, {recursive: true}) +copyFileSync(source, join(publicDir, "pdf.worker.min.mjs")) +console.log("[copy-pdf-worker] pdfjs worker β†’ public/pdf.worker.min.mjs") diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index 044cba9a1e..4e0658af88 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -1,16 +1,15 @@ -import {lazy, Suspense, useEffect, useRef, useState} from "react" +import {lazy, Suspense, useEffect, useRef, useState, type CSSProperties} from "react" -import {workflowMolecule} from "@agenta/entities/workflow" import {simulatedAgentRunAtomFamily} from "@agenta/shared/state" import {Splitter, Tabs} from "antd" import clsx from "clsx" import {useAtomValue, useSetAtom} from "jotai" import {useOptionalOnboardingContext} from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingContext" -// Direct file import β€” the barrel would statically pull the inspector drawer into this chunk. -import SessionInspectorButton from "@/oss/components/SessionInspector/SessionInspectorButton" +// Direct file import β€” the barrel would statically pull the inspector drawer into this chunk. import {ConversationSkeleton, SessionBarSkeleton} from "./components/AgentChatSkeleton" +import InspectSessionButton from "./components/Inspector/InspectSessionButton" import MountFade from "./components/MountFade" import SessionHistoryMenu from "./components/SessionHistoryMenu" import {chatPanelMaximizedAtom} from "./state/panelLayout" @@ -19,6 +18,7 @@ import { activeSessionIdAtomFamily, addSessionAtomFamily, closeSessionAtomFamily, + pruneSessionHusksAtomFamily, renameSessionAtomFamily, sessionsListAtomFamily, setActiveSessionAtomFamily, @@ -53,7 +53,6 @@ const RAIL_MAX_WIDTH = 480 * `buildAgentRequest` against the current `entityId` (so the run always uses the live draft config). */ const AgentChatPanel = ({entityId}: {entityId: string}) => { - const artifactId = useAtomValue(workflowMolecule.selectors.workflowId(entityId)) const scope = useChatScopeKey() // Pre-commit onboarding: one ephemeral session, no multi-session UX β€” hide the whole session bar // (tabs / new / search / history). Stays hidden through the commit + first send, then eases in a beat @@ -68,6 +67,7 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { const closeSession = useSetAtom(closeSessionAtomFamily(scope)) const renameSession = useSetAtom(renameSessionAtomFamily(scope)) const setActiveSession = useSetAtom(setActiveSessionAtomFamily(scope)) + const pruneSessionHusks = useSetAtom(pruneSessionHusksAtomFamily(scope)) const chatMaximized = useAtomValue(chatPanelMaximizedAtom) // Shared entrance latch: the composer's Reveal plays for the first conversation this // panel mounts; every additional session pane skips it (no per-switch flash). @@ -84,6 +84,13 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { if (sessions.length > 0) seeded.current = false }, [sessions.length, addSession]) + // Sweep husks (never-run, untitled, empty sessions) that accumulated in history β€” from before + // the close-time cleanup, or orphaned by a reload. Open tabs are untouched, so this never drops + // the blank tab you're about to type in. + useEffect(() => { + pruneSessionHusks() + }, [pruneSessionHusks]) + // Tolerate a stale active id (its tab was closed) by falling back to the first tab. const activeId = sessions.some((s) => s.id === rawActiveId) ? rawActiveId : sessions[0]?.id @@ -104,6 +111,9 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { // in the SAME commit as the size flip (else it snaps), then held ~280ms; off during drag/resize. const prevMaximizedRef = useRef(chatMaximized) const [holdAnimate, setHoldAnimate] = useState(false) + // Rail pane is controlled: 0 while collapsed (build mode), the dragged width while open. + // Keeping `size` always defined + an `onResize` satisfies antd's controlled-Splitter contract. + const [railSize, setRailSize] = useState(RAIL_WIDTH) const justToggled = prevMaximizedRef.current !== chatMaximized // Deps = toggle value ONLY: with `justToggled` in deps, the holdAnimate re-render re-ran the // effect and its cleanup cancelled the timer β€” the class stuck on and every drag lagged. @@ -129,26 +139,30 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { "playground-splitter-animated": animateRailSplit, }, )} + onResize={(sizes) => { + if (chatMaximized) setRailSize(sizes[0]) + }} > - {/* `inert` drops the clipped rail from tab order + a11y while collapsed. */} -
+ {/* `inert` drops the clipped rail from tab order + a11y while collapsed. Flex-bounded + (not a plain h-full cascade) so the rail's session list actually scrolls β€” a bare + h-full chain through the fade wrapper grew with content and never bounded. */} +
{/* Rail pane is width-0 unless maximized, so no visible fallback is needed. */} {/* min-w matches RAIL_MIN_WIDTH (Tailwind needs the literal). */} - + @@ -157,18 +171,33 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { ( - // Kept mounted in ALL states so its height ANIMATES on transitions rather than the node - // mounting at full height (which snapped the content down). Collapsed to 0 in chat mode - // (controls live in the SessionRail) AND during onboarding (single ephemeral session); - // expands to 48 when the committed build view takes over β€” same eased height transition - // as the rail/config panes. + // renderTabBar's node stands in for the nav, so making IT absolute (pinned top, + // bounded to the pane width) takes the bar out of flow β€” the transcript no longer + // reflows when it appears, and the strip has a bounded width so tabs scroll. It just + // fades (opacity) out in chat mode / onboarding while the content padding animates. + ) : ( +
+ +
+
Files
+
+ {drive.fileCount} file{drive.fileCount === 1 ? "" : "s"} Β·{" "} + {humanSize(drive.totalSize) || "0 B"} Β· this conversation +
+
+
+ ) + } + extra={ + inPreview && file ? ( +
+ {/* Secondary actions as borderless icon buttons; Download stays primary. */} + + + + {/* Details toggle β†’ reveals the metadata grid in the body (the header already + shows name Β· size Β· time). Info icon fills + tints primary when open. */} + {buildMode ? ( + + +
+ ) : undefined + } + footer={ + inPreview && files.length > 1 ? ( +
+
+ ) : undefined + } + styles={{body: {padding: 0, display: "flex", minHeight: 0}}} + > + setQuickLook({path: p})} + /> + + ) +} diff --git a/web/oss/src/components/Drives/FilesDrawerBody.tsx b/web/oss/src/components/Drives/FilesDrawerBody.tsx new file mode 100644 index 0000000000..89434daf29 --- /dev/null +++ b/web/oss/src/components/Drives/FilesDrawerBody.tsx @@ -0,0 +1,87 @@ +/** + * FilesDrawerBody β€” the heavy body of the chat Files drawer: the FilesWindow grid (thumbnails, + * renderers) plus the sliding single-file preview. Its OWN module so {@link FilesDrawer} can + * `next/dynamic`-import it β€” the grid/renderer/pdfjs graph then loads only when the drawer opens, + * not with the always-mounted per-session chat pane, and unmounts on close (`destroyOnClose`). + */ +import {AnimatePresence, motion} from "motion/react" + +import {SESSION_SPRING} from "@/oss/components/AgentChatSlice/assets/sessionMotion" + +import {DriveFileContentViewer} from "./DriveExplorer" +import {DriveFileMetaList} from "./fileMeta" +import FilesWindow from "./FilesWindow" +import {type DriveRecentFile, type ResolvedMountPath} from "./useSessionDrive" + +export default function FilesDrawerBody({ + sessionId, + inPreview, + file, + resolvedFile, + buildMode, + metaExpanded, + isLoading, + onNavigate, +}: { + sessionId: string + inPreview: boolean + /** The previewed file (null when the requested path isn't in the drive yet). */ + file: DriveRecentFile | null + /** Which mount backs the previewed file + its mount-relative path (for content/download). */ + resolvedFile: ResolvedMountPath | null + /** Build mode shows the metadata; chat mode stays content-first. */ + buildMode: boolean + /** Whether the metadata grid is expanded β€” the toggle lives in the drawer header. */ + metaExpanded: boolean + isLoading: boolean + /** Open another drive file β€” an internal HTML-preview link resolves to its path. */ + onNavigate: (path: string) => void +}) { + return ( +
+ {/* Grid stays mounted so its scroll survives a preview round-trip. */} +
+ +
+ {/* initial={false}: a direct link-open lands ON the preview (no grid flash); a + grid→preview navigation within the open drawer still slides. */} + + {inPreview ? ( + + {file ? ( +
+ {buildMode ? ( + + ) : null} + +
+ ) : ( +
+ {isLoading ? "Loading…" : "This file isn't in the drive yet."} +
+ )} +
+ ) : null} +
+
+ ) +} diff --git a/web/oss/src/components/Drives/FilesWindow.tsx b/web/oss/src/components/Drives/FilesWindow.tsx new file mode 100644 index 0000000000..d036844cad --- /dev/null +++ b/web/oss/src/components/Drives/FilesWindow.tsx @@ -0,0 +1,218 @@ +/** + * FilesWindow β€” the chat-mode "Files" surface (build-spec view E2). Jargon-free: never says + * mount/cwd/session drive. Grid (default, flat recent-first tiles β†’ Quick Look) or List (the + * Build drawer's two-pane explorer β€” build once, skin twice), with client-side search and + * Recent/Name/Size sort. Read-only in phase 1, same endpoints as everything else. + */ +import {useMemo, useState} from "react" + +import {FolderSimple, ListBullets, MagnifyingGlass, SquaresFour, Tray} from "@phosphor-icons/react" +import {Input, Segmented, Select, Skeleton, Tooltip, Typography} from "antd" +import {useSetAtom} from "jotai" +import {AnimatePresence, MotionConfig, motion} from "motion/react" + +import {DriveExplorer} from "./DriveExplorer" +import {DriveFileRow} from "./DriveFileRow" +import {gridArrowKeyDown} from "./driveKeyboard" +import {FILE_ITEM_VARIANTS, FILE_SPRING} from "./driveMotion" +import {useDriveArtifactId} from "./driveSessionContext" +import {humanSize} from "./driveTree" +import {ORIGIN_TIP} from "./OriginTag" +import {driveQuickLookAtomFamily} from "./quickLook" +import {isRecentlyChanged, useRecentChangeClock} from "./recentChange" +import {fileOrigin, useSessionDrive, type FileOrigin} from "./useSessionDrive" + +const {Text} = Typography + +type SortKey = "recent" | "name" | "size" +type OriginFilter = "all" | FileOrigin + +export default function FilesWindow({ + sessionId, + embedded = false, +}: { + sessionId: string + /** Rendered inside a titled shell (the Files drawer) β€” hide the inner "Files" label. */ + embedded?: boolean +}) { + const artifactId = useDriveArtifactId() + const drive = useSessionDrive(sessionId, artifactId ?? undefined) + const openQuickLook = useSetAtom(driveQuickLookAtomFamily(sessionId)) + const now = useRecentChangeClock(drive.lastTouchedAt) + + const [view, setView] = useState<"grid" | "list">("grid") + const [search, setSearch] = useState("") + const [sort, setSort] = useState("recent") + const [origin, setOrigin] = useState("all") + + // Offer the agent/session filter only when the drive actually holds both kinds. + const mixed = useMemo(() => { + const kinds = new Set(drive.recents.map((f) => fileOrigin(f.path))) + return kinds.has("agent") && kinds.has("session") + }, [drive.recents]) + + const shown = useMemo(() => { + const q = search.trim().toLowerCase() + let filtered = drive.recents + if (mixed && origin !== "all") + filtered = filtered.filter((f) => fileOrigin(f.path) === origin) + if (q) filtered = filtered.filter((f) => f.path.toLowerCase().includes(q)) + if (sort === "recent") return filtered // recents are already recency-ordered + return [...filtered].sort((a, b) => + sort === "name" + ? (a.path.split("/").pop() ?? "").localeCompare(b.path.split("/").pop() ?? "") + : (b.size ?? 0) - (a.size ?? 0), + ) + }, [drive.recents, search, sort, origin, mixed]) + + return ( +
+
+ {!embedded ? ( + <> + + Files + + ) : null} +
+ {view === "grid" ? ( + <> + {mixed ? ( + setOrigin(v as OriginFilter)} + options={[ + {value: "all", label: "All"}, + { + value: "agent", + label: ( + + Agent + + ), + }, + { + value: "session", + label: ( + + Session + + ), + }, + ]} + /> + ) : null} + setSearch(e.target.value)} + placeholder="Search" + className="w-[140px]" + prefix={ + + } + /> +