Skip to content

Commit 42ba6af

Browse files
h4x0rclaude
andcommitted
feat: add kanban filtering, AI summaries, cloud sync, session persistence, and export
Five desktop app enhancements with full TDD coverage (77 new tests): - Kanban search & filtering: filterTasks pure function + KanbanFilters component (24 tests) - AI task summaries: TaskSummary component + Rust route stub (9 tests) - Cloud sync: useCloudSync hook with debounced push + pull on mount (10 tests) - Session persistence: ResumePrompt + useSessionPersistence hook (14 tests) - Export/reporting: JSON/CSV export utils + ExportButton dropdown (20 tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1138bc5 commit 42ba6af

24 files changed

Lines changed: 2059 additions & 20 deletions

crates/shepherd-server/src/routes/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod northstar;
1010
pub mod plugins;
1111
pub mod pr;
1212
pub mod replay;
13+
pub mod summaries;
1314
pub mod sync;
1415
pub mod tasks;
1516
pub mod templates;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
use axum::{extract::Path, http::StatusCode, Json};
2+
use serde::Serialize;
3+
4+
#[derive(Serialize)]
5+
pub struct SummaryResponse {
6+
pub summary: String,
7+
pub generated_at: String,
8+
}
9+
10+
pub async fn get_task_summary(
11+
Path(task_id): Path<i64>,
12+
) -> Result<Json<SummaryResponse>, StatusCode> {
13+
// Stub: return a placeholder summary for now
14+
// Full LLM integration will come when the LLM module is wired
15+
Ok(Json(SummaryResponse {
16+
summary: format!("Task {} completed successfully.", task_id),
17+
generated_at: chrono::Utc::now().to_rfc3339(),
18+
}))
19+
}

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useWebSocket } from "./hooks/useWebSocket";
1414
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
1515
import { useNotifications, notifyFromServer } from "./hooks/useNotifications";
1616
import { useAuthCallback } from "./hooks/useAuthCallback";
17+
import { useCloudSync } from "./hooks/useCloudSync";
1718
import { useStore } from "./store";
1819
import type { ServerEvent } from "./types";
1920
import type { ConnectionStatus } from "./lib/ws";
@@ -72,6 +73,7 @@ const App: React.FC = () => {
7273
useKeyboardShortcuts(wsRef);
7374
useNotifications();
7475
useAuthCallback();
76+
useCloudSync();
7577

7678
// Sync the wsClient ref into the store whenever connection status changes.
7779
// The wsRef.current is set before onStatusChange fires, so it's safe to read here.

src/features/focus/FocusView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { PermissionPrompt } from "./PermissionPrompt";
77
import { SessionPicker } from "../iterm2/SessionPicker";
88
import { SetupPrompt } from "../iterm2/SetupPrompt";
99
import { getClaudeSessions, resumeClaudeSession, startFreshSession } from "../../lib/api";
10+
import { TaskSummary } from "./TaskSummary";
1011

1112
const Terminal = React.lazy(() =>
1213
import("./Terminal").then((m) => ({ default: m.Terminal })),
@@ -146,6 +147,9 @@ export const FocusView: React.FC = () => {
146147
)}
147148
</div>
148149

150+
{/* AI summary (only rendered for done tasks) */}
151+
<TaskSummary taskId={task.id} taskStatus={task.status} />
152+
149153
{/* Content area: Terminal + DiffViewer side by side */}
150154
<div className="flex-1 flex min-h-0">
151155
{/* Center panel: Terminal + Permission prompt */}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import React from "react";
2+
import type { SessionState } from "../../types/task";
3+
4+
export interface ResumePromptProps {
5+
sessions: SessionState[];
6+
onResume: (taskId: number) => void;
7+
onFresh: (taskId: number) => void;
8+
onDismiss: (taskId: number) => void;
9+
}
10+
11+
function truncatePrompt(prompt: string): string {
12+
if (prompt.length <= 80) return prompt;
13+
return prompt.slice(0, 80) + "...";
14+
}
15+
16+
export const ResumePrompt: React.FC<ResumePromptProps> = ({
17+
sessions,
18+
onResume,
19+
onFresh,
20+
onDismiss,
21+
}) => {
22+
if (sessions.length === 0) return null;
23+
24+
return (
25+
<div data-testid="resume-prompt" className="space-y-2">
26+
{sessions.map((session) => (
27+
<div
28+
key={session.task_id}
29+
className="bg-shepherd-surface border border-shepherd-border rounded-lg p-4 flex items-start justify-between gap-4"
30+
>
31+
<div className="flex-1 min-w-0">
32+
<p className="text-sm font-medium text-shepherd-text truncate">
33+
{truncatePrompt(session.last_prompt)}
34+
</p>
35+
<p className="text-xs text-shepherd-muted-foreground mt-1">
36+
<span>{session.working_dir}</span>
37+
<span className="mx-2">&#183;</span>
38+
<span>{new Date(session.saved_at).toLocaleString()}</span>
39+
</p>
40+
</div>
41+
<div className="flex gap-2 shrink-0">
42+
<button
43+
data-testid="resume-btn"
44+
onClick={() => onResume(session.task_id)}
45+
className="px-3 py-1.5 text-xs font-medium rounded bg-shepherd-accent text-white hover:bg-shepherd-accent/90"
46+
>
47+
Resume
48+
</button>
49+
<button
50+
data-testid="fresh-btn"
51+
onClick={() => onFresh(session.task_id)}
52+
className="px-3 py-1.5 text-xs font-medium rounded bg-shepherd-surface border border-shepherd-border hover:bg-shepherd-muted"
53+
>
54+
Start Fresh
55+
</button>
56+
<button
57+
data-testid="dismiss-btn"
58+
onClick={() => onDismiss(session.task_id)}
59+
className="px-3 py-1.5 text-xs font-medium rounded text-shepherd-muted-foreground hover:text-shepherd-text"
60+
>
61+
Dismiss
62+
</button>
63+
</div>
64+
</div>
65+
))}
66+
</div>
67+
);
68+
};

src/features/focus/TaskSummary.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React, { useState, useEffect, useRef } from "react";
2+
import type { TaskStatus } from "../../types/task";
3+
4+
interface TaskSummaryProps {
5+
taskId: number;
6+
taskStatus: TaskStatus;
7+
}
8+
9+
export const TaskSummary: React.FC<TaskSummaryProps> = ({ taskId, taskStatus }) => {
10+
const [summary, setSummary] = useState<string | null>(null);
11+
const [loading, setLoading] = useState(false);
12+
const [error, setError] = useState<string | null>(null);
13+
const cachedTaskId = useRef<number | null>(null);
14+
15+
useEffect(() => {
16+
if (taskStatus !== "done") return;
17+
if (cachedTaskId.current === taskId && summary) return;
18+
19+
setLoading(true);
20+
setError(null);
21+
import("../../lib/api")
22+
.then(({ getTaskSummary }) => getTaskSummary(taskId))
23+
.then((data) => {
24+
setSummary(data.summary);
25+
cachedTaskId.current = taskId;
26+
})
27+
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load summary"))
28+
.finally(() => setLoading(false));
29+
}, [taskId, taskStatus]);
30+
31+
if (taskStatus !== "done") return null;
32+
if (loading) return <div data-testid="summary-loading" className="px-4 py-3 border-b border-shepherd-border bg-shepherd-surface/50 text-sm text-shepherd-muted">Generating summary...</div>;
33+
if (error) return <div data-testid="summary-error" className="px-4 py-3 border-b border-shepherd-border bg-shepherd-surface/50 text-sm text-shepherd-red">Summary unavailable</div>;
34+
if (!summary) return null;
35+
36+
return (
37+
<div data-testid="task-summary" className="px-4 py-3 border-b border-shepherd-border bg-shepherd-surface/50">
38+
<h3 className="text-xs font-semibold text-shepherd-muted uppercase tracking-wider mb-1">Summary</h3>
39+
<p className="text-sm text-shepherd-text leading-relaxed">{summary}</p>
40+
</div>
41+
);
42+
};
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { render, screen, fireEvent } from "@testing-library/react";
3+
import { ResumePrompt } from "../ResumePrompt";
4+
import type { SessionState } from "../../../types/task";
5+
6+
function makeSession(overrides: Partial<SessionState> = {}): SessionState {
7+
return {
8+
task_id: 1,
9+
session_id: "sess-abc",
10+
last_prompt: "Implement the user authentication flow",
11+
working_dir: "/home/user/project",
12+
saved_at: "2026-03-23T10:00:00Z",
13+
...overrides,
14+
};
15+
}
16+
17+
describe("ResumePrompt", () => {
18+
const onResume = vi.fn();
19+
const onFresh = vi.fn();
20+
const onDismiss = vi.fn();
21+
22+
it("renders nothing when sessions array is empty", () => {
23+
const { container } = render(
24+
<ResumePrompt sessions={[]} onResume={onResume} onFresh={onFresh} onDismiss={onDismiss} />,
25+
);
26+
expect(container.innerHTML).toBe("");
27+
});
28+
29+
it("renders a banner for each interrupted session", () => {
30+
const sessions = [
31+
makeSession({ task_id: 1 }),
32+
makeSession({ task_id: 2, last_prompt: "Fix the build pipeline" }),
33+
];
34+
render(
35+
<ResumePrompt sessions={sessions} onResume={onResume} onFresh={onFresh} onDismiss={onDismiss} />,
36+
);
37+
const resumeButtons = screen.getAllByTestId("resume-btn");
38+
expect(resumeButtons).toHaveLength(2);
39+
});
40+
41+
it("shows truncated prompt text (first 80 chars + '...')", () => {
42+
const longPrompt =
43+
"This is a very long prompt that exceeds eighty characters and should be truncated with an ellipsis at the end";
44+
const sessions = [makeSession({ last_prompt: longPrompt })];
45+
render(
46+
<ResumePrompt sessions={sessions} onResume={onResume} onFresh={onFresh} onDismiss={onDismiss} />,
47+
);
48+
expect(screen.getByText(longPrompt.slice(0, 80) + "...")).toBeInTheDocument();
49+
});
50+
51+
it("shows full prompt if under 80 chars (no ellipsis)", () => {
52+
const shortPrompt = "Fix the login bug";
53+
const sessions = [makeSession({ last_prompt: shortPrompt })];
54+
render(
55+
<ResumePrompt sessions={sessions} onResume={onResume} onFresh={onFresh} onDismiss={onDismiss} />,
56+
);
57+
expect(screen.getByText(shortPrompt)).toBeInTheDocument();
58+
// Should not have an ellipsis
59+
expect(screen.queryByText(shortPrompt + "...")).not.toBeInTheDocument();
60+
});
61+
62+
it("shows working directory", () => {
63+
const sessions = [makeSession({ working_dir: "/projects/my-app" })];
64+
render(
65+
<ResumePrompt sessions={sessions} onResume={onResume} onFresh={onFresh} onDismiss={onDismiss} />,
66+
);
67+
expect(screen.getByText("/projects/my-app")).toBeInTheDocument();
68+
});
69+
70+
it("Resume button calls onResume with correct taskId", () => {
71+
const sessions = [makeSession({ task_id: 42 })];
72+
render(
73+
<ResumePrompt sessions={sessions} onResume={onResume} onFresh={onFresh} onDismiss={onDismiss} />,
74+
);
75+
fireEvent.click(screen.getByTestId("resume-btn"));
76+
expect(onResume).toHaveBeenCalledWith(42);
77+
});
78+
79+
it("Fresh button calls onFresh with correct taskId", () => {
80+
const sessions = [makeSession({ task_id: 7 })];
81+
render(
82+
<ResumePrompt sessions={sessions} onResume={onResume} onFresh={onFresh} onDismiss={onDismiss} />,
83+
);
84+
fireEvent.click(screen.getByTestId("fresh-btn"));
85+
expect(onFresh).toHaveBeenCalledWith(7);
86+
});
87+
88+
it("Dismiss button calls onDismiss with correct taskId", () => {
89+
const sessions = [makeSession({ task_id: 99 })];
90+
render(
91+
<ResumePrompt sessions={sessions} onResume={onResume} onFresh={onFresh} onDismiss={onDismiss} />,
92+
);
93+
fireEvent.click(screen.getByTestId("dismiss-btn"));
94+
expect(onDismiss).toHaveBeenCalledWith(99);
95+
});
96+
});

0 commit comments

Comments
 (0)