feat(vis): add session download, import, export and delete#1402
feat(vis): add session download, import, export and delete#1402
Conversation
…session filtering
…rd with delete confirmation
…ons found message
| def _find_session_by_id(session_id: str) -> Path | None: | ||
| """Find a session directory by session ID across all work directories.""" | ||
| from kimi_cli.share import get_share_dir | ||
|
|
||
| sessions_root = get_share_dir() / "sessions" | ||
| if not sessions_root.exists(): | ||
| return None | ||
|
|
||
| for work_dir_hash_dir in sessions_root.iterdir(): | ||
| if not work_dir_hash_dir.is_dir(): | ||
| continue | ||
| candidate = work_dir_hash_dir / session_id | ||
| if candidate.is_dir(): | ||
| return candidate | ||
|
|
||
| return None |
There was a problem hiding this comment.
🟡 CLI export command cannot find imported sessions
The _find_session_by_id function in export.py only searches get_share_dir() / "sessions" (line 19), but imported sessions are stored under get_share_dir() / "imported_sessions" (as defined in src/kimi_cli/vis/api/sessions.py:51). Any attempt to run kimi export <imported-session-id> will always fail with "session not found" for imported sessions, making the CLI export feature inconsistent with the web import feature introduced in the same PR.
| def _find_session_by_id(session_id: str) -> Path | None: | |
| """Find a session directory by session ID across all work directories.""" | |
| from kimi_cli.share import get_share_dir | |
| sessions_root = get_share_dir() / "sessions" | |
| if not sessions_root.exists(): | |
| return None | |
| for work_dir_hash_dir in sessions_root.iterdir(): | |
| if not work_dir_hash_dir.is_dir(): | |
| continue | |
| candidate = work_dir_hash_dir / session_id | |
| if candidate.is_dir(): | |
| return candidate | |
| return None | |
| def _find_session_by_id(session_id: str) -> Path | None: | |
| """Find a session directory by session ID across all work directories.""" | |
| from kimi_cli.share import get_share_dir | |
| sessions_root = get_share_dir() / "sessions" | |
| if sessions_root.exists(): | |
| for work_dir_hash_dir in sessions_root.iterdir(): | |
| if not work_dir_hash_dir.is_dir(): | |
| continue | |
| candidate = work_dir_hash_dir / session_id | |
| if candidate.is_dir(): | |
| return candidate | |
| imported_root = get_share_dir() / "imported_sessions" | |
| if imported_root.exists(): | |
| candidate = imported_root / session_id | |
| if candidate.is_dir(): | |
| return candidate | |
| return None |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Pull request overview
Adds end-to-end session packaging workflows across the vis UI and CLI: download a session as a ZIP, import a session ZIP into a dedicated imported-sessions store, export a session ZIP via CLI, and delete imported sessions from the UI.
Changes:
- Backend: add session download/import/delete endpoints and include an
importedflag in session listings. - Frontend: add API helpers + sessions explorer UI for import, imported-only filtering, download actions, and delete confirmation dialog.
- CLI: add
kimi export <session_id>command to package a session directory as a ZIP.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| vis/src/main.tsx | Wraps app with TooltipProvider to support tooltips. |
| vis/src/lib/api.ts | Adds imported field to session info and introduces download/import/delete API helpers with timeouts. |
| vis/src/features/sessions-explorer/sessions-explorer.tsx | Adds import handling, imported-only filtering, delete callback wiring, and updated empty states. |
| vis/src/features/sessions-explorer/session-card.tsx | Adds per-session download action and imported-session delete dialog/action. |
| vis/src/features/sessions-explorer/project-group.tsx | Plumbs session deletion callback down to cards. |
| vis/src/features/sessions-explorer/explorer-toolbar.tsx | Adds imported filter toggle and ZIP import button/input with loading state. |
| vis/src/components/ui/tooltip.tsx | New tooltip component wrappers. |
| vis/src/components/ui/alert-dialog.tsx | New AlertDialog component wrappers used for delete confirmation. |
| vis/src/App.tsx | Adds session download button on detail view and tooltip-based click-to-copy session ID UI. |
| src/kimi_cli/vis/api/sessions.py | Adds imported sessions root support, download/import/delete endpoints, and augments session listing with imported. |
| src/kimi_cli/cli/export.py | Adds kimi export CLI command to write a session ZIP to disk. |
| src/kimi_cli/cli/init.py | Registers the new export CLI subcommand. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not file.filename or not file.filename.endswith(".zip"): | ||
| raise HTTPException(status_code=400, detail="Only .zip files are accepted") | ||
|
|
||
| content = await file.read() | ||
| if not content: | ||
| raise HTTPException(status_code=400, detail="Empty file") |
There was a problem hiding this comment.
import_session() reads the entire uploaded ZIP into memory (await file.read()) and doesn't enforce the 200MB upload limit mentioned in the PR description. Consider streaming the upload in chunks and rejecting when size exceeds the limit (e.g., return 413), so large uploads don't cause high memory usage.
| zf.extractall(session_dir) | ||
|
|
There was a problem hiding this comment.
ZIP extraction uses ZipFile.extractall(session_dir) without validating member paths. A crafted ZIP can write outside session_dir via ../ or absolute paths (Zip Slip). Validate each entry’s target path stays within session_dir (and reject unsafe names) before extracting.
| zf.extractall(session_dir) | |
| session_root = session_dir.resolve() | |
| for member in zf.infolist(): | |
| member_name = member.filename | |
| # Skip empty names and directory entries; directories will be created as needed. | |
| if not member_name or member_name.endswith("/"): | |
| continue | |
| member_path = Path(member_name) | |
| # Reject absolute paths or any use of ".." to prevent path traversal. | |
| if member_path.is_absolute() or ".." in member_path.parts: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="ZIP contains unsafe file paths", | |
| ) | |
| target_path = (session_root / member_path).resolve() | |
| try: | |
| target_path.relative_to(session_root) | |
| except ValueError: | |
| # Resolved path escapes the intended session_root directory. | |
| raise HTTPException( | |
| status_code=400, | |
| detail="ZIP contains unsafe file paths", | |
| ) | |
| target_path.parent.mkdir(parents=True, exist_ok=True) | |
| with zf.open(member) as src, open(target_path, "wb") as dst: | |
| shutil.copyfileobj(src, dst) |
| buf = io.BytesIO() | ||
| with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: | ||
| for file_path in sorted(session_dir.iterdir()): | ||
| if file_path.is_file(): | ||
| zf.write(file_path, arcname=file_path.name) | ||
| buf.seek(0) | ||
|
|
There was a problem hiding this comment.
download_session() builds the full ZIP in a BytesIO buffer before returning it. For large sessions this can spike memory usage; consider streaming the ZIP (or writing to a temp file and streaming) so memory usage stays bounded.
| <span | ||
| role="button" | ||
| tabIndex={0} | ||
| onClick={handleDownload} | ||
| onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleDownload(e as unknown as React.MouseEvent); }} | ||
| className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors" | ||
| title="Download session files" | ||
| > | ||
| <Download size={12} /> | ||
| </span> |
There was a problem hiding this comment.
In the card view, the download/delete controls are interactive <span role="button" ...> elements nested inside the outer <button>. This is invalid markup and harms accessibility (focus/keyboard handling). Consider refactoring so these actions are real buttons not nested within another button.
|
|
||
| const handleDownload = (e: React.MouseEvent) => { | ||
| e.stopPropagation(); | ||
| window.open(downloadUrl); |
There was a problem hiding this comment.
handleDownload uses window.open(downloadUrl) without specifying noopener/noreferrer. This allows the opened page to access window.opener (reverse-tabnabbing). Prefer opening with noopener (or use an <a target="_blank" rel="noopener noreferrer">).
| window.open(downloadUrl); | |
| const newWindow = window.open(downloadUrl, "_blank", "noopener,noreferrer"); | |
| if (newWindow) { | |
| newWindow.opener = null; | |
| } |
| onClick={() => { | ||
| const fullId = sessionId.split("/").pop() ?? sessionId; | ||
| navigator.clipboard.writeText(fullId); | ||
| setCopied(true); | ||
| setTimeout(() => setCopied(false), 2000); |
There was a problem hiding this comment.
navigator.clipboard.writeText(fullId) returns a Promise, but it isn’t awaited/handled. If clipboard write fails (permissions, insecure context), this can surface as an unhandled rejection. Consider await + try/catch (or .catch(...)) and only show the “Copied!” tooltip on success.
| onClick={() => { | |
| const fullId = sessionId.split("/").pop() ?? sessionId; | |
| navigator.clipboard.writeText(fullId); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| onClick={async () => { | |
| const fullId = sessionId.split("/").pop() ?? sessionId; | |
| try { | |
| if (!navigator.clipboard || !navigator.clipboard.writeText) { | |
| return; | |
| } | |
| await navigator.clipboard.writeText(fullId); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| } catch { | |
| // Swallow clipboard errors to avoid unhandled promise rejections | |
| } |
| names = zf.namelist() | ||
| # Must contain wire.jsonl or context.jsonl at root or under exactly one directory | ||
| _VALID_FILES = ("wire.jsonl", "context.jsonl") | ||
| has_valid = any( | ||
| n in _VALID_FILES or (n.count("/") == 1 and n.endswith(_VALID_FILES)) for n in names | ||
| ) | ||
| if not has_valid: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail="ZIP must contain wire.jsonl or context.jsonl at the top level " | ||
| "(or inside a single directory)", | ||
| ) |
There was a problem hiding this comment.
The ZIP layout validation only checks that some wire.jsonl/context.jsonl exists at the top level or one directory deep, but it does not enforce the stated “flat or single-directory only” constraint (e.g., ZIPs with multiple top-level dirs or deeper nesting can still pass). Consider validating that all entries are either top-level files or all share the same single top-level directory and have no deeper paths.
| <span | ||
| role="button" | ||
| tabIndex={0} | ||
| onClick={handleDownload} | ||
| onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleDownload(e as unknown as React.MouseEvent); }} | ||
| className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shrink-0" | ||
| title="Download session files" | ||
| > | ||
| <Download size={11} /> | ||
| </span> | ||
| {session.imported && ( | ||
| <span | ||
| role="button" | ||
| tabIndex={0} | ||
| onClick={handleDeleteClick} | ||
| onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleDeleteClick(e as unknown as React.MouseEvent); }} | ||
| className="rounded p-0.5 hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors shrink-0" | ||
| title="Delete imported session" | ||
| > | ||
| <Trash2 size={11} /> | ||
| </span> | ||
| )} | ||
| </button> |
There was a problem hiding this comment.
In the compact view, the action controls are <span role="button" ...> nested inside a <button>. Nested interactive elements are invalid HTML and cause accessibility/keyboard issues. Consider restructuring so the card container isn’t a <button>, or move actions into separate <button type="button"> elements outside the main button while keeping stopPropagation().
| <span | |
| role="button" | |
| tabIndex={0} | |
| onClick={handleDownload} | |
| onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleDownload(e as unknown as React.MouseEvent); }} | |
| className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shrink-0" | |
| title="Download session files" | |
| > | |
| <Download size={11} /> | |
| </span> | |
| {session.imported && ( | |
| <span | |
| role="button" | |
| tabIndex={0} | |
| onClick={handleDeleteClick} | |
| onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleDeleteClick(e as unknown as React.MouseEvent); }} | |
| className="rounded p-0.5 hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors shrink-0" | |
| title="Delete imported session" | |
| > | |
| <Trash2 size={11} /> | |
| </span> | |
| )} | |
| </button> | |
| </button> | |
| <div className="flex items-center gap-1"> | |
| <button | |
| type="button" | |
| onClick={handleDownload} | |
| className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shrink-0" | |
| title="Download session files" | |
| > | |
| <Download size={11} /> | |
| </button> | |
| {session.imported && ( | |
| <button | |
| type="button" | |
| onClick={handleDeleteClick} | |
| className="rounded p-0.5 hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors shrink-0" | |
| title="Delete imported session" | |
| > | |
| <Trash2 size={11} /> | |
| </button> | |
| )} | |
| </div> |
src/kimi_cli/cli/export.py
Outdated
| Path | None, | ||
| typer.Option( | ||
| "--output", | ||
| "-o", | ||
| help="Output ZIP file path. Default: session-{id}.zip in current directory.", | ||
| ), | ||
| ] = None, | ||
| ) -> None: | ||
| """Export a session as a ZIP archive.""" | ||
| session_dir = _find_session_by_id(session_id) | ||
| if session_dir is None: | ||
| typer.echo(f"Error: session '{session_id}' not found.", err=True) | ||
| raise typer.Exit(code=1) | ||
|
|
||
| # Collect files | ||
| files = sorted(f for f in session_dir.iterdir() if f.is_file()) | ||
| if not files: | ||
| typer.echo(f"Error: session '{session_id}' has no files.", err=True) | ||
| raise typer.Exit(code=1) | ||
|
|
||
| # Determine output path | ||
| if output is None: | ||
| output = Path.cwd() / f"session-{session_id[:8]}.zip" |
There was a problem hiding this comment.
The --output option help says the default is session-{id}.zip, but the implementation uses only the first 8 chars (session-{session_id[:8]}.zip). Update the help text or change the default filename logic so they match.
| names = zf.namelist() | ||
| # Must contain wire.jsonl or context.jsonl at root or under exactly one directory | ||
| _VALID_FILES = ("wire.jsonl", "context.jsonl") | ||
| has_valid = any( | ||
| n in _VALID_FILES or (n.count("/") == 1 and n.endswith(_VALID_FILES)) for n in names | ||
| ) | ||
| if not has_valid: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail="ZIP must contain wire.jsonl or context.jsonl at the top level " | ||
| "(or inside a single directory)", | ||
| ) | ||
|
|
||
| session_id = uuid4().hex[:16] | ||
| imported_root = _get_imported_root() | ||
| session_dir = imported_root / session_id | ||
| session_dir.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| # Extract - handle both flat ZIPs and ZIPs with a single top-level directory | ||
| zf.extractall(session_dir) | ||
|
|
||
| # If all files are under a single subdirectory, flatten them | ||
| entries = list(session_dir.iterdir()) | ||
| if len(entries) == 1 and entries[0].is_dir(): | ||
| nested_dir = entries[0] | ||
| for item in nested_dir.iterdir(): | ||
| shutil.move(str(item), str(session_dir / item.name)) | ||
| nested_dir.rmdir() |
There was a problem hiding this comment.
🟡 ZIP import validation allows mixed-structure archives that won't be flattened correctly
The import validation at line 443-444 checks if any entry matches valid file patterns, but the flattening logic at lines 462-467 only flattens when there is exactly one top-level directory entry. A ZIP containing both root-level files and a subdirectory with wire.jsonl (e.g., readme.txt + subdir/wire.jsonl) passes validation but won't trigger flattening, leaving wire.jsonl buried inside the subdirectory. The resulting imported session appears in the UI but has no usable wire/context data since _scan_session_dir (src/kimi_cli/vis/api/sessions.py:101) looks for session_dir / "wire.jsonl" at the top level only.
Was this helpful? React with 👍 or 👎 to provide feedback.
…t in import session fix(session-card): open download URL in a new tab with security features
| has_valid = any( | ||
| n in _VALID_FILES or (n.count("/") == 1 and n.endswith(_VALID_FILES)) for n in names |
There was a problem hiding this comment.
🟡 ZIP validation uses suffix matching instead of exact filename matching
The has_valid check at src/kimi_cli/vis/api/sessions.py:449 uses n.endswith(_VALID_FILES) to validate whether a ZIP entry inside a subdirectory is a valid session file. Because str.endswith() performs suffix matching, a path like dir/notwire.jsonl passes the check because it ends with "wire.jsonl". The intent is to match exactly dir/wire.jsonl or dir/context.jsonl, but any filename ending with those suffixes will be accepted. This makes the validation overly permissive, allowing ZIPs without actual session data to be imported as empty/broken sessions.
Reproduction example
n.endswith(("wire.jsonl", "context.jsonl")) is True for "dir/notwire.jsonl" and "dir/my_context.jsonl" because Python's str.endswith() checks string suffixes, not path component names. The correct check should split on / and compare the basename.
| has_valid = any( | |
| n in _VALID_FILES or (n.count("/") == 1 and n.endswith(_VALID_FILES)) for n in names | |
| has_valid = any( | |
| n in _VALID_FILES or (n.count("/") == 1 and n.split("/")[-1] in _VALID_FILES) for n in names | |
| ) |
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
~/.kimi/imported_sessions/directorykimi export <session_id>CLI command to package a session as a ZIP archiveChanged files
src/kimi_cli/vis/api/sessions.py— download, import, delete endpoints; ZIP validationsrc/kimi_cli/cli/export.py— new CLI export commandvis/src/lib/api.ts— frontend API functions with timeoutsvis/src/features/sessions-explorer/— import button, filter toggle, delete dialog, empty statesvis/src/components/ui/alert-dialog.tsx— new shadcn AlertDialog componentvis/src/App.tsx— download button in session detail viewChecklist
make gen-changelogto update the changelog.make gen-docsto update the user documentation.