Skip to content

feat(vis): add session download, import, export and delete#1402

Merged
RealKai42 merged 8 commits intomainfrom
kaiyi/vis-download-session
Mar 11, 2026
Merged

feat(vis): add session download, import, export and delete#1402
RealKai42 merged 8 commits intomainfrom
kaiyi/vis-download-session

Conversation

@RealKai42
Copy link
Copy Markdown
Collaborator

@RealKai42 RealKai42 commented Mar 11, 2026

Summary

  • Add one-click ZIP download for session files from both the explorer card page and session detail page
  • Add session import via ZIP upload, stored in a dedicated ~/.kimi/imported_sessions/ directory
  • Add kimi export <session_id> CLI command to package a session as a ZIP archive
  • Add delete support for imported sessions with AlertDialog confirmation
  • Add "Imported" filter toggle and empty-state handling in sessions explorer
  • Validate ZIP layout on import (flat or single-directory only) and enforce 200MB upload limit

Changed files

  • src/kimi_cli/vis/api/sessions.py — download, import, delete endpoints; ZIP validation
  • src/kimi_cli/cli/export.py — new CLI export command
  • vis/src/lib/api.ts — frontend API functions with timeouts
  • vis/src/features/sessions-explorer/ — import button, filter toggle, delete dialog, empty states
  • vis/src/components/ui/alert-dialog.tsx — new shadcn AlertDialog component
  • vis/src/App.tsx — download button in session detail view

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked the related issue, if any.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have run make gen-changelog to update the changelog.
  • I have run make gen-docs to update the user documentation.

Open with Devin

Copilot AI review requested due to automatic review settings March 11, 2026 10:06
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +15 to +30
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
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
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 imported flag 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.

Comment on lines +425 to +430
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")
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +459 to +460
zf.extractall(session_dir)

Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +407 to +413
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)

Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +196 to +205
<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>
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
window.open(downloadUrl);
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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">).

Suggested change
window.open(downloadUrl);
const newWindow = window.open(downloadUrl, "_blank", "noopener,noreferrer");
if (newWindow) {
newWindow.opener = null;
}

Copilot uses AI. Check for mistakes.
Comment on lines +102 to +106
onClick={() => {
const fullId = sessionId.split("/").pop() ?? sessionId;
navigator.clipboard.writeText(fullId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
Comment on lines +440 to +451
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)",
)
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +149 to +171
<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>
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Suggested change
<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>

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +62
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"
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 12 additional findings in Devin Review.

Open in Devin Review

Comment on lines +440 to +467
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()
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Open in Devin Review

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
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 14 additional findings in Devin Review.

Open in Devin Review

Comment on lines +448 to +449
has_valid = any(
n in _VALID_FILES or (n.count("/") == 1 and n.endswith(_VALID_FILES)) for n in names
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
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
)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants