Skip to content

Commit 0206e3c

Browse files
MarkShawn2020claude
andcommitted
feat(chat): 全局 Readable Slash Command 设置
- 将 "Original View" 重命名为 "Readable Slash Command" - 新增 useReadableText hook 统一处理 slash command 转换 - 应用到所有显示 session summary 的位置: - ProjectList, SessionList, MessageView - VerticalFeatureTabs, RecentActivity, ProjectDashboard - tooltip prompts 先转换再截断,避免正则匹配失败 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fbeb2fa commit 0206e3c

9 files changed

Lines changed: 181 additions & 41 deletions

File tree

src/components/GlobalHeader/VerticalFeatureTabs.tsx

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
ChevronDownIcon,
55
ChevronRightIcon,
66
ChevronLeftIcon,
7-
ChatBubbleIcon,
87
DotsVerticalIcon,
98
} from "@radix-ui/react-icons";
109
import {
@@ -15,7 +14,12 @@ import {
1514
} from "@/store";
1615
import { useNavigate, useInvokeQuery } from "@/hooks";
1716
import { invoke } from "@tauri-apps/api/core";
18-
import type { Session } from "@/types";
17+
import type { Session, Message } from "@/types";
18+
import {
19+
Tooltip,
20+
TooltipTrigger,
21+
TooltipContent,
22+
} from "@/components/ui/tooltip";
1923
import {
2024
DropdownMenu,
2125
DropdownMenuContent,
@@ -25,6 +29,7 @@ import { ProjectLogo } from "@/views/Workspace/ProjectLogo";
2529
import { SessionDropdownMenuItems } from "@/components/shared/SessionMenuItems";
2630
import { NewTerminalSplitButton } from "@/components/ui/new-terminal-button";
2731
import type { WorkspaceData, WorkspaceProject } from "@/views/Workspace/types";
32+
import { useReadableText, restoreSlashCommand } from "@/views/Chat/utils";
2833

2934
const MIN_WIDTH = 180;
3035
const MAX_WIDTH = 400;
@@ -133,6 +138,7 @@ function ProjectSessionsGroup({
133138
}: ProjectSessionsGroupProps) {
134139
const [workspace, setWorkspace] = useAtom(workspaceDataAtom);
135140
const navigate = useNavigate();
141+
const toReadable = useReadableText();
136142

137143
// Fetch all CC sessions, then filter by project path
138144
const { data: allSessions = [], isLoading } = useInvokeQuery<Session[]>(
@@ -181,7 +187,7 @@ function ProjectSessionsGroup({
181187
const currentProject = currentWorkspace.projects.find((p) => p.id === project.id);
182188
if (!currentProject) return currentWorkspace;
183189

184-
const title = session.summary || "Untitled";
190+
const title = toReadable(session.summary) || "Untitled";
185191
const command = `claude --resume "${session.id}"`;
186192
const panels = currentProject.panels || [];
187193
const panelId = panels[0]?.id;
@@ -391,6 +397,10 @@ interface SessionItemProps {
391397
}
392398

393399
function SessionItem({ session, onResume }: SessionItemProps) {
400+
const [userPrompts, setUserPrompts] = useState<string[] | null>(null);
401+
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
402+
const toReadable = useReadableText();
403+
394404
const formatDate = (ts: number) => {
395405
const d = new Date(ts * 1000);
396406
const now = new Date();
@@ -408,21 +418,69 @@ function SessionItem({ session, onResume }: SessionItemProps) {
408418
}
409419
};
410420

421+
// Lazy-load user prompts when tooltip opens
422+
const handleTooltipOpen = async (open: boolean) => {
423+
setIsTooltipOpen(open);
424+
if (open && userPrompts === null) {
425+
try {
426+
const messages = await invoke<Message[]>("get_session_messages", {
427+
projectId: session.project_id,
428+
sessionId: session.id,
429+
});
430+
const prompts = messages
431+
.filter((m) => m.role === "user")
432+
.map((m) => {
433+
// Convert first, then truncate (order matters for regex matching)
434+
const text = restoreSlashCommand(m.content.trim());
435+
return text.length > 100 ? text.slice(0, 100) + "..." : text;
436+
});
437+
setUserPrompts(prompts);
438+
} catch {
439+
setUserPrompts([]);
440+
}
441+
}
442+
};
443+
411444
return (
412445
<div className="flex items-center gap-0.5 group">
413-
<button
414-
onClick={onResume}
415-
className="flex-1 flex items-center gap-1.5 px-2 py-1 rounded text-left transition-colors text-muted-foreground hover:text-ink hover:bg-card-alt min-w-0"
416-
title={`Resume: ${session.summary || "Untitled"}`}
417-
>
418-
<ChatBubbleIcon className="w-2.5 h-2.5 flex-shrink-0" />
419-
<span className="text-xs truncate flex-1">
420-
{session.summary || "Untitled"}
421-
</span>
422-
<span className="text-[10px] text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
423-
{formatDate(session.last_modified)}
424-
</span>
425-
</button>
446+
<Tooltip open={isTooltipOpen} onOpenChange={handleTooltipOpen}>
447+
<TooltipTrigger asChild>
448+
<button
449+
onClick={onResume}
450+
className="flex-1 flex items-center gap-1.5 px-2 py-1 rounded text-left transition-colors text-muted-foreground hover:text-ink hover:bg-card-alt min-w-0"
451+
>
452+
<span className="text-xs truncate flex-1">
453+
{toReadable(session.summary) || "Untitled"}
454+
</span>
455+
<span className="text-[10px] text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
456+
{formatDate(session.last_modified)}
457+
</span>
458+
</button>
459+
</TooltipTrigger>
460+
<TooltipContent side="right" className="max-w-[300px] p-2 bg-card text-ink border border-border">
461+
<div className="space-y-1.5">
462+
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
463+
User Prompts ({userPrompts?.length ?? "..."})
464+
</div>
465+
{userPrompts === null ? (
466+
<div className="text-xs text-muted-foreground">Loading...</div>
467+
) : userPrompts.length === 0 ? (
468+
<div className="text-xs text-muted-foreground">No prompts</div>
469+
) : (
470+
<div className="space-y-1 max-h-[200px] overflow-y-auto">
471+
{userPrompts.map((prompt, i) => (
472+
<div
473+
key={i}
474+
className="text-xs text-ink/80 bg-muted/50 rounded px-1.5 py-1 truncate"
475+
>
476+
{prompt}
477+
</div>
478+
))}
479+
</div>
480+
)}
481+
</div>
482+
</TooltipContent>
483+
</Tooltip>
426484
<DropdownMenu>
427485
<DropdownMenuTrigger asChild>
428486
<button

src/components/home/RecentActivity.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { FolderOpen, MessageSquare } from "lucide-react";
22
import type { Project, Session } from "../../types";
3+
import { useReadableText } from "../../views/Chat/utils";
34

45
type ActivityItem =
56
| { type: "project"; data: Project }
@@ -20,6 +21,8 @@ export function RecentActivity({
2021
onSessionClick,
2122
maxItems = 5,
2223
}: RecentActivityProps) {
24+
const toReadable = useReadableText();
25+
2326
// Merge and sort by last_modified
2427
const activities: ActivityItem[] = [
2528
...projects.map((p) => ({ type: "project" as const, data: p })),
@@ -94,7 +97,7 @@ export function RecentActivity({
9497
<MessageSquare className="w-4 h-4 text-muted-foreground/70 shrink-0" />
9598
<div className="flex-1 min-w-0">
9699
<p className="text-sm text-foreground truncate group-hover:text-primary transition-colors">
97-
{session.summary || "Untitled session"}
100+
{toReadable(session.summary) || "Untitled session"}
98101
</p>
99102
<p className="text-xs text-muted-foreground">
100103
{session.message_count} messages

src/components/shared/SessionMenuItems.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export function SessionDropdownMenuItems({
8484
<DropdownMenuSeparator />
8585
{setOriginalChat && (
8686
<DropdownMenuCheckboxItem checked={originalChat} onCheckedChange={setOriginalChat}>
87-
Original View
87+
Readable Slash Command
8888
</DropdownMenuCheckboxItem>
8989
)}
9090
{setMarkdownPreview && (
@@ -153,7 +153,7 @@ export function SessionContextMenuItems({
153153
<ContextMenuSeparator />
154154
{setOriginalChat && (
155155
<ContextMenuCheckboxItem checked={originalChat} onCheckedChange={setOriginalChat}>
156-
Original View
156+
Readable Slash Command
157157
</ContextMenuCheckboxItem>
158158
)}
159159
{setMarkdownPreview && (

src/views/Chat/MessageView.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { useAtom } from "jotai";
2121
import { originalChatAtom, markdownPreviewAtom } from "../../store";
2222
import { CollapsibleContent } from "./CollapsibleContent";
2323
import { ExportDialog } from "./ExportDialog";
24-
import { restoreSlashCommand } from "./utils";
24+
import { useReadableText } from "./utils";
2525
import { useAppConfig } from "../../context";
2626
import type { Message } from "../../types";
2727

@@ -38,9 +38,10 @@ export function MessageView({ projectId, projectPath, sessionId, summary: initia
3838
const [messages, setMessages] = useState<Message[]>([]);
3939
const [loading, setLoading] = useState(true);
4040
const [freshSummary, setFreshSummary] = useState<string | null>(initialSummary);
41-
const displaySummary = freshSummary ? restoreSlashCommand(freshSummary) : "Session";
4241
const [originalChat, setOriginalChat] = useAtom(originalChatAtom);
4342
const [markdownPreview, setMarkdownPreview] = useAtom(markdownPreviewAtom);
43+
const toReadable = useReadableText();
44+
const displaySummary = toReadable(freshSummary) || "Session";
4445
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
4546
const [exportDialogOpen, setExportDialogOpen] = useState(false);
4647
const [sessionFilePath, setSessionFilePath] = useState("");
@@ -58,9 +59,7 @@ export function MessageView({ projectId, projectPath, sessionId, summary: initia
5859
.catch(() => {});
5960
}, [projectId, sessionId]);
6061

61-
const processContent = (content: string) => {
62-
return originalChat ? restoreSlashCommand(content) : content;
63-
};
62+
const processContent = (content: string) => toReadable(content);
6463

6564
const handleCopyContent = (content: string) => {
6665
invoke("copy_to_clipboard", { text: content });

src/views/Chat/ProjectList.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useAtom } from "jotai";
66
import { chatViewModeAtom, allProjectsSortByAtom, hideEmptySessionsAllAtom } from "../../store";
77
import { useAppConfig } from "../../context";
88
import { VirtualChatList } from "./VirtualChatList";
9-
import { formatRelativeTime } from "./utils";
9+
import { formatRelativeTime, useReadableText } from "./utils";
1010
import { useInvokeQuery } from "../../hooks";
1111
import type { Project, Session, ChatMessage, SearchResult, ChatsResponse } from "../../types";
1212

@@ -19,6 +19,7 @@ interface ProjectListProps {
1919
export function ProjectList({ onSelectProject, onSelectSession, onSelectChat }: ProjectListProps) {
2020
const { formatPath } = useAppConfig();
2121
const [viewMode, setViewMode] = useAtom(chatViewModeAtom);
22+
const toReadable = useReadableText();
2223

2324
// Use react-query for cached data fetching
2425
const { data: projects, isLoading: loadingProjects } = useInvokeQuery<Project[]>(["projects"], "list_projects");
@@ -270,7 +271,7 @@ export function ProjectList({ onSelectProject, onSelectSession, onSelectChat }:
270271
onClick={() => onSelectSession(session)}
271272
className="w-full text-left bg-card rounded-xl p-4 border border-border hover:border-primary transition-colors"
272273
>
273-
<p className="font-medium text-ink line-clamp-2">{session.summary || "Untitled session"}</p>
274+
<p className="font-medium text-ink line-clamp-2">{toReadable(session.summary) || "Untitled session"}</p>
274275
<p className="text-sm text-muted-foreground mt-1 truncate">
275276
{session.project_path ? formatPath(session.project_path) : session.project_id}
276277
</p>

src/views/Chat/SessionList.tsx

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import { ContextFileItem } from "../../components/ContextFileItem";
77
import { useAtom } from "jotai";
88
import { sessionContextTabAtom, sessionSelectModeAtom, hideEmptySessionsAtom, userPromptsOnlyAtom } from "../../store";
99
import { useAppConfig } from "../../context";
10-
import { formatDate } from "./utils";
10+
import { formatDate, useReadableText } from "./utils";
1111
import { useInvokeQuery } from "../../hooks";
12-
import type { Session, ContextFile, Message, SearchResult } from "../../types";
12+
import type { Session, ContextFile, Message, SearchResult, SessionUsageEntry, SessionUsage } from "../../types";
1313

1414
interface SessionListProps {
1515
projectId: string;
@@ -18,8 +18,22 @@ interface SessionListProps {
1818
onSelect: (s: Session) => void;
1919
}
2020

21+
// Format token count (e.g., 1234567 -> "1.23M")
22+
function formatTokens(n: number): string {
23+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
24+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
25+
return n.toString();
26+
}
27+
28+
// Format cost (e.g., 0.1234 -> "$0.12")
29+
function formatCost(cost: number): string {
30+
if (cost < 0.01) return `$${cost.toFixed(4)}`;
31+
return `$${cost.toFixed(2)}`;
32+
}
33+
2134
export function SessionList({ projectId, projectPath, onBack, onSelect }: SessionListProps) {
2235
const { formatPath } = useAppConfig();
36+
const toReadable = useReadableText();
2337

2438
// Use react-query for cached data
2539
const { data: sessions = [], isLoading: loadingSessions } = useInvokeQuery<Session[]>(
@@ -37,6 +51,33 @@ export function SessionList({ projectId, projectPath, onBack, onSelect }: Sessio
3751
{ projectPath },
3852
);
3953

54+
// Load usage data separately (lazy loading for performance)
55+
const { data: usageData = [] } = useInvokeQuery<SessionUsageEntry[]>(
56+
["sessionsUsage", projectId],
57+
"get_sessions_usage",
58+
{ projectId }
59+
);
60+
61+
// Create usage map for quick lookup
62+
const usageMap = useMemo(() => {
63+
const map = new Map<string, SessionUsage>();
64+
usageData.forEach((entry) => map.set(entry.session_id, entry.usage));
65+
return map;
66+
}, [usageData]);
67+
68+
// Calculate total usage for all sessions
69+
const totalUsage = useMemo(() => {
70+
let total = { input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, cost_usd: 0 };
71+
usageData.forEach((entry) => {
72+
total.input_tokens += entry.usage.input_tokens;
73+
total.output_tokens += entry.usage.output_tokens;
74+
total.cache_creation_tokens += entry.usage.cache_creation_tokens;
75+
total.cache_read_tokens += entry.usage.cache_read_tokens;
76+
total.cost_usd += entry.usage.cost_usd;
77+
});
78+
return total;
79+
}, [usageData]);
80+
4081
const globalContext = useMemo(() => allContextFiles.filter((f) => f.scope === "global"), [allContextFiles]);
4182
const loading = loadingSessions || loadingContext;
4283

@@ -108,7 +149,7 @@ generator: "Lovcode"
108149
.replace(/\s+/g, "-");
109150
const toc = selected
110151
.map((s, i) => {
111-
const title = `Session ${i + 1}: ${s.summary || "Untitled"}`;
152+
const title = `Session ${i + 1}: ${toReadable(s.summary) || "Untitled"}`;
112153
return `- [${title}](#${toAnchor(title)})`;
113154
})
114155
.join("\n");
@@ -126,7 +167,7 @@ generator: "Lovcode"
126167
.join("\n\n---\n\n");
127168
const msgCountLabel = userPromptsOnly ? `${messages.length} prompts` : `${session.message_count} messages`;
128169
const meta = `_${msgCountLabel} · ${formatDate(session.last_modified)}_`;
129-
parts.push(`## Session ${i + 1}: ${session.summary || "Untitled"}\n\n${meta}\n\n${sessionMd}`);
170+
parts.push(`## Session ${i + 1}: ${toReadable(session.summary) || "Untitled"}\n\n${meta}\n\n${sessionMd}`);
130171
}
131172
const body = parts.join("\n\n<br>\n\n---\n\n<br>\n\n");
132173
const header = `# ${projectName}
@@ -162,9 +203,21 @@ generator: "Lovcode"
162203
<button onClick={onBack} className="text-muted-foreground hover:text-ink mb-2 flex items-center gap-1 text-sm">
163204
<span></span> Projects
164205
</button>
165-
<h1 className="font-serif text-2xl font-semibold text-ink truncate">
166-
{projectPath ? formatPath(projectPath) : projectId}
167-
</h1>
206+
<div className="flex items-center justify-between gap-4">
207+
<h1 className="font-serif text-2xl font-semibold text-ink truncate">
208+
{projectPath ? formatPath(projectPath) : projectId}
209+
</h1>
210+
{totalUsage.cost_usd > 0 && (
211+
<div className="flex items-center gap-3 text-sm text-muted-foreground shrink-0">
212+
<span title="Total tokens (input + output)">
213+
{formatTokens(totalUsage.input_tokens + totalUsage.output_tokens)} tokens
214+
</span>
215+
<span className="font-medium text-primary" title="Estimated cost">
216+
{formatCost(totalUsage.cost_usd)}
217+
</span>
218+
</div>
219+
)}
220+
</div>
168221
</header>
169222

170223
<div className="relative mb-6">
@@ -305,6 +358,7 @@ generator: "Lovcode"
305358
<div className="space-y-3">
306359
{filteredSessions.map((session) => {
307360
const isSelected = selectedIds.has(session.id);
361+
const usage = usageMap.get(session.id);
308362
return (
309363
<div
310364
key={session.id}
@@ -315,10 +369,21 @@ generator: "Lovcode"
315369
>
316370
<div className="flex items-start justify-between gap-2">
317371
<div className="flex-1 min-w-0">
318-
<p className="font-medium text-ink line-clamp-2">{session.summary || "Untitled session"}</p>
319-
<p className="text-sm text-muted-foreground mt-2">
320-
{session.message_count} messages · {formatDate(session.last_modified)}
321-
</p>
372+
<p className="font-medium text-ink line-clamp-2">{toReadable(session.summary) || "Untitled session"}</p>
373+
<div className="flex items-center gap-3 mt-2 text-sm text-muted-foreground">
374+
<span>{session.message_count} messages</span>
375+
<span>·</span>
376+
<span>{formatDate(session.last_modified)}</span>
377+
{usage && usage.cost_usd > 0 && (
378+
<>
379+
<span>·</span>
380+
<span title={`Input: ${formatTokens(usage.input_tokens)}, Output: ${formatTokens(usage.output_tokens)}`}>
381+
{formatTokens(usage.input_tokens + usage.output_tokens)} tokens
382+
</span>
383+
<span className="text-primary font-medium">{formatCost(usage.cost_usd)}</span>
384+
</>
385+
)}
386+
</div>
322387
</div>
323388
{selectMode && (
324389
<input

src/views/Chat/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ export { ExportDialog } from "./ExportDialog";
55
export { MessageView } from "./MessageView";
66
export { CollapsibleContent } from "./CollapsibleContent";
77
export { CopyButton } from "./CopyButton";
8-
export { restoreSlashCommand, formatRelativeTime, formatDate } from "./utils";
8+
export { restoreSlashCommand, formatRelativeTime, formatDate, useReadableText } from "./utils";
99
export type { SortKey, ChatViewMode, ExportFormat, MarkdownStyle } from "./types";

0 commit comments

Comments
 (0)