Skip to content

Commit 8a56886

Browse files
committed
fix: conversations列表列显示不全、Enter无响应、无法触发总结
- InteractiveList: 没有renderSidePreview时不使用双栏布局,列宽按全终端宽度计算 - InteractiveList: 全固定宽度列时按实际总宽判断是否裁剪,而非无条件裁剪 - ConversationsView: 已总结对话Enter跳转笔记详情,未总结显示提示并支持s键总结 - 新增 GET /api/notes/by-conversation/:id 接口 Co-Authored-By: Rayner Zeng <1361209507@qq.com>
1 parent a917382 commit 8a56886

5 files changed

Lines changed: 86 additions & 16 deletions

File tree

server/src/cli/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,10 @@ export class CrystalClient {
264264
}>('GET', `/api/conversations${qs ? `?${qs}` : ''}`);
265265
}
266266

267+
async getNoteByConversation(conversationId: string) {
268+
return this.request<{ id: number } | null>('GET', `/api/notes/by-conversation/${encodeURIComponent(conversationId)}`);
269+
}
270+
267271
async search(query: string, limit = 10) {
268272
return this.request<Array<{
269273
note_id: number; title: string; project_name: string; score: number; tags: string[];

server/src/cli/ui/App.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,14 @@ export function App({ client, initialView }: AppProps) {
124124
source={props.source as string | undefined}
125125
status={props.status as string | undefined}
126126
search={props.search as string | undefined}
127-
onSelect={(conv: ConversationItem) => {
128-
if (conv.status === 'summarized') {
129-
push({ type: 'search', props: { initialQuery: conv.project_name || conv.id } });
127+
onSelect={async (conv: ConversationItem) => {
128+
try {
129+
const note = await client.getNoteByConversation(conv.id);
130+
if (note) {
131+
push({ type: 'note-detail', props: { noteId: note.id } });
132+
}
133+
} catch {
134+
// note not found — no-op, view handles the hint
130135
}
131136
}}
132137
onSearch={() => push({ type: 'search', props: {} })}

server/src/cli/ui/components/InteractiveList.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ interface InteractiveListProps<T> {
3434
onQuit: () => void;
3535
/** Called when user presses r to retry */
3636
onRetry?: () => void;
37+
/** Called when user presses s to summarize the current item */
38+
onSummarize?: (item: T | null) => void;
3739
/** Render inline preview for selected item (narrow mode) */
3840
renderPreview?: (item: T) => string | null;
3941
/** Render side panel preview (wide mode). Receives available width for truncation. */
@@ -57,7 +59,7 @@ const PREVIEW_LINES = 3;
5759
*/
5860
export function InteractiveList<T>({
5961
items, columns, total, loading, error, hasMore,
60-
onLoadMore, onSelect, onSearch, onQuit, onRetry,
62+
onLoadMore, onSelect, onSearch, onQuit, onRetry, onSummarize,
6163
renderPreview, renderSidePreview,
6264
extraHints, title, keyboardActive = true,
6365
}: InteractiveListProps<T>) {
@@ -66,8 +68,11 @@ export function InteractiveList<T>({
6668
const { columns: termCols, rows: termRows, isWide } = useTerminalSize();
6769
const t = getLocale();
6870

71+
// Only use dual-pane layout when wide AND side preview is provided
72+
const useSideLayout = isWide && !!renderSidePreview;
73+
6974
// Calculate viewport height
70-
const viewportHeight = Math.max(3, termRows - CHROME_LINES - (isWide ? 0 : PREVIEW_LINES));
75+
const viewportHeight = Math.max(3, termRows - CHROME_LINES - (useSideLayout ? 0 : PREVIEW_LINES));
7176

7277
// C2 fix: refs for values read in handleAction to avoid stale closures
7378
const cursorRef = useRef(cursor);
@@ -125,8 +130,11 @@ export function InteractiveList<T>({
125130
case 'retry':
126131
onRetry?.();
127132
break;
133+
case 'summarize':
134+
onSummarize?.(currentItems.length > 0 ? currentItems[cursorRef.current] : null);
135+
break;
128136
}
129-
}, [viewportHeight, onSelect, onSearch, onQuit, onRetry]);
137+
}, [viewportHeight, onSelect, onSearch, onQuit, onRetry, onSummarize]);
130138

131139
useKeyboard({ active: keyboardActive, onAction: handleAction });
132140

@@ -144,7 +152,7 @@ export function InteractiveList<T>({
144152
const selectedItem = items[cursor] ?? null;
145153

146154
// Available width for list content (prefix " ▸ " = 3 chars)
147-
const listPanelWidth = isWide ? Math.floor(termCols * 0.4) : termCols;
155+
const listPanelWidth = useSideLayout ? Math.floor(termCols * 0.4) : termCols;
148156
const availableListWidth = listPanelWidth - 3;
149157

150158
// Column layout: flex column (no width) fills remaining space.
@@ -155,16 +163,26 @@ export function InteractiveList<T>({
155163
// Find the flex column (first column without explicit width)
156164
const flexIdx = columns.findIndex(c => !c.width);
157165

158-
// Start with all columns, drop rightmost fixed columns until flex has enough space
166+
// Start with all columns, drop rightmost fixed columns until content fits
159167
let cols = [...columns];
168+
const calcTotalWidth = (arr: typeof columns) => {
169+
const fixedSum = arr.reduce((s, c) => s + (c.width || 10), 0);
170+
const gaps = (arr.length - 1) * 2;
171+
return fixedSum + gaps;
172+
};
160173
const calcFlexWidth = (arr: typeof columns) => {
161174
const fixedSum = arr.reduce((s, c, i) => i === flexIdx ? s : s + (c.width || 10), 0);
162175
const gaps = (arr.length - 1) * 2;
163176
return availableListWidth - fixedSum - gaps;
164177
};
165178

166179
// Drop from the right, but never drop the flex column
167-
while (cols.length > 1 && (flexIdx < 0 || calcFlexWidth(cols) < MIN_FLEX_WIDTH)) {
180+
const shouldDrop = () => {
181+
if (flexIdx >= 0) return calcFlexWidth(cols) < MIN_FLEX_WIDTH;
182+
// All fixed-width columns: drop when total exceeds available width
183+
return calcTotalWidth(cols) > availableListWidth;
184+
};
185+
while (cols.length > 1 && shouldDrop()) {
168186
// Find rightmost droppable column (not the flex column)
169187
let dropIdx = -1;
170188
for (let i = cols.length - 1; i >= 0; i--) {
@@ -213,7 +231,7 @@ export function InteractiveList<T>({
213231

214232
// Inline preview for narrow mode — fixed height, pinned at bottom
215233
function renderInlinePreview() {
216-
if (isWide || !renderPreview) return null;
234+
if (useSideLayout || !renderPreview) return null;
217235
const previewText = selectedItem ? renderPreview(selectedItem) : null;
218236
const previewContentWidth = termCols - 2; // 1 char padding each side
219237
const sepLine = '┄'.repeat(previewContentWidth);
@@ -231,7 +249,7 @@ export function InteractiveList<T>({
231249
}
232250

233251
// Wide mode: side-by-side layout
234-
if (isWide && renderSidePreview) {
252+
if (useSideLayout) {
235253
const listWidth = Math.floor(termCols * 0.4);
236254
const previewWidth = termCols - listWidth - 3;
237255

server/src/cli/ui/views/ConversationsView.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import React, { useCallback, useMemo } from 'react';
1+
import React, { useCallback, useMemo, useState } from 'react';
22
import { InteractiveList, type ColumnDef } from '../components/InteractiveList.js';
33
import { usePagination } from '../hooks/usePagination.js';
44
import { getLocale } from '../locale/index.js';
55
import { truncate } from '../../formatter.js';
66
import type { CrystalClient } from '../../client.js';
7+
import type { Hint } from '../components/StatusBar.js';
78

89
export interface ConversationItem {
910
id: string;
@@ -19,21 +20,22 @@ interface ConversationsViewProps {
1920
source?: string;
2021
status?: string;
2122
search?: string;
22-
/** Called when user selects a conversation */
23+
/** Called when user selects a summarized conversation (to view its note) */
2324
onSelect: (conversation: ConversationItem) => void;
2425
onSearch: () => void;
2526
onQuit: () => void;
2627
}
2728

2829
export function ConversationsView({ client, source, status, search, onSelect, onSearch, onQuit }: ConversationsViewProps) {
2930
const t = getLocale();
31+
const [summarizing, setSummarizing] = useState<string | null>(null);
3032

3133
const fetchPage = useCallback(async (offset: number, limit: number) => {
3234
const data = await client.getConversations({ source, status, search, offset, limit });
3335
return { items: data.items as ConversationItem[], total: data.total };
3436
}, [client, source, status, search]);
3537

36-
const { items, total, loading, error, hasMore, loadMore, retry } = usePagination<ConversationItem>({ fetchPage });
38+
const { items, total, loading, error, hasMore, loadMore, retry, reload } = usePagination<ConversationItem>({ fetchPage });
3739

3840
const columns: ColumnDef[] = useMemo(() => [
3941
{ header: 'ID', accessor: (c: ConversationItem) => truncate(c.id, 12), width: 14 },
@@ -44,6 +46,25 @@ export function ConversationsView({ client, source, status, search, onSelect, on
4446
{ header: t.headerLastActive, accessor: (c: ConversationItem) => c.last_message_at ? new Date(c.last_message_at).toLocaleDateString() : '', width: 12 },
4547
], [t]);
4648

49+
const handleSelect = useCallback((item: ConversationItem) => {
50+
if (item.status === 'summarized') {
51+
onSelect(item);
52+
}
53+
// Not summarized: do nothing on Enter, preview shows hint
54+
}, [onSelect]);
55+
56+
const handleSummarize = useCallback(async (item: ConversationItem | null) => {
57+
if (!item || item.status === 'summarized' || summarizing) return;
58+
setSummarizing(item.id);
59+
try {
60+
await client.summarize(item.id);
61+
reload();
62+
} catch { /* ignore */ }
63+
finally { setSummarizing(null); }
64+
}, [client, summarizing, reload]);
65+
66+
const extraHints: Hint[] = [{ key: 's', label: t.hints.summarize.split(':')[1] }];
67+
4768
return (
4869
<InteractiveList<ConversationItem>
4970
items={items}
@@ -53,12 +74,18 @@ export function ConversationsView({ client, source, status, search, onSelect, on
5374
error={error}
5475
hasMore={hasMore}
5576
onLoadMore={loadMore}
56-
onSelect={(item) => onSelect(item)}
77+
onSelect={handleSelect}
5778
onSearch={onSearch}
5879
onQuit={onQuit}
5980
onRetry={retry}
81+
onSummarize={handleSummarize}
82+
extraHints={extraHints}
6083
title={t.conversationsTitle}
61-
renderPreview={(item) => `${item.source} | ${item.project_name} | ${item.message_count} msgs | ${item.status}`}
84+
renderPreview={(item) => {
85+
if (summarizing === item.id) return `⟳ ...`;
86+
if (item.status === 'summarized') return `${item.source} | ${item.project_name} | ${item.message_count} msgs`;
87+
return `${t.notSummarized}${t.pressSToSummarize}`;
88+
}}
6289
/>
6390
);
6491
}

server/src/routes/notes.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ export async function noteRoutes(app: FastifyInstance) {
125125
};
126126
});
127127

128+
// Get note ID by conversation ID
129+
app.get('/api/notes/by-conversation/:conversationId', async (req, reply) => {
130+
const { conversationId } = req.params as { conversationId: string };
131+
const db = getDatabase();
132+
const result = db.exec(
133+
'SELECT id FROM notes WHERE conversation_id = ?',
134+
[conversationId],
135+
);
136+
if (!result.length || !result[0].values.length) {
137+
reply.status(404);
138+
return { success: false, error: 'Note not found for this conversation' };
139+
}
140+
const noteId = Number(result[0].values[0][0]);
141+
return { success: true, data: { id: noteId } };
142+
});
143+
128144
// Get single note
129145
app.get('/api/notes/:id', async (req, reply) => {
130146
const { id } = req.params as { id: string };

0 commit comments

Comments
 (0)