Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/desktop/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,45 @@ pub fn list_sessions() -> Result<Vec<SessionMeta>, String> {
Ok(out)
}

/// Reject session ids that could escape the sessions directory.
fn safe_session_id(id: &str) -> Result<(), String> {
if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(format!("invalid session id: {id}"));
}
Ok(())
}

/// Permanently delete a session's JSONL file.
#[tauri::command]
pub fn session_delete(id: String) -> Result<(), String> {
safe_session_id(&id)?;
let Some(home) = dirs::home_dir() else {
return Err("no home directory".into());
};
let path = home
.join(".deepcode")
.join("sessions")
.join(format!("{id}.jsonl"));
std::fs::remove_file(&path).map_err(|e| format!("delete {}: {}", path.display(), e))
}

/// Archive a session by moving its JSONL into sessions/archived/ — excluded from
/// list_sessions but recoverable from disk.
#[tauri::command]
pub fn session_archive(id: String) -> Result<(), String> {
safe_session_id(&id)?;
let Some(home) = dirs::home_dir() else {
return Err("no home directory".into());
};
let dir = home.join(".deepcode").join("sessions");
let archived = dir.join("archived");
std::fs::create_dir_all(&archived)
.map_err(|e| format!("mkdir {}: {}", archived.display(), e))?;
let from = dir.join(format!("{id}.jsonl"));
let to = archived.join(format!("{id}.jsonl"));
std::fs::rename(&from, &to).map_err(|e| format!("archive {}: {}", from.display(), e))
}

