Skip to content

Commit abb19d4

Browse files
committed
Categorize all sesions by action buttons
1 parent 0c6c2da commit abb19d4

3 files changed

Lines changed: 201 additions & 39 deletions

File tree

‎local-docs/03-codex-session-management.zh-CN.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,17 @@ Lark SessionStore
10311031

10321032
显示 owner / project / thread / cwd。
10331033

1034+
卡片顶部提供分类切换(取最近 20 个 Session):
1035+
1036+
```text
1037+
● ↪ Handoff (n) ▶ Use (n) ↩ Hand Back (n) 🗂 All (n)
1038+
```
1039+
1040+
分类与每行按钮同源(`sessionCategory()` 镜像 `sessionActionButtons()`),
1041+
因此列在 Handoff 下的 Session 一定会渲染 Handoff 按钮。默认打开可 Handoff
1042+
分类;若为空则依次回退到 Hand Back / Use / All。点击分类按钮会就地更新同一
1043+
张卡片,不会堆叠新卡片。更早的 Session 用 `/session list <keyword>` 查找。
1044+
10341045
---
10351046

10361047
## 29. 为什么不应让 `/session list` 每次完整扫描全部 rollout

‎src/commands/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ async function handleSessionCommand(args: string, ctx: CommandContext): Promise<
470470
[
471471
'🗂️ **All Codex Sessions**',
472472
'',
473-
'• **/session list [all|keyword]** — 查看全部 Session、Owner 与状态',
473+
'• **/session list [handoff|use|handback|all|keyword]** — 查看 Session、Owner 与状态;默认按可 Handoff 分类,卡片顶部可切换',
474474
'• **/session use <selector>** — Detached Session → 当前 Lark scope',
475475
'• **/session handoff <selector>** — Windows → 当前 Lark scope',
476476
'• **/session handback** — 当前 Lark scope → Detached / Windows-ready',

‎src/commands/local-session-manager.ts‎

Lines changed: 189 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,22 @@ import {
1111
type SessionInventoryItem,
1212
} from './local-session-state.js';
1313
import { actions, divMd, divPlain, shell, type ButtonSpec } from '../card/templates.js';
14+
import { log } from '../core/logger.js';
1415
import { readLastCodexResponse } from '../session/codex-transcript.js';
1516

17+
/**
18+
* Which one-click ownership transfer a Session currently offers. The list card
19+
* groups Sessions by this so the default view only shows rows that can act.
20+
*/
21+
type SessionCategory = 'handoff' | 'use' | 'handback' | 'none';
22+
23+
const SESSION_FILTERS = ['handoff', 'use', 'handback', 'all'] as const;
24+
25+
type SessionFilter = (typeof SESSION_FILTERS)[number];
26+
27+
/** How many Sessions the category tabs are allowed to draw from. */
28+
const SESSION_FILTER_POOL = 20;
29+
1630
function clean(
1731
value: unknown,
1832
): string {
@@ -384,6 +398,68 @@ function inventoryForContext(
384398
};
385399
}
386400

401+
/*
402+
* Mirror of the decision tree in sessionActionButtons(). Kept as a separate
403+
* pure function so the category tabs and the per-row buttons cannot disagree:
404+
* a Session listed under "Handoff" is exactly one that renders a Handoff
405+
* button. Any change to one must be made in the other.
406+
*/
407+
function sessionCategory(
408+
item: SessionInventoryItem,
409+
bindings: LarkBinding[],
410+
): SessionCategory {
411+
const linked = bindingsFor(item, bindings);
412+
const current = linked.some((binding) => binding.current);
413+
414+
// Conflicting Windows + Lark ownership: inspect before transferring.
415+
if (item.handoff.windowsActive && linked.length > 0) {
416+
return 'none';
417+
}
418+
419+
if (current) {
420+
return 'handback';
421+
}
422+
423+
// Bound to another Lark scope — not movable from here.
424+
if (linked.length > 0) {
425+
return 'none';
426+
}
427+
428+
if (item.handoff.windowsActive) {
429+
return item.handoff.code === 'busy' ? 'none' : 'handoff';
430+
}
431+
432+
return 'use';
433+
}
434+
435+
function filterTabButtons(
436+
counts: Record<SessionFilter, number>,
437+
active: SessionFilter,
438+
): ButtonSpec[] {
439+
const labels: Record<SessionFilter, string> = {
440+
handoff: '↪ Handoff',
441+
use: '▶ Use',
442+
handback: '↩ Hand Back',
443+
all: '🗂 All',
444+
};
445+
446+
const hints: Record<SessionFilter, string> = {
447+
handoff: 'Show only Sessions that can be handed off from Windows to this Lark scope.',
448+
use: 'Show only Detached Sessions that can be bound to this Lark scope.',
449+
handback: 'Show only Sessions bound to this Lark scope that can be handed back to Windows.',
450+
all: 'Show every Session, including ones with no available transfer action.',
451+
};
452+
453+
return SESSION_FILTERS.map((filter) => ({
454+
// The active tab is marked in the label because Lark gives no
455+
// selected-state styling for buttons inside an action row.
456+
text: `${filter === active ? '● ' : ''}${labels[filter]} (${counts[filter]})`,
457+
value: { cmd: 'session.list', arg: filter },
458+
style: filter === active ? 'primary' : 'default',
459+
hoverTips: hints[filter],
460+
}));
461+
}
462+
387463
function sessionActionButtons(
388464
ctx: any,
389465
item: SessionInventoryItem,
@@ -472,55 +548,88 @@ export async function handleLocalSessions(
472548
}
473549

474550
const input = args.trim();
475-
const showAll = input.toLowerCase() === 'all';
476-
const keyword = showAll ? '' : input.toLowerCase();
551+
const lowered = input.toLowerCase();
552+
const asFilter = SESSION_FILTERS.find((filter) => filter === lowered);
553+
// A bare filter word selects a category tab; anything else is a keyword search.
554+
const keyword = asFilter ? '' : lowered;
477555
const { items, bindings } = inventoryForContext(ctx);
478556

479-
let candidates = items;
557+
let candidates: SessionInventoryItem[];
558+
let counts: Record<SessionFilter, number> | undefined;
559+
let activeFilter: SessionFilter | undefined;
480560

481561
if (keyword) {
482-
candidates = items.filter(
483-
(item) =>
484-
item.sessionId.toLowerCase().includes(keyword) ||
485-
item.threadName?.toLowerCase().includes(keyword) ||
486-
item.projectName?.toLowerCase().includes(keyword),
487-
);
488-
}
489-
else if (!showAll) {
490-
// Default view: all currently-owned sessions + a small recent detached history.
491-
const important = items.filter(
492-
(item) => item.handoff.windowsActive || bindingsFor(item, bindings).length > 0,
493-
);
494-
const detached = items
562+
candidates = items
495563
.filter(
496-
(item) => !item.handoff.windowsActive && bindingsFor(item, bindings).length === 0,
564+
(item) =>
565+
item.sessionId.toLowerCase().includes(keyword) ||
566+
item.threadName?.toLowerCase().includes(keyword) ||
567+
item.projectName?.toLowerCase().includes(keyword),
497568
)
498-
.slice(0, 10);
499-
500-
candidates = [...important, ...detached];
501-
const seen = new Set<string>();
502-
candidates = candidates.filter((item) => {
503-
if (seen.has(item.sessionId)) return false;
504-
seen.add(item.sessionId);
505-
return true;
506-
});
569+
.slice(0, 30);
570+
} else {
571+
/*
572+
* Category tabs replace the old "recent history" heuristic. Only the most
573+
* recent SESSION_FILTER_POOL Sessions participate, so the counts on the
574+
* tabs always describe exactly what the tabs can show.
575+
*/
576+
const pool = items.slice(0, SESSION_FILTER_POOL);
577+
const byCategory = new Map<SessionCategory, SessionInventoryItem[]>();
578+
for (const item of pool) {
579+
const category = sessionCategory(item, bindings);
580+
const bucket = byCategory.get(category);
581+
if (bucket) bucket.push(item);
582+
else byCategory.set(category, [item]);
583+
}
584+
585+
counts = {
586+
handoff: byCategory.get('handoff')?.length ?? 0,
587+
use: byCategory.get('use')?.length ?? 0,
588+
handback: byCategory.get('handback')?.length ?? 0,
589+
all: pool.length,
590+
};
591+
592+
/*
593+
* Default to Handoff because that is the state needing a decision. When it
594+
* is empty, fall through to the next non-empty category rather than opening
595+
* on a blank card.
596+
*/
597+
activeFilter =
598+
asFilter ??
599+
(['handoff', 'handback', 'use'] as const).find((filter) => counts![filter] > 0) ??
600+
'all';
601+
602+
candidates =
603+
activeFilter === 'all' ? pool : (byCategory.get(activeFilter) ?? []);
507604
}
508605

509-
const limit = showAll ? 30 : 20;
510-
candidates = candidates.slice(0, limit).map(hydrateInventoryItem);
606+
candidates = candidates.map(hydrateInventoryItem);
511607

512-
if (candidates.length === 0) {
513-
await reply(
514-
ctx,
515-
keyword
516-
? `No Codex Session matched **${clean(input)}**.`
517-
: 'No Codex Sessions were found.',
518-
);
608+
if (candidates.length === 0 && keyword) {
609+
await reply(ctx, `No Codex Session matched **${clean(input)}**.`);
610+
return;
611+
}
612+
613+
if (counts?.all === 0) {
614+
await reply(ctx, 'No Codex Sessions were found.');
519615
return;
520616
}
521617

522618
const elements: object[] = [];
523619

620+
if (counts && activeFilter) {
621+
elements.push(actions(filterTabButtons(counts, activeFilter)));
622+
elements.push({ tag: 'hr' });
623+
}
624+
625+
if (candidates.length === 0) {
626+
elements.push(
627+
divMd(
628+
`No Session in the most recent ${SESSION_FILTER_POOL} currently offers this action. Pick another category above.`,
629+
),
630+
);
631+
}
632+
524633
for (const [idx, item] of candidates.entries()) {
525634
const linked = bindingsFor(item, bindings);
526635
const current = linked.some((binding) => binding.current);
@@ -566,14 +675,56 @@ export async function handleLocalSessions(
566675
'• **Handoff to this Lark** — Windows → current Lark scope',
567676
'• **Hand Back to Windows** — current Lark scope → Detached / Windows-ready',
568677
'• **Last Response** — read the latest completed user-visible Codex response',
569-
...(!showAll && !keyword && items.length > candidates.length
570-
? ['', 'Use **/session list all** to show more Session history.']
678+
...(counts && items.length > counts.all
679+
? [
680+
'',
681+
`Showing the most recent ${SESSION_FILTER_POOL} of ${items.length} Sessions. Use **/session list <keyword>** to find an older one.`,
682+
]
571683
: []),
572684
].join('\n'),
573685
),
574686
);
575687

576-
await replyCard(ctx, shell('🗂️ All Codex Sessions', elements));
688+
const card = shell('🗂️ All Codex Sessions', elements);
689+
690+
/*
691+
* A tab click re-runs this handler with ctx.msg.messageId set to the card's
692+
* own message (see makeFakeMsg in card/dispatcher.ts). Recall that card and
693+
* post the replacement, which is the same pattern the /account and /config
694+
* card flows use.
695+
*
696+
* Two rejected alternatives, both of which fail silently:
697+
* - channel.updateCard() → im.v1.message.patch. Feishu answers HTTP 200
698+
* with a non-zero body code and the SDK discards the response, so a
699+
* refused patch throws nothing and renders nothing.
700+
* - sendManagedCard() → cardkit.card.create, which rejects the v1 schema
701+
* that shell() builds ("returned no card_id").
702+
*
703+
* Cost of recall-and-resend: the card moves to the bottom of the chat on each
704+
* tab switch instead of updating where it sits.
705+
*/
706+
if (ctx.fromCardAction && ctx.msg.messageId) {
707+
/*
708+
* Send first, recall second. The replacement is sent as a reply to the old
709+
* card because that is the only way to land in the right topic thread
710+
* (SendOptions has no threadId — only replyTo/replyInThread), and recalling
711+
* a parent message in Feishu does not remove replies already made to it.
712+
*/
713+
const stale = ctx.msg.messageId;
714+
await replyCard(ctx, card);
715+
716+
try {
717+
await ctx.channel.recallMessage(stale);
718+
} catch (err) {
719+
// Leaves the old card above the new one — visible, not silent.
720+
log.warn('command', 'session-list-recall-failed', {
721+
err: err instanceof Error ? err.message : String(err),
722+
});
723+
}
724+
return;
725+
}
726+
727+
await replyCard(ctx, card);
577728
}
578729

579730
function formatResponseTime(

0 commit comments

Comments
 (0)