feat(projects): cross-app embedding — Files + Messages in ProjectWorkspace#264
Conversation
📝 WalkthroughWalkthroughAdds project-scoped file and messaging capabilities: frontend apps accept project roots/scopes and ProjectWorkspace exposes files/messages tabs; backend gains project-scoped chat filters and a new project files router with listing, watch (SSE), upload, mkdir, download, delete, and stats endpoints; tests added for both areas. Changes
Sequence DiagramsequenceDiagram
participant Client as Client / ProjectWorkspace
participant FilesApp as FilesApp Component
participant Backend as Backend API
participant FS as File System
Client->>FilesApp: mount with rootPath="project:slug"
FilesApp->>Backend: GET /api/files/project/{slug}?path=...
Backend->>FS: read directory
FS-->>Backend: entries + metadata
Backend-->>FilesApp: JSON entries
FilesApp-->>Client: render list
Client->>FilesApp: request watch /api/files/project/{slug}/watch
FilesApp->>Backend: open SSE
loop poll interval
Backend->>FS: compute signature
FS-->>Backend: signature
Backend-->>FilesApp: SSE update (if changed)
end
Client->>FilesApp: upload file (multipart)
FilesApp->>Backend: POST /upload
Backend->>FS: create dirs & write file
FS-->>Backend: success + size
Backend-->>FilesApp: upload response
FilesApp-->>Client: refresh list
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@desktop/src/apps/FilesApp.tsx`:
- Around line 398-405: FilesApp initializes location from rootPath but leaves
selectedLocation null, so mobile view lands on the list pane and the project
root isn’t reachable; update the selectedLocation state initializer to mirror
location when rootPath is provided (i.e., initialize selectedLocation to
rootPath ?? "workspace" or to the same derived value as location) so
MobileSplitView / MobileDetailPane opens the project detail pane on mobile;
locate the selectedLocation state (and any setSelectedLocation usage) and change
its useState initial value to match the existing location initialization in
FilesApp.
In `@tinyagentos/routes/project_files.py`:
- Around line 65-67: _dir_signature currently casts entry['modified'] to int
which drops sub-second precision and can cause identical signatures for rapid
edits; update _dir_signature to preserve fractional seconds (e.g., use the float
value or format with sufficient precision like 6+ decimals) when building the
signature parts so changes within the same second produce different signatures;
locate _dir_signature and replace the int(...) conversion with the
preserved/rounded float formatting (e.g., using the raw timestamp or formatted
float) for the modified field.
- Around line 144-160: The api_project_mkdir handler should check for name
collisions with existing files before calling target.mkdir: after resolving
target with _resolve_safe (and after current validations), add a check using
target.exists() / target.is_file() and if the path exists and is a file return
JSONResponse({"error": "path exists and is a file"}, status_code=400); if it
already exists and is a directory you can return the same success response (or a
200), otherwise call target.mkdir(parents=True, exist_ok=True) as before. Ensure
you reference api_project_mkdir, _resolve_safe, workspace, target, and body.path
when making the changes.
- Around line 121-141: The upload handler api_project_upload_file should
validate that the resolved path is a directory (not an existing file) before
calling mkdir and must stream the incoming upload to disk instead of buffering
it in memory; after calling _resolve_safe(workspace, path) ensure target_dir
exists and if target_dir exists and is_file() return a 400 "Invalid path" error,
otherwise create directories; then write the incoming UploadFile to disk by
streaming from UploadFile.file (or by reading in fixed-size chunks via await
file.read(chunk_size)) into dest opened in binary write mode (or use
shutil.copyfileobj on file.file) so the whole file is not loaded into RAM, and
ensure you close the source/dest file handles when done.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b9f087bc-6215-4304-acf6-b3036b3488ea
📒 Files selected for processing (8)
desktop/src/apps/FilesApp.tsxdesktop/src/apps/MessagesApp.tsxdesktop/src/apps/ProjectsApp/ProjectWorkspace.tsxtests/test_routes_chat_channels.pytests/test_routes_project_files.pytinyagentos/app.pytinyagentos/routes/chat.pytinyagentos/routes/project_files.py
| @router.post("/api/projects/{slug}/files/upload") | ||
| async def api_project_upload_file(request: Request, slug: str, path: str = "", file: UploadFile = File(...)): | ||
| """Upload a file to the project's files folder.""" | ||
| workspace = _get_project_files_root(request, slug) | ||
| if workspace is None: | ||
| return JSONResponse({"error": "Invalid slug"}, status_code=400) | ||
|
|
||
| if path: | ||
| target_dir = _resolve_safe(workspace, path) | ||
| if target_dir is None: | ||
| return JSONResponse({"error": "Invalid path"}, status_code=400) | ||
| target_dir.mkdir(parents=True, exist_ok=True) | ||
| else: | ||
| target_dir = workspace | ||
|
|
||
| filename = Path(file.filename).name | ||
| dest = target_dir / filename | ||
| content = await file.read() | ||
| dest.write_bytes(content) | ||
| rel = dest.relative_to(workspace) | ||
| return {"name": filename, "path": str(rel), "size": len(content), "status": "uploaded"} |
There was a problem hiding this comment.
Validate the upload target and avoid buffering the whole file in memory.
If path already resolves to a file, target_dir.mkdir(..., exist_ok=True) raises FileExistsError, so this handler returns a 500 instead of a client error. The following await file.read() also loads the entire upload into RAM, which is risky for large project files.
Proposed fix
`@router.post`("/api/projects/{slug}/files/upload")
async def api_project_upload_file(request: Request, slug: str, path: str = "", file: UploadFile = File(...)):
@@
if path:
target_dir = _resolve_safe(workspace, path)
if target_dir is None:
return JSONResponse({"error": "Invalid path"}, status_code=400)
+ if target_dir.exists() and not target_dir.is_dir():
+ return JSONResponse({"error": "Upload path must be a directory"}, status_code=409)
target_dir.mkdir(parents=True, exist_ok=True)
else:
target_dir = workspace
filename = Path(file.filename).name
dest = target_dir / filename
- content = await file.read()
- dest.write_bytes(content)
+ with dest.open("wb") as fh:
+ while chunk := await file.read(1024 * 1024):
+ fh.write(chunk)
rel = dest.relative_to(workspace)
- return {"name": filename, "path": str(rel), "size": len(content), "status": "uploaded"}
+ return {"name": filename, "path": str(rel), "size": dest.stat().st_size, "status": "uploaded"}🧰 Tools
🪛 Ruff (0.15.11)
[warning] 122-122: Do not perform function call File in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable
(B008)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tinyagentos/routes/project_files.py` around lines 121 - 141, The upload
handler api_project_upload_file should validate that the resolved path is a
directory (not an existing file) before calling mkdir and must stream the
incoming upload to disk instead of buffering it in memory; after calling
_resolve_safe(workspace, path) ensure target_dir exists and if target_dir exists
and is_file() return a 400 "Invalid path" error, otherwise create directories;
then write the incoming UploadFile to disk by streaming from UploadFile.file (or
by reading in fixed-size chunks via await file.read(chunk_size)) into dest
opened in binary write mode (or use shutil.copyfileobj on file.file) so the
whole file is not loaded into RAM, and ensure you close the source/dest file
handles when done.
- mobile FilesApp now opens the project location directly (was unreachable) - preserve sub-second precision in watch signature - upload + mkdir routes return 400 instead of 500 on file/dir collision
There was a problem hiding this comment.
♻️ Duplicate comments (1)
tinyagentos/routes/project_files.py (1)
138-143:⚠️ Potential issue | 🟠 MajorStream uploads and guard destination filename collisions to prevent 500s.
The handler still buffers the entire upload in memory, and
dest.write_bytes(...)can raise on directory/empty-name collisions, returning server errors instead of client-safe 400 responses.Proposed fix
- filename = Path(file.filename).name - dest = target_dir / filename - content = await file.read() - dest.write_bytes(content) - rel = dest.relative_to(workspace) - return {"name": filename, "path": str(rel), "size": len(content), "status": "uploaded"} + filename = Path(file.filename or "").name + if not filename: + return JSONResponse({"error": "Invalid filename"}, status_code=400) + + dest = target_dir / filename + if dest.exists() and dest.is_dir(): + return JSONResponse({"error": "Path conflicts with an existing directory"}, status_code=400) + + size = 0 + with dest.open("wb") as fh: + while chunk := await file.read(1024 * 1024): + fh.write(chunk) + size += len(chunk) + await file.close() + + rel = dest.relative_to(workspace) + return {"name": filename, "path": str(rel), "size": size, "status": "uploaded"}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tinyagentos/routes/project_files.py` around lines 138 - 143, The current handler buffers the entire upload via content = await file.read() and calls dest.write_bytes(...) which can OOM and raise unhandled errors; change it to stream the incoming file into dest using the UploadFile.file stream (e.g., copy chunks from file.file into dest.open("wb")) to avoid buffering, validate Path(file.filename).name is non-empty and sanitized (no path traversal) before creating dest, ensure dest.resolve() is still under workspace.resolve() and check if dest.exists() to return a client-safe 400 for bad filenames/collisions instead of letting dest.write_bytes raise, and wrap filesystem operations around specific exception handling to convert expected filesystem/client errors to 400 responses while letting unexpected errors propagate as 500s.
🧹 Nitpick comments (1)
desktop/src/apps/FilesApp.tsx (1)
163-168: Update the root-kinds helper comment to include project scope.The comment still says there are three root kinds, but the implementation now supports project roots as well.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@desktop/src/apps/FilesApp.tsx` around lines 163 - 168, The comment describing supported workspace root kinds is out of date — update the "root-kinds" helper comment (the comment above the function that builds endpoint URLs for workspace roots) to mention project-scoped roots as well (e.g., "project:<id>" or "project" depending on your implementation) and adjust the list/count to reflect four root kinds: workspace, agent:<slug>, project (project-scoped), and <folder-name> (shared folder); keep the wording consistent with the existing style so fetch/upload/mkdir/delete/watch consumers remain clear.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@tinyagentos/routes/project_files.py`:
- Around line 138-143: The current handler buffers the entire upload via content
= await file.read() and calls dest.write_bytes(...) which can OOM and raise
unhandled errors; change it to stream the incoming file into dest using the
UploadFile.file stream (e.g., copy chunks from file.file into dest.open("wb"))
to avoid buffering, validate Path(file.filename).name is non-empty and sanitized
(no path traversal) before creating dest, ensure dest.resolve() is still under
workspace.resolve() and check if dest.exists() to return a client-safe 400 for
bad filenames/collisions instead of letting dest.write_bytes raise, and wrap
filesystem operations around specific exception handling to convert expected
filesystem/client errors to 400 responses while letting unexpected errors
propagate as 500s.
---
Nitpick comments:
In `@desktop/src/apps/FilesApp.tsx`:
- Around line 163-168: The comment describing supported workspace root kinds is
out of date — update the "root-kinds" helper comment (the comment above the
function that builds endpoint URLs for workspace roots) to mention
project-scoped roots as well (e.g., "project:<id>" or "project" depending on
your implementation) and adjust the list/count to reflect four root kinds:
workspace, agent:<slug>, project (project-scoped), and <folder-name> (shared
folder); keep the wording consistent with the existing style so
fetch/upload/mkdir/delete/watch consumers remain clear.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: c08798ee-8511-4778-81c3-cf013227458b
📒 Files selected for processing (3)
desktop/src/apps/FilesApp.tsxtests/test_routes_project_files.pytinyagentos/routes/project_files.py
🚧 Files skipped from review as they are similar to previous changes (1)
- tests/test_routes_project_files.py
Summary
Tasks 25 and 26 from the Projects-app foundation plan — embed FilesApp and MessagesApp inside
ProjectWorkspace, with backend route family for project-scoped files andproject_id-aware chat channel filtering.rootPathprop ("project:<slug>"); standalone-mode UI unchanged. Backend exposes/api/projects/{slug}/files*mirroring user-workspace routes, rooted at<projects_root>/<slug>/files. 12 new route tests.scope?: { projectId?: string }prop. When scoped, fetches and creates channels filter byproject_idserver-side; when standalone, root sidebar sections (DM/topic/group) strictly exclude project-tagged channels and a new collapsible "Projects" section nests them. Cloned-agent channels appear only inside Projects (naturally — they carry the owningproject_id). 2 new route tests.key={project.id}to remount on project change (avoids stale state from lazyuseStateinitialisers).button+aria-expanded+aria-controls).Test plan
pytest tests/projects tests/test_routes_projects.py tests/test_chat_channels.py tests/test_routes_chat_channels.py tests/test_routes_project_files.py— 94 passednpx tsc -b --noEmit(desktop) — cleanproject_id~/Projects/<slug>/files, upload/mkdir/delete work, switching projects shows the new project's tree (no stale state)Summary by CodeRabbit