/// Path to the `deepcode` CLI so the GUI can drop users into it for advanced
/// workflows. Resolves a globally-installed `deepcode` on PATH (npm i -g
/// deepcode-cli). Bundling the CLI inside the .app is separate future work.
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ mod tools;
use commands::{
append_allow_matcher, cli_path, get_app_info, get_settings_path, list_plugins, list_sessions,
list_skills, load_keybindings, load_settings_file, open_url, read_credentials,
save_credentials, save_keybindings, save_settings_file, session_append, session_create,
session_read, session_set_title,
save_credentials, save_keybindings, save_settings_file, session_append, session_archive,
session_create, session_delete, session_read, session_set_title,
};
use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write};
use tauri::Manager;
Expand Down Expand Up @@ -46,6 +46,8 @@ pub fn run() {
session_append,
session_read,
session_set_title,
session_delete,
session_archive,
list_sessions,
list_plugins,
list_skills,
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,14 @@ export function App(): JSX.Element {
setActiveSessionId(null);
setSessionEpoch((k) => k + 1);
}}
onSessionRemoved={() => {
// The active session was archived/deleted — reset to a fresh chat.
clearAgentHistory();
setResumedMessages(undefined);
setActiveSessionId(null);
setScreen('repl');
setSessionEpoch((k) => k + 1);
}}
/>
<main className="chat-main" key={`main-${sessionEpoch}`}>
{renderScreen(
Expand All @@ -212,6 +220,7 @@ export function App(): JSX.Element {
() => setSessionEpoch((k) => k + 1),
handleInspector,
resumedMessages,
(path) => void fp.open(path),
)}
</main>
{fp.isOpen && (
Expand Down Expand Up @@ -257,6 +266,7 @@ function renderScreen(
onTurnComplete: () => void,
onInspector: (patch: Partial<InspectorData>) => void,
initialMessages?: Msg[],
onOpenFile?: (path: string) => void,
): JSX.Element {
switch (screen) {
case 'chat':
Expand All @@ -267,6 +277,7 @@ function renderScreen(
onTurnComplete={onTurnComplete}
initialMessages={initialMessages}
onInspector={onInspector}
onOpenFile={onOpenFile}
/>
);
case 'sessions':
Expand All @@ -292,6 +303,7 @@ function renderScreen(
onTurnComplete={onTurnComplete}
initialMessages={initialMessages}
onInspector={onInspector}
onOpenFile={onOpenFile}
/>
);
}
Expand Down
70 changes: 68 additions & 2 deletions apps/desktop/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@

import { useCallback, useEffect, useState } from 'react';
import { projectName } from '../lib/project.js';
import { listSessions, sessionSetTitle, type SessionMeta } from '../lib/tauri-api.js';
import {
listSessions,
sessionArchive,
sessionDelete,
sessionSetTitle,
type SessionMeta,
} from '../lib/tauri-api.js';
import { BrandMark } from './BrandMark.js';

interface SidebarProps {
Expand All @@ -18,6 +24,8 @@ interface SidebarProps {
onNewSession: () => void;
/** Triggers a re-show of the folder picker so the user can switch projects. */
onSwitchProject: () => void;
/** Called after the active session is archived/deleted so the parent resets. */
onSessionRemoved?: (id: string) => void;
}

type Bucket = 'Today' | 'Yesterday' | 'Earlier';
Expand All @@ -43,6 +51,7 @@ export function Sidebar({
onPickSession,
onNewSession,
onSwitchProject,
onSessionRemoved,
}: SidebarProps): JSX.Element {
const [sessions, setSessions] = useState<SessionMeta[]>([]);
const [now, setNow] = useState<number>(Math.floor(Date.now() / 1000));
Expand All @@ -56,9 +65,17 @@ export function Sidebar({
.catch(() => setSessions([]));
}, []);

// Reload on mount + whenever the active session changes, then poll so a
// session's auto-derived title (set on its first message) and freshly-created
// sessions surface without needing a remount.
useEffect(() => {
reload();
const t = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 30_000);
}, [reload, activeSessionId]);
useEffect(() => {
const t = setInterval(() => {
setNow(Math.floor(Date.now() / 1000));
reload();
}, 8_000);
return () => clearInterval(t);
}, [reload]);

Expand All @@ -72,6 +89,29 @@ export function Sidebar({
}
}

async function handleArchive(id: string): Promise<void> {
try {
await sessionArchive(id);
if (id === activeSessionId) onSessionRemoved?.(id);
reload();
} catch {
/* ignore — session stays listed */
}
}

async function handleDelete(id: string, label: string): Promise<void> {
if (!window.confirm(`Delete session "${label}"? This permanently removes its history.`)) {
return;
}
try {
await sessionDelete(id);
if (id === activeSessionId) onSessionRemoved?.(id);
reload();
} catch {
/* ignore — session stays listed */
}
}

const grouped: Record<Bucket, SessionMeta[]> = {
Today: [],
Yesterday: [],
Expand Down Expand Up @@ -204,6 +244,32 @@ export function Sidebar({
<span className="label">{s.title?.trim() ? s.title : shortTitle(s.id)}</span>
)}
<span className="meta">{relTime(s.updated_at_secs, now)}</span>
{editingId !== s.id && (
<span className="row-actions">
<button
type="button"
className="row-act"
title="Archive session"
onClick={(e) => {
e.stopPropagation();
void handleArchive(s.id);
}}
>
🗄
</button>
<button
type="button"
className="row-act danger"
title="Delete session"
onClick={(e) => {
e.stopPropagation();
void handleDelete(s.id, s.title?.trim() ? s.title : shortTitle(s.id));
}}
>
🗑
</button>
</span>
)}
</div>
))}
</div>
Expand Down
23 changes: 20 additions & 3 deletions apps/desktop/src/components/ToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,31 @@ interface ToolCardProps {
body?: ReactNode;
/** If true, body is a diff (line-by-line; preserves whitespace strictly). */
diff?: boolean;
/**
* If set, the target becomes a clickable "open preview" affordance — used for
* file tools (Read/Write/Edit) to load the file into the right-side panel.
*/
onOpen?: () => void;
}

export function ToolCard({ name, target, status, body, diff }: ToolCardProps): JSX.Element {
export function ToolCard({ name, target, status, body, diff, onOpen }: ToolCardProps): JSX.Element {
return (
<div className="tool-card">
<div className={'tool-card' + (onOpen ? ' openable' : '')}>
<div className="tc-head">
<span className="name">▸ {name}</span>
{target && <span className="target">{target}</span>}
{target &&
(onOpen ? (
<button
type="button"
className="target tc-open"
title="Open preview in the file panel"
onClick={onOpen}
>
{target} <span className="tc-open-caret">›</span>
</button>
) : (
<span className="target">{target}</span>
))}
{status && <Badge kind={status.kind}>{status.label}</Badge>}
</div>
{body !== undefined && <div className={diff ? 'tc-body diff' : 'tc-body'}>{body}</div>}
Expand Down
65 changes: 65 additions & 0 deletions apps/desktop/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,13 @@ select {
grid-template-rows: 1fr;
height: 100vh;
background: var(--bg-1);
/* Clear the macOS transparent titlebar (titleBarStyle:Transparent +
hiddenTitle) so the traffic lights + native drag region don't overlap the
sidebar brand / chat-header pills / inspector head. box-sizing is
border-box globally, so the grid fits within 100vh − this inset. The native
title region (top ~28px) stays OS-draggable; we deliberately do NOT add
-webkit-app-region:drag to the shell (it would make chat text unselectable). */
padding-top: 30px;
}

/* ──────────────────────────────────────────────────────────────────── */
Expand Down Expand Up @@ -357,6 +364,39 @@ select {
margin-left: auto;
flex-shrink: 0;
}
/* Hover-revealed per-session actions (archive / delete) — they replace the
relative-time meta on hover so the row doesn't get crowded. */
.sidebar .item .row-actions {
display: none;
margin-left: auto;
gap: 2px;
flex-shrink: 0;
}
.sidebar .item:hover .row-actions {
display: inline-flex;
}
.sidebar .item:hover .meta {
display: none;
}
.sidebar .item .row-act {
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 2px 4px;
border-radius: 5px;
opacity: 0.7;
filter: grayscale(0.3);
}
.sidebar .item .row-act:hover {
background: var(--bg-3);
opacity: 1;
filter: none;
}
.sidebar .item .row-act.danger:hover {
background: rgba(255, 84, 112, 0.18);
}

/* ──────────────────────────────────────────────────────────────────── */
/* Inspector rail (right column) */
Expand Down Expand Up @@ -775,6 +815,31 @@ select {
text-overflow: ellipsis;
white-space: nowrap;
}
/* File-tool cards: the target is a clickable "open preview" affordance. */
.tool-card .tc-head .tc-open {
border: none;
background: transparent;
font: inherit;
cursor: pointer;
color: var(--brand);
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border-radius: 5px;
max-width: 100%;
}
.tool-card .tc-head .tc-open:hover {
background: var(--brand-tint);
color: #b4c2ff;
}
.tool-card .tc-head .tc-open .tc-open-caret {
color: var(--text-3);
font-weight: 700;
}
.tool-card .tc-head .tc-open:hover .tc-open-caret {
color: var(--brand);
}
.tool-card .tc-head .badge {
margin-left: auto;
flex-shrink: 0;
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,44 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DeepCode</title>
<!--
Critical, render-blocking styles applied BEFORE index.css loads. In dev,
Vite injects index.css via JS, so the first paint is otherwise unstyled —
which ballooned the BrandMark <svg> into a full-size black silhouette on a
white background (the "big B&W cat" startup flash). Pinning the dark
surface + the brand-mark box here makes the first paint match the app.
-->
<style>
html,
body {
margin: 0;
height: 100%;
background: #0b0d12;
color: #f2f4f8;
}
#root {
height: 100%;
}
.mark {
width: 26px;
height: 26px;
border-radius: 7px;
background: linear-gradient(135deg, #4d6bfe 0%, #6b86ff 100%);
display: inline-flex;
align-items: center;
justify-content: center;
}
.mark.mark-lg {
width: 64px;
height: 64px;
border-radius: 16px;
}
.mark svg {
width: 72%;
height: 72%;
color: #fff;
}
</style>
</head>
<body>
<div id="root"></div>
Expand Down
Loading
Loading