feat(feishu): session-list rows as V2 column pairs (fixed star toggle + session button)

Each session row is a column_set with a fixed 90px watch-toggle column
(⭐关注/⭐取关 for already-watched sessions) and a weighted session button
carrying the number label; number replies bind the session. Replaces the
stacked button pair; also honors the per-bot archived-session policy.
This commit is contained in:
liuwenbo00 2026-08-21 11:29:35 +08:00 • committed by xmanrui
parent 1790ec21ac
commit 4e325c62e0
4 changed files with 195 additions and 145 deletions

File diff suppressed because one or more lines are too long

View file

@ -1048,7 +1048,15 @@ export class FeishuHarnessBridge {
if (!action) return Promise.resolve(); if (!action) return Promise.resolve();
const messageId = nonEmptyString(event?.context?.open_message_id); const messageId = nonEmptyString(event?.context?.open_message_id);
const entry = messageId ? this.#cardKeys.get(messageId) : null; const entry = messageId ? this.#cardKeys.get(messageId) : null;
if (!entry) return Promise.resolve(); if (!entry) {
// The card predates this process (the in-memory mapping resets on
// restart) or never came from us: nudge instead of staying silent.
const chatId = nonEmptyString(event?.context?.open_chat_id);
if (chatId) {
this.#send(chatId, '这个菜单已过期,请回复 /m 重新打开。').catch(() => undefined);
}
return Promise.resolve();
}
// The promise is returned so tests (and future callers) can await the // The promise is returned so tests (and future callers) can await the
// action; the runtime dispatcher ignores it. // action; the runtime dispatcher ignores it.
return this.#handleCardAction(action, entry).catch((error) => { return this.#handleCardAction(action, entry).catch((error) => {
@ -1140,6 +1148,7 @@ export class FeishuHarnessBridge {
await this.#send(chatId, `本页只有 ${menu.sessions.length} 个会话,回复 /sessionlist 重新查看。`); await this.#send(chatId, `本页只有 ${menu.sessions.length} 个会话,回复 /sessionlist 重新查看。`);
return; return;
} }
// The number label sits on the session (bind) button of the row.
await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key }); await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
return; return;
} }
@ -1186,16 +1195,24 @@ export class FeishuHarnessBridge {
} }
const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE); const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
const safePage = Number.isSafeInteger(page) && page > 0 ? Math.min(page, pageCount - 1) : 0; const safePage = Number.isSafeInteger(page) && page > 0 ? Math.min(page, pageCount - 1) : 0;
const watchedSet = new Set(
(this.#state.watchEntries?.(key) ?? []).map((entry) => entry.sessionId),
);
const pageSlice = sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE);
this.#rememberMenu(key, { this.#rememberMenu(key, {
kind: 'sessions', kind: 'sessions',
sessions: sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE), sessions: pageSlice.map((session) => ({ ...session, watched: watchedSet.has(session.sessionId) })),
});
await this.#sendCard(chatId, sessionListCard(workspace, sessions, safePage, sessions.length), {
key,
// Keep the canonical selector result for later page callbacks. The
// list response's workspace is display data and is not authoritative.
sessionWorkspace: resolved.workspace,
}); });
await this.#sendCard(
chatId,
sessionListCard(workspace, sessions, safePage, sessions.length, watchedSet),
{
key,
// Keep the canonical selector result for later page callbacks. The
// list response's workspace is display data and is not authoritative.
sessionWorkspace: resolved.workspace,
},
);
} catch (error) { } catch (error) {
this.#logger.warn?.('[dsh-feishu] session list failed:', error.message); this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
await this.#send(chatId, '暂时无法获取会话列表,请稍后重试。'); await this.#send(chatId, '暂时无法获取会话列表,请稍后重试。');

View file

@ -4,10 +4,14 @@
* `im.message.create` API expects as `content` for `msg_type: interactive` * `im.message.create` API expects as `content` for `msg_type: interactive`
* (card schema 2.0; callback buttons live inside a column_set/column layout). * (card schema 2.0; callback buttons live inside a column_set/column layout).
* *
* Buttons carry a small `{ action }` callback behavior that * The session list lays each row out as a `column_set` (fixed-width ⭐
* `card.action.trigger` events echo back (when the app subscribes that * watch-toggle column + weighted session-button column), which is how V2
* callback); every button also carries a numeric label so the number-reply * expresses a row of buttons.
* fallback stays usable without button callbacks. *
* Buttons carry a small `{ action }` value object that `card.action.trigger`
* events echo back (when the app subscribes that event); every numbered
* button also has a numeric label so the number-reply fallback stays usable
* without button callbacks.
*/ */
export const MENU_PAGE_SIZE = 10; export const MENU_PAGE_SIZE = 10;
@ -39,6 +43,17 @@ function button(content, actionValue) {
}; };
} }
/** The raw button element (without the full-width column_set wrapper). */
function buttonElement(content, actionValue) {
return {
tag: 'button',
text: plainText(content),
type: 'default',
width: 'fill',
behaviors: [{ type: 'callback', value: { action: actionValue } }],
};
}
function safeTitle(value) { function safeTitle(value) {
const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim(); const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
return title || '暂无标题'; return title || '暂无标题';
@ -102,29 +117,43 @@ export function cardActionProbeCard(nonce) {
} }
/** /**
* One page of the workspace's sessions. Each session renders as a bind * One page of the workspace's sessions. Each row is a `column_set` pair:
* button plus a watch button (schema 2.0: buttons are top-level body * the fixed-width ⭐ watch toggle (`⭐关注` / `⭐取关` for already-watched
* elements, so the pair is two adjacent buttons); the number label equals * sessions) followed by the session button that carries the page-local
* the reply-number for the bind action (fallback). Archived sessions are * number label (reply-number fallback = bind). Archived sessions are marked
* marked in the label. * in the label. `watchedSessionIds` is a Set-like of ids this conversation
* already watches.
*/ */
export function sessionListCard(workspace, sessions, page, total) { export function sessionListCard(workspace, sessions, page, total, watchedSessionIds = new Set()) {
const start = page * MENU_PAGE_SIZE; const start = page * MENU_PAGE_SIZE;
const slice = sessions.slice(start, start + MENU_PAGE_SIZE); const slice = sessions.slice(start, start + MENU_PAGE_SIZE);
const pageCount = Math.max(1, Math.ceil(total / MENU_PAGE_SIZE)); const pageCount = Math.max(1, Math.ceil(total / MENU_PAGE_SIZE));
const watched = (id) => typeof watchedSessionIds?.has === 'function' && watchedSessionIds.has(id);
/** One row: fixed 90px watch toggle + the session button filling the rest. */
const row = (watchButton, sessionButton) => ({
tag: 'column_set',
flex_mode: 'none',
horizontal_spacing: 'default',
columns: [
{ tag: 'column', width: '90px', vertical_align: 'center', elements: [watchButton] },
{ tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', elements: [sessionButton] },
],
});
const elements = [ const elements = [
{ tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) }, { tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) },
...slice.flatMap((session, offset) => { ...slice.map((session, offset) => {
// Page-local numbering: number replies resolve against this page.
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`; const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`;
return [ const watching = watched(session.sessionId);
button(label, `use:${session.sessionId}`), return row(
button('👁 关注', `watch:${session.sessionId}`), buttonElement(watching ? '⭐取关' : '⭐关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
]; buttonElement(label, `use:${session.sessionId}`),
);
}), }),
]; ];
if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`)); if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`)); if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`));
elements.push({ tag: 'div', text: markdown('回复数字(1~N)同样可以绑定本页会话。') }); elements.push({ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') });
return cardWith('📂 会话列表', elements); return cardWith('📂 会话列表', elements);
} }

View file

@ -2045,7 +2045,9 @@ test('session list paginates by page number across 25 sessions', async () => {
const firstButton = firstLayout?.columns?.[0]?.elements?.[0]; const firstButton = firstLayout?.columns?.[0]?.elements?.[0];
assert.equal(firstButton?.tag, 'button'); assert.equal(firstButton?.tag, 'button');
assert.equal(Object.hasOwn(firstButton, 'value'), false, 'V2 buttons must not use the legacy value field'); assert.equal(Object.hasOwn(firstButton, 'value'), false, 'V2 buttons must not use the legacy value field');
assert.equal(callbackAction(firstButton), 'use:session-01'); assert.equal(callbackAction(firstButton), 'watch:session-01', 'the watch toggle leads each row');
const sessionButton = firstLayout?.columns?.[1]?.elements?.[0];
assert.equal(callbackAction(sessionButton), 'use:session-01');
assert.equal(useActionsFromCard(page0).length, 10); assert.equal(useActionsFromCard(page0).length, 10);
assert.equal(useActionsFromCard(page0)[0], 'session-01'); assert.equal(useActionsFromCard(page0)[0], 'session-01');
@ -2081,7 +2083,8 @@ test('number replies on a later session page use page-local labels', async () =>
await bridge.waitForIdle(); await bridge.waitForIdle();
const page2Buttons = buttonsFromCard(cards(sent).at(-1).content); const page2Buttons = buttonsFromCard(cards(sent).at(-1).content);
assert.match(page2Buttons[0].text.content, /^1\. Session 21$/); const sessionButtons = page2Buttons.filter((candidate) => (callbackAction(candidate) ?? '').startsWith('use:'));
assert.match(sessionButtons[0].text.content, /^1\. Session 21$/);
await bridge.accept(event('sessions-number-pick', '1', { senderOpenId: 'ou_owner' })); await bridge.accept(event('sessions-number-pick', '1', { senderOpenId: 'ou_owner' }));
await bridge.waitForIdle(); await bridge.waitForIdle();
@ -2127,7 +2130,8 @@ test('session pagination preserves an explicitly selected workspace', async () =
await bridge.onCardAction(cardActionEvent('om_card_1', 'sessions:1', 'ou_owner')); await bridge.onCardAction(cardActionEvent('om_card_1', 'sessions:1', 'ou_owner'));
await bridge.waitForIdle(); await bridge.waitForIdle();
assert.equal(useActionsFromCard(cards(sent).at(-1).content)[0], 'selected-11'); assert.equal(useActionsFromCard(cards(sent).at(-1).content)[0], 'selected-11');
assert.match(JSON.stringify(cards(sent).at(-1).content), new RegExp(workspaceB.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); const header = cards(sent).at(-1).content.body.elements[0].text.content;
assert.match(header, new RegExp(workspaceB.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}); });
const REPAIR_APP_ID = 'cli_repair_test'; const REPAIR_APP_ID = 'cli_repair_test';