// Sessions page: filter bar, status board/table, detail drawer with locate/resume/close.
import { KANBAN_TEAM_STORAGE_KEY, normalizeSessionsViewMode, readStoredBoardOrder, readStoredKanbanGroupBy, readStoredSessionsViewMode, writeStoredBoardOrder, writeStoredKanbanGroupBy, writeStoredSessionsViewMode, } from './preferences.js';
import { computeDropPosition, deriveKanbanColumn, effectiveKanbanPosition, } from './kanban-model.js';
import { store } from './store.js';
import { botDisplayName, botAvatarHtml, chatAvatarHtml, chatDisplayTitle, attentionWaitSince, escapeHtml, loadNameMaps, relTime, stripMentionPrefix, t, ui, } from './ui.js';
function th(sort, label) {
    return `<th data-sort="${sort}" data-label="${escapeHtml(label)}">${escapeHtml(label)}</th>`;
}
function tokenCount(value) {
    return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function formatTokenCount(value) {
    const n = tokenCount(value);
    return n === null ? '-' : n.toLocaleString('en-US');
}
const CLI_FILTER_OPTIONS = [
    'claude-code',
    'seed',
    'codex',
    'codex-app',
    'cursor',
    'gemini',
    'opencode',
    'mtr',
    'hermes',
    'mira',
    'pi',
    'copilot',
    'aiden',
    'coco',
    'oh-my-pi',
    'unknown',
];
const BOARD_COLUMNS = [
    { id: 'needs-you', labelKey: 'sessions.board.needsYou', hintKey: 'sessions.board.needsYouHint' },
    { id: 'starting', labelKey: 'sessions.board.starting', hintKey: 'sessions.board.startingHint' },
    { id: 'working', labelKey: 'sessions.board.working', hintKey: 'sessions.board.workingHint' },
    { id: 'idle', labelKey: 'sessions.board.idle', hintKey: 'sessions.board.idleHint' },
];
// ── 看板视图 ──────────────────────────────────────────────────────────────────
// 五列手动工作流（待办池/待办/进行中/待确认/已完成）：卡片可拖拽换列与列内排序，
// 放置持久化在 Session 上（daemon /board 端点）；未手动放置的会话按运行状态
// 推导默认列（kanban-model.ts），已关闭会话固定落「已完成」。
const KANBAN_COLUMNS = [
    { id: 'backlog', labelKey: 'sessions.kanban.backlog' },
    { id: 'todo', labelKey: 'sessions.kanban.todo' },
    { id: 'in_progress', labelKey: 'sessions.kanban.inProgress' },
    { id: 'in_review', labelKey: 'sessions.kanban.inReview' },
    { id: 'done', labelKey: 'sessions.kanban.done' },
];
// 「已完成」列收纳所有已关闭会话，可能积累上千条——只展示最前的一截，剩余计数提示。
const KANBAN_DONE_CAP = 50;
// 列状态图标：14x14 SVG，圆环 + 不同填充度的扇形/对勾表达工作流进度，
// 颜色由列容器的 currentColor 决定（CSS 里按列着色）。
function kanbanStatusIcon(id) {
    const ring = (extra = '') => `<svg viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.4" fill="none" stroke="currentColor" stroke-width="1.6"${extra}/>`;
    switch (id) {
        case 'backlog': // 虚线圆环
            return `${ring(' stroke-dasharray="1.6 2.1"')}</svg>`;
        case 'in_progress': // 半圆填充
            return `${ring()}<path d="M7,7 L7,3.6 A3.4,3.4 0 0 1 7,10.4 Z" fill="currentColor"/></svg>`;
        case 'in_review': // 3/4 填充
            return `${ring()}<path d="M7,7 L7,3.6 A3.4,3.4 0 1 1 3.6,7 Z" fill="currentColor"/></svg>`;
        case 'done': // 实心圆 + 对勾
            return `<svg viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="6.2" fill="currentColor"/><path d="M4.4 7.2 6.2 9 9.7 5.4" fill="none" stroke="var(--surface)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
        case 'todo': // 空心圆环
        default:
            return `${ring()}</svg>`;
    }
}
function cssToken(value) {
    return String(value ?? 'unknown').toLowerCase().replace(/[^a-z0-9_-]/g, '-');
}
function repoBasename(workingDir) {
    const value = String(workingDir ?? '').trim();
    if (!value)
        return '-';
    const parts = value.replace(/\\/g, '/').split('/').filter(Boolean);
    return parts.at(-1) ?? value;
}
function terminalHref(s) {
    if (!s.webPort)
        return null;
    const port = s.proxyPort ?? s.webPort;
    const suffix = s.proxyPort ? `/s/${encodeURIComponent(s.sessionId)}` : '';
    return `http://${location.hostname}:${port}${suffix}`;
}
// Cohesive icon set for the session-card action bar — stroke-based (CSS sets
// stroke:currentColor), 16px viewBox to match the sidebar nav glyphs. Icons
// instead of text labels keep the row a fixed width across locales: the EN
// labels used to be wider than zh and overflowed the narrow card, spilling
// "Close" onto its own line.
const ICON = {
    pin: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 14.3s4.2-3.9 4.2-7.3A4.2 4.2 0 0 0 8 2.9a4.2 4.2 0 0 0-4.2 4.1C3.8 10.4 8 14.3 8 14.3z"/><circle cx="8" cy="6.9" r="1.5"/></svg>',
    openChat: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9.4 2.8h3.8v3.8"/><path d="M13.2 2.8 7.3 8.7"/><path d="M11.5 9.3v2.9a1.2 1.2 0 0 1-1.2 1.2H3.8a1.2 1.2 0 0 1-1.2-1.2V5.7a1.2 1.2 0 0 1 1.2-1.2h2.9"/></svg>',
    details: '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2.2" y="2.9" width="11.6" height="10.2" rx="1.8"/><path d="M9.9 2.9v10.2"/></svg>',
    terminal: '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.7" y="2.7" width="12.6" height="10.6" rx="2"/><path d="M4.4 6.3 6.4 8.1 4.4 9.9"/><path d="M8.2 10.2h3.4"/></svg>',
    key: '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="6.1" r="3"/><path d="M8.1 8.2 13 13.1"/><path d="M11.3 11.4 12.6 10.1"/><path d="M12.7 12.8 13.7 11.8"/></svg>',
    close: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.2 4.2 11.8 11.8"/><path d="M11.8 4.2 4.2 11.8"/></svg>',
    edit: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.7 3.3 12.7 5.3 6.3 11.7 3.7 12.3 4.3 9.7 10.7 3.3z"/></svg>',
    history: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.2 4.8a2 2 0 0 1 2-2h7.6a2 2 0 0 1 2 2v4.6a2 2 0 0 1-2 2H6.6l-2.9 2.4v-2.4h-.5a2 2 0 0 1-2-2z"/><path d="M5.2 6.2h5.6M5.2 8.4h3.6"/></svg>',
    // 飞书：两片交叠的羽毛向右上展翅（还原 Lark 彩色 logo 的飞鸟造型），单色
    // stroke:currentColor 适配本组线性图标，圆角端点呼应原 logo 的圆润羽尖。
    feishu: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.6 4.4C6.4 4 10.4 5.4 13.4 8.2 9.8 7 6.4 6.6 3.4 7.4"/><path d="M13.4 8.2C9.6 8.7 6 10 2.9 12 5.6 9 8.8 7.6 13.4 8.2"/></svg>',
};
/** Compact icon action button for the card bar. `kind` adds a tint variant. */
function cardActBtn(action, icon, label, kind = '') {
    return `<button type="button" class="card-act${kind ? ' ' + kind : ''}" data-action="${action}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${icon}</button>`;
}
// Terminal access pill for a live session: a read-only "open" segment (always
// shown) plus — for authenticated users — a "key" segment that mints the
// writable (token-bearing) link on demand. Icon-only + tooltip keeps it compact;
// the writable segment carries the accent fill so it reads as the real action.
function terminalControlsHtml(url) {
    if (!url)
        return '';
    const open = `<a class="term-btn term-open" href="${escapeHtml(url)}" target="_blank" rel="noopener" title="${escapeHtml(t('sessions.openTerminal'))}" aria-label="${escapeHtml(t('sessions.openTerminal'))}">${ICON.terminal}</a>`;
    if (!ui.authed)
        return `<span class="term-pill solo">${open}</span>`;
    const write = `<button type="button" class="term-btn term-write" data-action="write-link" title="${escapeHtml(t('sessions.writeLinkHint'))}" aria-label="${escapeHtml(t('sessions.writeLink'))}">${ICON.key}</button>`;
    return `<span class="term-pill">${open}${write}</span>`;
}
// Mint + open the writable web terminal for `s`. The tab is opened synchronously
// inside the click gesture (then navigated post-fetch) so the popup blocker —
// which fires when window.open trails an await — stays quiet. The token lands in
// the new tab's address bar, same as the Lark card's link, by design.
async function openWriteLink(s, btn) {
    const tab = window.open('about:blank', '_blank');
    if (tab)
        tab.opener = null;
    if (btn)
        btn.disabled = true;
    try {
        const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/write-link`);
        const body = await r.json().catch(() => ({}));
        if (!r.ok || body?.ok === false || !body?.url) {
            tab?.close();
            // 401 already raises the global read-only toast — don't double-alert.
            if (r.status !== 401)
                alert(`${t('sessions.writeLinkFail')}: ${body?.error ?? r.status}`);
            return;
        }
        if (tab)
            tab.location.href = body.url;
        else
            window.open(body.url, '_blank', 'noopener');
    }
    catch (e) {
        tab?.close();
        alert(`${t('sessions.writeLinkFail')}: ${e}`);
    }
    finally {
        if (btn)
            btn.disabled = false;
    }
}
function deriveSessionBoardColumn(s) {
    if (s.status === 'closed')
        return null;
    if (s.pendingRepo || s.tuiPromptActive || s.agentAttention || s.status === 'limited')
        return 'needs-you';
    if (s.status === 'starting')
        return 'starting';
    if (s.status === 'working' || s.status === 'analyzing' || s.status === 'active')
        return 'working';
    return 'idle';
}
// CLI 多选收进一个下拉 chip：默认全选时只占一个胶囊位，点开才展开
// 全部选项，避免 15 个 checkbox 铺满一整行。
export function renderCliFilterGroup() {
    return `<details class="filter-cli">
    <summary>${t('sessions.cli')} · <b id="cli-filter-count">${t('common.all')}</b></summary>
    <div class="filter-cli-pop" role="group" aria-label="${t('sessions.cli')}">
      ${CLI_FILTER_OPTIONS.map(cli => `
        <label class="filter-check">
          <input type="checkbox" name="cli" value="${escapeHtml(cli)}" checked>
          <span>${escapeHtml(cli)}</span>
        </label>
      `).join('')}
    </div>
  </details>`;
}
function pageHtml() {
    return `<section class="page">
    <div class="page-heading">
      <div>
        <p class="eyebrow">${t('nav.sessions')}</p>
        <h1>${t('sessions.title')}</h1>
        <p>${t('sessions.subtitle')}</p>
      </div>
      <div class="sessions-view-controls">
        <span id="kanban-team-stats" class="kanban-team-stats" hidden></span>
        <select id="kanban-team" class="kanban-team-select" aria-label="${t('sessions.kanban.groupTeam')}" hidden></select>
        <div class="segmented kanban-groupby" id="kanban-groupby" role="group" aria-label="${t('sessions.kanban.groupBy')}" hidden>
          <button type="button" data-groupby="flow">${t('sessions.kanban.groupFlow')}</button>
          <button type="button" data-groupby="team">${t('sessions.kanban.groupTeam')}</button>
          <button type="button" data-groupby="bot">${t('sessions.kanban.groupBot')}</button>
        </div>
        <div class="segmented sessions-view-toggle" role="group" aria-label="${t('sessions.viewMode')}">
          <button type="button" data-view="kanban">${t('sessions.viewKanban')}</button>
          <button type="button" data-view="board">${t('sessions.viewBoard')}</button>
          <button type="button" data-view="table">${t('sessions.viewTable')}</button>
        </div>
      </div>
    </div>
    <form id="filters" class="filters sessions-filters">
      <input type="search" name="q" placeholder="${t('sessions.search')}" />
      <select name="status">
        <option value="">${t('sessions.anyStatus')}</option>
        <option>starting</option><option>working</option><option>idle</option>
        <option>analyzing</option><option>active</option><option>closed</option>
      </select>
      <select name="adopt">
        <option value="">${t('sessions.adoptAny')}</option>
        <option value="yes">${t('sessions.adoptYes')}</option>
        <option value="no">${t('sessions.adoptNo')}</option>
      </select>
      ${renderCliFilterGroup()}
      <label class="filter-toggle"><input type="checkbox" name="active" checked> <span>${t('sessions.activeOnly')}</span></label>
    </form>
    <div id="bulk-bar" class="bulk-bar" hidden>
      <span id="bulk-count"></span>
      <button type="button" id="bulk-close" class="contrast">${t('sessions.closeSelected')}</button>
      <button type="button" id="bulk-clear">${t('sessions.clearSelection')}</button>
    </div>
    <table id="sessions-table">
      <thead><tr>
        <th><input type="checkbox" id="select-all" title="${t('sessions.activeOnly')}"></th>
        ${th('botName', t('sessions.bot'))}
        ${th('cliId', t('sessions.cli'))}
        ${th('status', t('sessions.status'))}
        ${th('tokenIn', t('sessions.tokenIn'))}
        ${th('tokenOut', t('sessions.tokenOut'))}
        ${th('title', t('sessions.titleCol'))}
        ${th('workingDir', t('sessions.workingDir'))}
        ${th('spawnedAt', t('sessions.created'))}
        ${th('lastMessageAt', t('sessions.last'))}
        ${th('adopt', t('sessions.adopt'))}
        <th>${t('sessions.actions')}</th>
      </tr></thead>
      <tbody></tbody>
    </table>
    <div id="sessions-board" class="sessions-board" hidden></div>
    <div id="sessions-kanban" class="sessions-kanban" hidden></div>
    <dialog id="drawer"></dialog>
    <dialog id="term-modal" class="term-modal"></dialog>
    <dialog id="history-modal" class="history-modal"></dialog>
  </section>`;
}
export function renderSessionsPage(root) {
    root.innerHTML = pageHtml();
    const tbody = root.querySelector('#sessions-table tbody');
    const filtersForm = root.querySelector('#filters');
    const drawer = root.querySelector('#drawer');
    const selectAllBox = root.querySelector('#select-all');
    const bulkBar = root.querySelector('#bulk-bar');
    const bulkCountSpan = root.querySelector('#bulk-count');
    const bulkCloseBtn = root.querySelector('#bulk-close');
    const bulkClearBtn = root.querySelector('#bulk-clear');
    const table = root.querySelector('#sessions-table');
    const board = root.querySelector('#sessions-board');
    const kanban = root.querySelector('#sessions-kanban');
    const termModal = root.querySelector('#term-modal');
    const historyModal = root.querySelector('#history-modal');
    const groupByBox = root.querySelector('#kanban-groupby');
    const teamSelect = root.querySelector('#kanban-team');
    const teamStats = root.querySelector('#kanban-team-stats');
    const viewButtons = root.querySelectorAll('.sessions-view-toggle [data-view]');
    const selected = new Set();
    let sortKey = 'lastMessageAt';
    let sortDir = 'desc';
    let viewMode = readStoredSessionsViewMode(window.localStorage);
    // 列顺序用户可调（拖列头 / ‹› 按钮），localStorage 持久化
    let boardOrder = readStoredBoardOrder(window.localStorage);
    let dragColId = null;
    // 防闪烁：上次渲染的 HTML 快照（内容没变就跳过 innerHTML），以及
    // 入场动画是否已播过（只在首次渲染播一轮）。
    let lastBoardHtml = '';
    let lastTableHtml = '';
    let lastKanbanHtml = '';
    let boardAnimated = false;
    // 看板交互态：拖拽中的卡片 id / 标题就地编辑中 —— 两者期间都跳过看板重绘，
    // 否则 SSE 触发的 innerHTML 重建会把拖拽源/输入框拍没。
    let kanbanDragId = null;
    let kanbanEditing = false;
    // 单击开终端 vs 双击改标题的仲裁：单击延迟 220ms 执行，双击先到就取消。
    let kanbanOpenTimer = null;
    // 上次渲染时每列的有序行（聚簇后的视觉平铺顺序）—— drop 落点据此找相邻卡片
    // 算持久化位置。
    let lastKanbanGroups = new Map();
    // 看板分组维度：flow=工作流五列（可拖拽）；team=选定团队的工作流看板（含
    // 团队内所有 bot 的会话，可拖拽）；bot=机器人视角列（只读总览）
    let kanbanGroupBy = readStoredKanbanGroupBy(window.localStorage);
    // 整簇拖拽：拖群组容器头部时记录 (chatId, 源列)，drop 时整组搬运
    let kanbanDragClusterChat = null;
    let kanbanDragClusterCol = null;
    // 团队清单（groupBy='team' 首次激活时懒加载：本地托管团队 + 远程 roster）。
    // botNames 用来与 /introduce 记录的外部 bot 按名字匹配（introduce 只留
    // openId+name，而 open_id 是 app-scoped 的，名字是两边唯一的公共标识）。
    let kanbanTeams = [];
    // 群 → { 在场自家 bot 集合, introduce 过的外部 bot 名字集合 }（/api/groups）。
    let kanbanChatBots = null;
    let kanbanTeamsLoaded = false;
    let kanbanTeamsLoading = false;
    let kanbanTeamKey = (() => {
        try {
            return window.localStorage.getItem(KANBAN_TEAM_STORAGE_KEY) ?? '';
        }
        catch {
            return '';
        }
    })();
    async function loadKanbanTeams() {
        if (kanbanTeamsLoading || kanbanTeamsLoaded)
            return;
        kanbanTeamsLoading = true;
        try {
            const [hosted, remote, groups] = await Promise.all([
                fetch('/api/team/hosted').then(r => r.json()).catch(() => null),
                fetch('/api/team/remote-roster').then(r => r.json()).catch(() => null),
                fetch('/api/groups').then(r => r.json()).catch(() => null),
            ]);
            if (Array.isArray(groups?.chats)) {
                kanbanChatBots = new Map(groups.chats.map((c) => [
                    String(c.chatId),
                    {
                        botIds: new Set((c.memberBots ?? []).filter((mb) => mb.inChat).map((mb) => String(mb.larkAppId))),
                        observedNames: new Set((c.observedBotNames ?? []).map((n) => String(n))),
                    },
                ]));
            }
            const rosterBots = (bots) => ({
                ids: new Set(bots.map((b) => String(b.larkAppId))),
                names: new Set(bots.map((b) => String(b.name ?? '')).filter(Boolean)),
            });
            const teams = [];
            for (const tm of hosted?.teams ?? []) {
                const { ids, names } = rosterBots(tm.bots ?? []);
                teams.push({
                    key: `local:${tm.teamId}`,
                    label: tm.isDefault ? t('team.myHostedTeam') : String(tm.name ?? tm.teamId),
                    botIds: ids,
                    botNames: names,
                    groupChats: new Set((tm.groupChatIds ?? []).map((c) => String(c))),
                });
            }
            for (const m of remote?.memberships ?? []) {
                const { ids, names } = rosterBots(m.roster?.bots ?? []);
                teams.push({
                    key: `${m.hubUrl}::${m.teamId}`,
                    label: String(m.teamName ?? m.teamId ?? m.hubUrl),
                    botIds: ids,
                    botNames: names,
                    // 远程团队发起的协作群绑定记录在 hub 侧，spoke 暂取不到
                    groupChats: new Set(),
                });
            }
            kanbanTeams = teams;
        }
        finally {
            kanbanTeamsLoaded = true;
            kanbanTeamsLoading = false;
        }
        if (kanbanTeams.length && !kanbanTeams.some(tm => tm.key === kanbanTeamKey)) {
            kanbanTeamKey = kanbanTeams[0].key;
        }
        delete teamSelect.dataset.loading;
        teamSelect.disabled = kanbanTeams.length === 0;
        teamSelect.innerHTML = kanbanTeams.length
            ? kanbanTeams.map(tm => `<option value="${escapeHtml(tm.key)}"${tm.key === kanbanTeamKey ? ' selected' : ''}>${escapeHtml(tm.label)}</option>`).join('')
            : `<option value="">${escapeHtml(t('sessions.kanban.noTeam'))}</option>`;
        lastKanbanHtml = '';
        rerender();
    }
    // ── hub 团队看板（共享编排 + 对方部署会话快照）────────────────────────────
    // 编排存团队 host：托管团队读本地 /api/team/board/local/<id>，加入的远程团队
    // 经 spoke 代理 /api/team/remote-board 到 hub。30s 软刷新。
    let kanbanTeamBoardData = null;
    let kanbanTeamBoardKey = '';
    let kanbanTeamBoardFetchedAt = 0;
    let kanbanTeamBoardLoading = false;
    // 对方部署的行不在 store 里——拖拽落点查这里
    let kanbanRemoteRows = new Map();
    async function ensureTeamBoard(team) {
        const fresh = kanbanTeamBoardKey === team.key && Date.now() - kanbanTeamBoardFetchedAt < 30_000;
        if (kanbanTeamBoardLoading || fresh)
            return;
        kanbanTeamBoardLoading = true;
        try {
            const isLocal = team.key.startsWith('local:');
            const u = isLocal
                ? `/api/team/board/local/${encodeURIComponent(team.key.slice('local:'.length))}`
                : `/api/team/remote-board?key=${encodeURIComponent(team.key)}`;
            const r = await fetch(u);
            const body = await r.json().catch(() => ({}));
            if (!r.ok || body?.ok === false)
                return;
            const myDeploymentId = typeof body.deploymentId === 'string' ? body.deploymentId : null;
            const remoteRows = [];
            kanbanRemoteRows = new Map();
            for (const rep of Array.isArray(body.reports) ? body.reports : []) {
                // 远程团队的响应里含自己部署的上报——本地行走实时 store，跳过
                if (myDeploymentId && rep.deploymentId === myDeploymentId)
                    continue;
                for (const s of Array.isArray(rep.sessions) ? rep.sessions : []) {
                    const row = { ...s, remoteDeployment: rep.deploymentName || rep.deploymentId };
                    remoteRows.push(row);
                    kanbanRemoteRows.set(String(s.sessionId), row);
                }
            }
            kanbanTeamBoardData = {
                board: body.board && typeof body.board === 'object' ? body.board : {},
                remoteRows,
            };
            kanbanTeamBoardKey = team.key;
            kanbanTeamBoardFetchedAt = Date.now();
            lastKanbanHtml = '';
            rerender();
        }
        catch {
            // 拉不到 hub 看板时退化为只看本地行
        }
        finally {
            kanbanTeamBoardLoading = false;
        }
    }
    /** 团队看板拖拽落盘：写 host 的共享编排（不动会话的个人看板字段）。 */
    async function persistTeamBoardMove(teamKey, sessionId, column, position, prevEntry) {
        try {
            const isLocal = teamKey.startsWith('local:');
            const r = isLocal
                ? await fetch(`/api/team/board/local/${encodeURIComponent(teamKey.slice('local:'.length))}/move`, {
                    method: 'POST',
                    headers: { 'content-type': 'application/json' },
                    body: JSON.stringify({ sessionId, column, position }),
                })
                : await fetch('/api/team/remote-board-move', {
                    method: 'POST',
                    headers: { 'content-type': 'application/json' },
                    body: JSON.stringify({ key: teamKey, sessionId, column, position }),
                });
            const body = await r.json().catch(() => ({}));
            if (!r.ok || body?.ok === false) {
                if (kanbanTeamBoardData) {
                    if (prevEntry)
                        kanbanTeamBoardData.board[sessionId] = prevEntry;
                    else
                        delete kanbanTeamBoardData.board[sessionId];
                }
                lastKanbanHtml = '';
                rerender();
                if (r.status !== 401)
                    alert(`${t('sessions.kanban.moveFail')}: ${body?.error ?? r.status}`);
            }
        }
        catch (e) {
            if (kanbanTeamBoardData) {
                if (prevEntry)
                    kanbanTeamBoardData.board[sessionId] = prevEntry;
                else
                    delete kanbanTeamBoardData.board[sessionId];
            }
            lastKanbanHtml = '';
            rerender();
            alert(`${t('sessions.kanban.moveFail')}: ${e}`);
        }
    }
    /** 团队模式拖拽的统一落点：乐观写本地缓存的共享编排 + POST host。 */
    function applyTeamBoardMove(sessionId, column, position) {
        const team = kanbanTeams.find(tm => tm.key === kanbanTeamKey) ?? kanbanTeams[0];
        if (!team)
            return;
        if (!kanbanTeamBoardData || kanbanTeamBoardKey !== team.key) {
            // 首次拉取尚未完成也允许拖：先建本地空编排缓存，写入照常进行
            kanbanTeamBoardData = { board: {}, remoteRows: kanbanTeamBoardData?.remoteRows ?? [] };
            kanbanTeamBoardKey = team.key;
        }
        const prev = kanbanTeamBoardData.board[sessionId];
        kanbanTeamBoardData.board[sessionId] = { column, position };
        void persistTeamBoardMove(team.key, sessionId, column, position, prev);
    }
    function orderedBoardColumns() {
        return boardOrder
            .map(id => BOARD_COLUMNS.find(c => c.id === id))
            .filter((c) => !!c);
    }
    function moveColumn(id, delta) {
        const from = boardOrder.indexOf(id);
        const to = from + delta;
        if (from < 0 || to < 0 || to >= boardOrder.length)
            return;
        const next = [...boardOrder];
        next.splice(from, 1);
        next.splice(to, 0, id);
        boardOrder = next;
        writeStoredBoardOrder(window.localStorage, boardOrder);
        rerender();
    }
    function moveColumnTo(id, targetId) {
        if (id === targetId)
            return;
        const from = boardOrder.indexOf(id);
        const to = boardOrder.indexOf(targetId);
        if (from < 0 || to < 0)
            return;
        const next = [...boardOrder];
        next.splice(from, 1);
        next.splice(to, 0, id);
        boardOrder = next;
        writeStoredBoardOrder(window.localStorage, boardOrder);
        rerender();
    }
    function rowHtml(s) {
        const closed = s.status === 'closed';
        const checked = selected.has(s.sessionId) ? 'checked' : '';
        return `<tr data-id="${escapeHtml(s.sessionId)}">
      <td><input type="checkbox" class="row-select" ${checked} ${closed ? 'disabled' : ''}></td>
      <td>${escapeHtml(botDisplayName(s))}</td>
      <td><span class="badge cli-${cssToken(s.cliId)}">${escapeHtml(s.cliId ?? 'unknown')}</span></td>
      <td><span class="status status-${escapeHtml(s.status ?? 'unknown')}">${escapeHtml(s.status ?? 'unknown')}</span></td>
      <td class="token-cell">${formatTokenCount(s.tokenUsage?.in)}</td>
      <td class="token-cell">${formatTokenCount(s.tokenUsage?.out)}</td>
      <td title="${escapeHtml(String(s.title ?? ''))}">${escapeHtml(stripMentionPrefix(s.title ?? '').slice(0, 48))}</td>
      <td title="${escapeHtml(s.workingDir ?? '')}">${escapeHtml((s.workingDir ?? '').slice(-34))}</td>
      <td>${relTime(s.spawnedAt)}</td>
      <td>${relTime(s.lastMessageAt)}</td>
      <td>${s.adopt ? '<span class="badge">adopt</span>' : ''}</td>
      <td><button class="open" type="button">${t('sessions.details')}</button></td>
    </tr>`;
    }
    // chat-scope（普通群/单聊平铺回复）没有话题可定位——「定位话题」换成直接
    // 打开群聊的 applink。旧 daemon 上报的行没有 scope 字段，保持定位行为。
    function chatScopeLink(s) {
        if (s.scope !== 'chat' || !s.feishuChatLink)
            return null;
        const label = t('sessions.openChat');
        return `<a class="card-act" href="${escapeHtml(s.feishuChatLink)}" target="_blank" rel="noopener" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${ICON.feishu}</a>`;
    }
    function boardSignalLabel(s) {
        // Agent-raised reason is the most informative — show it verbatim so the
        // human sees *why* the task is stuck, not a generic label.
        if (s.agentAttention?.reason)
            return s.agentAttention.reason;
        if (s.agentAttention)
            return t('sessions.board.signalAgent');
        if (s.pendingRepo)
            return t('sessions.board.signalRepo');
        if (s.tuiPromptActive)
            return t('sessions.board.signalPrompt');
        if (s.status === 'limited')
            return t('sessions.board.signalLimited');
        return '';
    }
    function boardCardHtml(s) {
        const isSelected = selected.has(s.sessionId);
        // title 剥掉开头的 "@bot" mention，只留消息内容；副行的群聊名比 cliId 更
        // 有辨识度（同一 bot 可能在多个群干活），查不到群名时回退 cliId。
        const title = stripMentionPrefix(s.title) || s.sessionId;
        const botName = botDisplayName(s);
        const chatTitle = chatDisplayTitle(s);
        const terminal = terminalHref(s);
        const signal = boardSignalLabel(s);
        const repo = repoBasename(s.workingDir);
        return `<article class="session-card${isSelected ? ' selected' : ''}" data-id="${escapeHtml(s.sessionId)}" aria-pressed="${isSelected}">
      <div class="session-card-top">
        ${botAvatarHtml({ name: botName, larkAppId: s.larkAppId, size: 'sm' })}
        <div class="session-card-title">
          <strong title="${escapeHtml(String(s.title ?? title))}">${escapeHtml(String(title).slice(0, 72))}</strong>
          <span>${escapeHtml(botName)} · ${escapeHtml(chatTitle ?? s.cliId ?? 'unknown')}</span>
        </div>
        <span class="status status-${escapeHtml(s.status ?? 'unknown')}">${escapeHtml(s.status ?? 'unknown')}</span>
      </div>
      ${repo !== '-' || s.adopt ? `<div class="session-card-meta">
        ${repo !== '-' ? `<span title="${escapeHtml(s.workingDir ?? '')}">${escapeHtml(repo)}</span>` : ''}
        ${s.adopt ? '<span class="badge">adopt</span>' : ''}
      </div>` : ''}
      <div class="session-card-time">
        <span>${s.agentAttention?.at
            ? `${escapeHtml(t('sessions.board.waiting'))} ${relTime(attentionWaitSince(s))}`
            : `${escapeHtml(t('sessions.last'))}: ${relTime(s.lastMessageAt)}`}</span>
      </div>
      ${signal ? `<div class="session-signal" title="${escapeHtml(signal)}">${escapeHtml(signal)}</div>` : ''}
      <div class="session-card-actions">
        ${chatScopeLink(s) ?? cardActBtn('locate', ICON.pin, t('sessions.locate'))}
        ${cardActBtn('details', ICON.details, t('sessions.details'))}
        ${terminalControlsHtml(terminal)}
        ${cardActBtn('close', ICON.close, t('sessions.close'), 'danger')}
      </div>
    </article>`;
    }
    function compareBoardRows(a, b, column) {
        const av = column === 'needs-you' ? attentionWaitSince(a) : Number(a.lastMessageAt ?? 0);
        const bv = column === 'needs-you' ? attentionWaitSince(b) : Number(b.lastMessageAt ?? 0);
        if (av !== bv)
            return column === 'needs-you' ? av - bv : bv - av;
        return String(a.title ?? a.sessionId).localeCompare(String(b.title ?? b.sessionId));
    }
    function renderBoard(rows) {
        const groups = new Map(BOARD_COLUMNS.map(c => [c.id, []]));
        for (const row of rows) {
            const column = deriveSessionBoardColumn(row);
            if (column)
                groups.get(column).push(row);
        }
        const columns = orderedBoardColumns();
        const html = columns.map((column, idx) => {
            const columnRows = (groups.get(column.id) ?? []).sort((a, b) => compareBoardRows(a, b, column.id));
            return `<section class="session-board-column session-board-${column.id}" data-col="${column.id}">
        <header draggable="true" title="${escapeHtml(t('sessions.board.dragHint'))}">
          <div>
            <h2>${escapeHtml(t(column.labelKey))}</h2>
            <p>${escapeHtml(t(column.hintKey))}</p>
          </div>
          <span class="session-board-head-right">
            <span class="session-board-move">
              <button type="button" data-move-col="${column.id}" data-dir="-1"
                aria-label="${escapeHtml(t('sessions.board.moveLeft'))}" ${idx === 0 ? 'disabled' : ''}>‹</button>
              <button type="button" data-move-col="${column.id}" data-dir="1"
                aria-label="${escapeHtml(t('sessions.board.moveRight'))}" ${idx === columns.length - 1 ? 'disabled' : ''}>›</button>
            </span>
            <span class="session-board-count">${columnRows.length}</span>
          </span>
        </header>
        <div class="session-board-list">
          ${columnRows.length ? columnRows.map(boardCardHtml).join('') : `<div class="session-board-empty">${t('sessions.board.emptyColumn')}</div>`}
        </div>
      </section>`;
        }).join('');
        // SSE 事件（心跳/无关字段 patch）远多于真正的可见变化——内容没变就不碰
        // DOM，避免 backdrop-blur 面板整板重排造成的闪烁。
        if (html === lastBoardHtml)
            return;
        lastBoardHtml = html;
        board.innerHTML = html;
        // 入场动画只在看板第一次画出来时播一轮（.board-enter），之后的状态更新
        // 重绘不再整板重播——那就是「每次状态更新页面闪一下」的来源。
        board.classList.toggle('board-enter', !boardAnimated);
        boardAnimated = true;
    }
    // ── 看板视图卡片 ─────────────────────────────────────────────────────────
    // 卡片整体即点击目标：单击开页面内终端弹窗；铅笔改标题；「详情」进抽屉；
    // 整卡可拖拽换列/排序。
    function kanbanCardHtml(s) {
        const title = stripMentionPrefix(s.title) || s.sessionId;
        const botName = botDisplayName(s);
        const chatTitle = chatDisplayTitle(s);
        const repo = repoBasename(s.workingDir);
        const signal = boardSignalLabel(s);
        const desc = [chatTitle, repo !== '-' ? repo : null].filter(Boolean).join(' · ');
        const status = String(s.status ?? 'unknown');
        // 对方部署的会话：数据是 host 快照，终端/历史/改名都在对方机器上做不了——
        // 只保留状态点与部署来源徽章，卡片仍可拖（团队共享编排）。
        const remote = typeof s.remoteDeployment === 'string' ? s.remoteDeployment : '';
        return `<article class="kanban-card${remote ? ' kanban-card-remote' : ''}" data-id="${escapeHtml(s.sessionId)}" tabindex="0" role="button" draggable="true">
      <div class="kanban-card-top">
        <span class="badge cli-${cssToken(s.cliId)}">${escapeHtml(s.cliId ?? 'unknown')}</span>
        ${s.adopt ? '<span class="badge">adopt</span>' : ''}
        ${remote ? `<span class="badge kanban-remote-badge" title="${escapeHtml(t('sessions.kanban.remoteHint', { name: remote }))}">${escapeHtml(remote)}</span>` : ''}
        <span class="kanban-card-top-right">
          <span class="kanban-card-dot" data-status="${escapeHtml(status)}" title="${escapeHtml(status)}"></span>
          ${remote ? '' : `<button type="button" class="card-act kanban-card-act" data-action="history" title="${escapeHtml(t('sessions.history.title'))}" aria-label="${escapeHtml(t('sessions.history.title'))}">${ICON.history}</button>
          ${s.feishuChatLink ? `<a class="card-act kanban-card-act" href="${escapeHtml(s.feishuChatLink)}" target="_blank" rel="noopener" title="${escapeHtml(t('sessions.kanban.openFeishu'))}" aria-label="${escapeHtml(t('sessions.kanban.openFeishu'))}">${ICON.feishu}</a>` : ''}
          <button type="button" class="card-act kanban-card-act" data-action="details" title="${escapeHtml(t('sessions.details'))}" aria-label="${escapeHtml(t('sessions.details'))}">${ICON.details}</button>`}
        </span>
      </div>
      <p class="kanban-card-title" title="${escapeHtml(String(s.title ?? title))}">${escapeHtml(String(title).slice(0, 140))}</p>
      ${desc ? `<p class="kanban-card-desc" title="${escapeHtml(desc)}">${escapeHtml(desc)}</p>` : ''}
      ${signal ? `<div class="session-signal" title="${escapeHtml(signal)}">${escapeHtml(signal)}</div>` : ''}
      <div class="kanban-card-foot">
        <span class="kanban-card-owner">${botAvatarHtml({ name: botName, larkAppId: s.larkAppId, size: 'sm' })}<span>${escapeHtml(botName)}</span></span>
        <span class="kanban-card-updated">${escapeHtml(t('sessions.kanban.updated', { time: relTime(s.lastMessageAt) }))}</span>
      </div>
    </article>`;
    }
    /** 列内同群聚合：≥2 张同 chatId 的卡片折成群组容器（群头像 + 群名 + 计数），
     *  一眼看出它们关联同一个群/话题群；簇按首个成员出现位置参与列内排序。
     *  返回聚簇后 HTML 与视觉平铺顺序（drop 落点据此算相邻位置）。 */
    function clusteredListHtml(columnRows) {
        const order = [];
        const byChat = new Map();
        for (const r of columnRows) {
            const key = String(r.chatId ?? r.sessionId);
            let g = byChat.get(key);
            if (!g) {
                g = { chatId: key, rows: [] };
                byChat.set(key, g);
                order.push(g);
            }
            g.rows.push(r);
        }
        const flat = [];
        const html = order.map(g => {
            flat.push(...g.rows);
            if (g.rows.length < 2)
                return kanbanCardHtml(g.rows[0]);
            const title = chatDisplayTitle(g.rows[0]) ?? g.chatId;
            return `<div class="kanban-cluster" data-chat="${escapeHtml(g.chatId)}">
        <header draggable="true" title="${escapeHtml(title)} · ${escapeHtml(t('sessions.kanban.clusterDragHint'))}">
          ${chatAvatarHtml({ chatId: g.chatId, name: title, size: 'sm' })}
          <span class="kanban-cluster-name">${escapeHtml(title)}</span>
          <span class="kanban-cluster-count">${g.rows.length}</span>
        </header>
        ${g.rows.map(kanbanCardHtml).join('')}
      </div>`;
        }).join('');
        return { html, flat };
    }
    /** 团队模式：列 = 团队里的每个 bot（按名字排序），卡片 = 它名下的活跃会话，
     *  按最近活跃倒序、同群聚簇。协作总览视图——不支持拖拽（会话不能换 bot）。 */
    function kanbanByBotHtml(rows) {
        const bots = new Map();
        for (const r of rows) {
            if (r.status === 'closed')
                continue;
            const key = String(r.larkAppId || r.botName || 'unknown');
            let b = bots.get(key);
            if (!b) {
                b = { name: botDisplayName(r), larkAppId: r.larkAppId, rows: [] };
                bots.set(key, b);
            }
            b.rows.push(r);
        }
        const cols = [...bots.values()].sort((a, b) => a.name.localeCompare(b.name));
        if (!cols.length)
            return `<div class="kanban-col-empty">${t('sessions.board.emptyColumn')}</div>`;
        return cols.map(col => {
            const colRows = col.rows.sort((a, b) => Number(b.lastMessageAt ?? 0) - Number(a.lastMessageAt ?? 0));
            const { html: listHtml } = clusteredListHtml(colRows);
            return `<section class="kanban-column kanban-bot-col" data-bot="${escapeHtml(col.larkAppId ?? col.name)}">
        <header>
          <span class="kanban-col-avatar">${botAvatarHtml({ name: col.name, larkAppId: col.larkAppId, size: 'sm' })}</span>
          <h2>${escapeHtml(col.name)}</h2>
          <span class="kanban-col-count">${colRows.length}</span>
        </header>
        <div class="kanban-col-list">${listHtml}</div>
      </section>`;
        }).join('');
    }
    /** 工作流五列看板（flow/team 共用）：聚簇 + 拖拽落点数据。 */
    function kanbanFlowHtml(rows) {
        const groups = new Map(KANBAN_COLUMNS.map(c => [c.id, []]));
        for (const row of rows)
            groups.get(deriveKanbanColumn(row)).push(row);
        const html = KANBAN_COLUMNS.map(column => {
            let columnRows = (groups.get(column.id) ?? [])
                .sort((a, b) => effectiveKanbanPosition(a) - effectiveKanbanPosition(b));
            let hiddenCount = 0;
            if (column.id === 'done' && columnRows.length > KANBAN_DONE_CAP) {
                hiddenCount = columnRows.length - KANBAN_DONE_CAP;
                columnRows = columnRows.slice(0, KANBAN_DONE_CAP);
            }
            const { html: listHtml, flat } = clusteredListHtml(columnRows);
            groups.set(column.id, flat);
            return `<section class="kanban-column kanban-${column.id}" data-col="${column.id}">
        <header>
          <span class="kanban-col-icon">${kanbanStatusIcon(column.id)}</span>
          <h2>${escapeHtml(t(column.labelKey))}</h2>
          <span class="kanban-col-count">${columnRows.length + hiddenCount}</span>
        </header>
        <div class="kanban-col-list">
          ${columnRows.length ? listHtml : `<div class="kanban-col-empty">${t('sessions.board.emptyColumn')}</div>`}
          ${hiddenCount ? `<div class="kanban-col-more">${escapeHtml(t('sessions.kanban.moreHidden', { count: hiddenCount }))}</div>` : ''}
        </div>
      </section>`;
        }).join('');
        lastKanbanGroups = groups;
        return html;
    }
    function renderKanban(rows) {
        // 拖拽/编辑期间冻结 DOM —— innerHTML 重建会拍掉拖拽源和输入框。
        if (kanbanDragId || kanbanDragClusterChat || kanbanEditing)
            return;
        kanban.classList.toggle('kanban-mode-bot', kanbanGroupBy === 'bot');
        let html;
        if (kanbanGroupBy === 'bot') {
            html = kanbanByBotHtml(rows);
            lastKanbanGroups = new Map(); // 机器人视角无拖拽，不需要落点数据
        }
        else if (kanbanGroupBy === 'team') {
            if (!kanbanTeamsLoaded) {
                html = `<div class="kanban-loading">${t('sessions.kanban.teamLoading')}</div>`;
                lastKanbanGroups = new Map();
                // 加载期间下拉显示占位项并禁用——空胶囊很难看，也防止误操作
                if (!teamSelect.dataset.loading) {
                    teamSelect.dataset.loading = '1';
                    teamSelect.disabled = true;
                    teamSelect.innerHTML = `<option>${escapeHtml(t('sessions.kanban.teamLoading'))}</option>`;
                }
                void loadKanbanTeams();
            }
            else {
                const team = kanbanTeams.find(tm => tm.key === kanbanTeamKey) ?? kanbanTeams[0];
                // 「团队群」白名单（申晗定稿）：
                //   A. dashboard 团队页发起的协作群（建群时落盘的 team↔chatId 绑定）
                //   B. 群里 /introduce 过该团队成员机器人的群——介绍记录按名字与团队
                //      roster 匹配；介绍过的若不是本团队成员，不算（防误筛）
                // 命中群里所有 bot 的会话都展示（本质 = 同团队 bot 所在群/话题的会话）。
                let teamRows = [];
                const teamChats = new Set();
                if (team) {
                    for (const chatId of team.groupChats)
                        teamChats.add(chatId);
                    if (kanbanChatBots) {
                        for (const [chatId, c] of kanbanChatBots) {
                            if (teamChats.has(chatId))
                                continue;
                            // 自家团队 bot 在场，且群里介绍过同团队的外部机器人
                            let hasTeamBot = false;
                            for (const id of team.botIds) {
                                if (c.botIds.has(id)) {
                                    hasTeamBot = true;
                                    break;
                                }
                            }
                            if (!hasTeamBot)
                                continue;
                            for (const n of c.observedNames) {
                                if (team.botNames.has(n)) {
                                    teamChats.add(chatId);
                                    break;
                                }
                            }
                        }
                    }
                    teamRows = rows.filter(r => teamChats.has(String(r.chatId)));
                }
                // ── hub 团队看板合并（申晗架构：编排存团队 host）──────────────────────
                // 本地行（实时）+ 对方部署上报的裁剪行（host 快照）；共享编排的列/排序
                // 覆盖个人看板字段——团队视图里大家看到同一份摆放。
                if (team)
                    void ensureTeamBoard(team);
                const board = (kanbanTeamBoardKey === team?.key ? kanbanTeamBoardData?.board : null) ?? {};
                const remoteRows = (kanbanTeamBoardKey === team?.key ? kanbanTeamBoardData?.remoteRows : null) ?? [];
                const merged = [...teamRows, ...remoteRows].map(r => {
                    const e = board[r.sessionId];
                    return e ? { ...r, kanbanColumn: e.column, kanbanPosition: e.position } : r;
                });
                teamStats.textContent = t('sessions.kanban.teamScope', { chats: teamChats.size, sessions: merged.length });
                html = kanbanFlowHtml(merged);
            }
        }
        else {
            html = kanbanFlowHtml(rows);
        }
        if (html === lastKanbanHtml)
            return;
        lastKanbanHtml = html;
        // innerHTML 重建会把每列列表的滚动位置归零（改名失焦/SSE 更新时列表跳回
        // 顶部）——重建前按列记录 scrollTop，重建后恢复。
        const scrollTops = new Map();
        kanban.querySelectorAll('.kanban-col-list').forEach(el => {
            const col = el.closest('.kanban-column')?.dataset.col;
            if (col && el.scrollTop)
                scrollTops.set(col, el.scrollTop);
        });
        kanban.innerHTML = html;
        if (scrollTops.size) {
            kanban.querySelectorAll('.kanban-col-list').forEach(el => {
                const col = el.closest('.kanban-column')?.dataset.col;
                const top = col ? scrollTops.get(col) : undefined;
                if (top)
                    el.scrollTop = top;
            });
        }
    }
    // ── 看板写操作：拖拽放置 / 重命名（乐观更新 + 失败回滚）────────────────────
    async function persistBoardMove(s, column, position, prev) {
        try {
            const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/board`, {
                method: 'POST',
                headers: { 'content-type': 'application/json' },
                body: JSON.stringify({ column, position }),
            });
            const body = await r.json().catch(() => ({}));
            if (!r.ok || body?.ok === false) {
                s.kanbanColumn = prev.column;
                s.kanbanPosition = prev.position;
                lastKanbanHtml = '';
                rerender();
                // 401（只读访客）由全局 fetch patch 弹只读 toast，这里只负责回滚。
                if (r.status !== 401)
                    alert(`${t('sessions.kanban.moveFail')}: ${body?.error ?? r.status}`);
            }
        }
        catch (e) {
            s.kanbanColumn = prev.column;
            s.kanbanPosition = prev.position;
            lastKanbanHtml = '';
            rerender();
            alert(`${t('sessions.kanban.moveFail')}: ${e}`);
        }
    }
    async function persistRename(s, title) {
        const prevTitle = s.title;
        s.title = title;
        lastKanbanHtml = '';
        rerender();
        try {
            const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/rename`, {
                method: 'POST',
                headers: { 'content-type': 'application/json' },
                body: JSON.stringify({ title }),
            });
            const body = await r.json().catch(() => ({}));
            if (!r.ok || body?.ok === false) {
                s.title = prevTitle;
                lastKanbanHtml = '';
                rerender();
                if (r.status !== 401)
                    alert(`${t('sessions.kanban.renameFail')}: ${body?.error ?? r.status}`);
            }
        }
        catch (e) {
            s.title = prevTitle;
            lastKanbanHtml = '';
            rerender();
            alert(`${t('sessions.kanban.renameFail')}: ${e}`);
        }
    }
    // ── 会话历史弹窗：实时拉取该会话所在飞书话题/群的消息，按聊天气泡渲染 ──────
    /** Lark create_time 是毫秒 epoch（数字或数字字符串）——直接 new Date(字符串)
     *  会得到 Invalid Date。统一转数字解析，解析不出就不显示。 */
    function historyTime(v) {
        if (v === undefined || v === null || v === '')
            return '';
        const n = Number(v);
        const d = Number.isFinite(n) && n > 0 ? new Date(n) : new Date(String(v));
        return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
    }
    function historyBubbleHtml(s, m, ownerOpenId) {
        const mine = m.senderType === 'user';
        // 后端经 contact API 补了 senderName/senderAvatar（可见范围内的真人）；
        // 拿不到回退「创建者/用户」占位 + 首字圆。
        const name = mine
            ? (m.senderName
                || (ownerOpenId && m.senderId === ownerOpenId ? t('sessions.history.owner') : t('sessions.history.user')))
            : botDisplayName(s);
        const time = historyTime(m.createTime);
        const content = String(m.content ?? '').trim() || `[${m.msgType ?? 'message'}]`;
        const avatar = mine
            ? (m.senderAvatar
                ? `<img class="history-avatar-img" src="${escapeHtml(String(m.senderAvatar))}" alt="" decoding="async" referrerpolicy="no-referrer">`
                : `<span class="history-avatar-user" aria-hidden="true">${escapeHtml(String(name).slice(0, 1))}</span>`)
            : botAvatarHtml({ name: botDisplayName(s), larkAppId: s.larkAppId, size: 'sm' });
        return `<div class="history-msg${mine ? ' mine' : ''}">
      ${avatar}
      <div class="history-msg-main">
        <div class="history-msg-meta"><span>${escapeHtml(name)}</span><time>${escapeHtml(time)}</time></div>
        <div class="history-bubble">${escapeHtml(content)}</div>
      </div>
    </div>`;
    }
    async function openHistoryModal(s) {
        const botName = botDisplayName(s);
        historyModal.innerHTML = `<div class="term-modal-head">
        <span class="term-modal-title">
          ${botAvatarHtml({ name: botName, larkAppId: s.larkAppId, size: 'sm' })}
          <strong title="${escapeHtml(String(s.title ?? ''))}">${escapeHtml((stripMentionPrefix(s.title) || s.sessionId).slice(0, 60))}</strong>
          <span class="history-scope-tag">${escapeHtml(t('sessions.history.title'))}</span>
        </span>
        <span class="term-modal-actions">
          <button type="button" id="history-close" class="card-act" title="${escapeHtml(t('sessions.dismiss'))}" aria-label="${escapeHtml(t('sessions.dismiss'))}">${ICON.close}</button>
        </span>
      </div>
      <div class="history-body"><div class="term-modal-loading">${t('sessions.history.loading')}</div></div>`;
        historyModal.showModal();
        historyModal.querySelector('#history-close').onclick = () => historyModal.close();
        try {
            const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/history?limit=80`);
            const body = await r.json().catch(() => ({}));
            if (!historyModal.open)
                return;
            const bodyEl = historyModal.querySelector('.history-body');
            if (!r.ok || body?.ok === false) {
                const errCode = String(body?.error ?? r.status);
                // not_found_yet = dashboard 进程没有该路由；not_found = daemon 没有 ——
                // 都是进程仍跑旧 build 的特征，明示重启而不是让人猜。
                const stale = errCode === 'not_found_yet' || errCode === 'not_found';
                bodyEl.innerHTML = `<div class="history-error">${escapeHtml(t('sessions.history.fail'))}: ${escapeHtml(errCode)}${stale ? `<br><span>${escapeHtml(t('sessions.history.staleHint'))}</span>` : ''}</div>`;
                return;
            }
            const messages = Array.isArray(body.messages) ? body.messages : [];
            if (!messages.length) {
                bodyEl.innerHTML = `<div class="history-error">${t('sessions.history.empty')}</div>`;
                return;
            }
            bodyEl.innerHTML = `<div class="history-list">${messages.map(m => historyBubbleHtml(s, m, body.ownerOpenId)).join('')}</div>`;
            bodyEl.scrollTop = bodyEl.scrollHeight; // 默认停在最新一条
        }
        catch (e) {
            if (!historyModal.open)
                return;
            const bodyEl = historyModal.querySelector('.history-body');
            if (bodyEl)
                bodyEl.innerHTML = `<div class="history-error">${escapeHtml(t('sessions.history.fail'))}: ${escapeHtml(String(e))}</div>`;
        }
    }
    /** 把卡片标题就地换成输入框：Enter/失焦保存，Esc 取消。 */
    function startKanbanRename(card, s) {
        const titleEl = card.querySelector('.kanban-card-title');
        if (!titleEl || card.querySelector('.kanban-rename-input'))
            return;
        kanbanEditing = true;
        const input = document.createElement('input');
        input.type = 'text';
        input.className = 'kanban-rename-input';
        input.maxLength = 200;
        input.value = stripMentionPrefix(s.title) || '';
        titleEl.replaceWith(input);
        input.focus();
        input.select();
        let settled = false;
        const finish = (commit) => {
            if (settled)
                return;
            settled = true;
            kanbanEditing = false;
            const next = input.value.trim();
            if (commit && next && next !== (stripMentionPrefix(s.title) || '')) {
                void persistRename(s, next);
            }
            else {
                lastKanbanHtml = '';
                rerender();
            }
        };
        input.addEventListener('keydown', ev => {
            ev.stopPropagation();
            if (ev.key === 'Enter') {
                ev.preventDefault();
                finish(true);
            }
            else if (ev.key === 'Escape') {
                ev.preventDefault();
                finish(false);
            }
        });
        input.addEventListener('blur', () => finish(true));
        input.addEventListener('click', ev => ev.stopPropagation());
    }
    /** 指针 Y 落点之下的第一张卡片（不含拖拽源）—— 新卡插它前面；null = 追加列尾。 */
    function kanbanInsertBeforeCard(column, clientY) {
        for (const card of column.querySelectorAll('.kanban-card:not(.dragging)')) {
            // 整簇拖拽时簇内成员不能当落点参照
            if (card.closest('.kanban-cluster.dragging'))
                continue;
            const rect = card.getBoundingClientRect();
            if (clientY < rect.top + rect.height / 2)
                return card;
        }
        return null;
    }
    function clearKanbanDragMarks() {
        kanban.querySelectorAll('.drag-over, .dragging, .drop-before')
            .forEach(el => el.classList.remove('drag-over', 'dragging', 'drop-before'));
    }
    // 终端弹窗标题就地改名：标题文本换输入框，Enter/失焦保存、Esc 取消；
    // 复用 persistRename（乐观更新 + 失败回滚 + 全视图同步）。
    function startTermTitleEdit(s) {
        const nameEl = termModal.querySelector('.term-modal-name');
        if (!nameEl || termModal.querySelector('.term-modal-name-input'))
            return;
        const input = document.createElement('input');
        input.type = 'text';
        input.className = 'term-modal-name-input';
        input.maxLength = 200;
        input.value = stripMentionPrefix(s.title) || '';
        nameEl.replaceWith(input);
        // 宽度贴合内容：按文本实测像素宽设宽，clamp 到 [80, 60vw]，超长则到上限
        // 后内部横向滚动（与原标题省略号的「有上限」体感一致）。
        const fitInput = () => {
            const cs = getComputedStyle(input);
            const span = document.createElement('span');
            span.style.cssText = 'position:absolute;visibility:hidden;white-space:pre';
            span.style.fontSize = cs.fontSize;
            span.style.fontFamily = cs.fontFamily;
            span.style.fontWeight = cs.fontWeight;
            span.style.letterSpacing = cs.letterSpacing;
            span.textContent = input.value || ' ';
            document.body.appendChild(span);
            const w = span.offsetWidth;
            span.remove();
            const max = Math.round(window.innerWidth * 0.6);
            input.style.width = `${Math.min(Math.max(w + 22, 80), max)}px`;
        };
        fitInput();
        input.addEventListener('input', fitInput);
        input.focus();
        input.select();
        let settled = false;
        const finish = (commit) => {
            if (settled)
                return;
            settled = true;
            const next = input.value.trim();
            const cur = stripMentionPrefix(s.title) || '';
            if (commit && next && next !== cur) {
                s.title = next; // 弹窗内即时反映；persistRename 再发请求 + 背后 rerender
                const strong = document.createElement('strong');
                strong.className = 'term-modal-name';
                strong.title = next;
                strong.textContent = next.slice(0, 60);
                input.replaceWith(strong);
                void persistRename(s, next);
            }
            else {
                const strong = document.createElement('strong');
                strong.className = 'term-modal-name';
                strong.title = String(s.title ?? cur);
                strong.textContent = cur.slice(0, 60);
                input.replaceWith(strong);
            }
        };
        input.addEventListener('keydown', ev => {
            ev.stopPropagation();
            if (ev.key === 'Enter') {
                ev.preventDefault();
                finish(true);
            }
            else if (ev.key === 'Escape') {
                ev.preventDefault();
                finish(false);
            }
        });
        input.addEventListener('blur', () => finish(true));
    }
    // 页面内终端弹窗：默认嵌只读终端；已认证用户先 mint 可写链接（弹窗里能直接
    // 打字），拿不到再回退只读。没有 web 终端（挂起/已关闭）时退回详情抽屉。
    async function openTerminalModal(s) {
        const readonlyUrl = terminalHref(s);
        if (!readonlyUrl) {
            openDrawer(s);
            return;
        }
        const title = stripMentionPrefix(s.title) || s.sessionId;
        const feishu = s.feishuChatLink
            ? `<a class="card-act" href="${escapeHtml(s.feishuChatLink)}" target="_blank" rel="noopener" title="${escapeHtml(t('sessions.kanban.openFeishu'))}" aria-label="${escapeHtml(t('sessions.kanban.openFeishu'))}">${ICON.feishu}</a>`
            : '';
        termModal.innerHTML = `<div class="term-modal-head">
        <span class="term-modal-title">
          ${botAvatarHtml({ name: botDisplayName(s), larkAppId: s.larkAppId, size: 'sm' })}
          <strong class="term-modal-name" title="${escapeHtml(String(s.title ?? title))}">${escapeHtml(String(title).slice(0, 60))}</strong>
          <button type="button" id="term-modal-edit" class="card-act" title="${escapeHtml(t('sessions.kanban.rename'))}" aria-label="${escapeHtml(t('sessions.kanban.rename'))}">${ICON.edit}</button>
          <span class="status status-${escapeHtml(s.status ?? 'unknown')}">${escapeHtml(s.status ?? 'unknown')}</span>
        </span>
        <span class="term-modal-actions">
          ${feishu}
          <a id="term-modal-tab" class="card-act" href="${escapeHtml(readonlyUrl)}" target="_blank" rel="noopener" title="${escapeHtml(t('sessions.kanban.openTab'))}" aria-label="${escapeHtml(t('sessions.kanban.openTab'))}">${ICON.terminal}</a>
          <button type="button" id="term-modal-close" class="card-act" title="${escapeHtml(t('sessions.dismiss'))}" aria-label="${escapeHtml(t('sessions.dismiss'))}">${ICON.close}</button>
        </span>
      </div>
      <div class="term-modal-body"><div class="term-modal-loading">${t('sessions.kanban.terminalLoading')}</div></div>`;
        termModal.showModal();
        termModal.querySelector('#term-modal-close').onclick = () => termModal.close();
        termModal.querySelector('#term-modal-edit').onclick = () => startTermTitleEdit(s);
        let url = readonlyUrl;
        if (ui.authed) {
            try {
                const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/write-link`);
                const body = await r.json().catch(() => ({}));
                if (r.ok && body?.ok !== false && body?.url)
                    url = body.url;
            }
            catch {
                // 可写链接拿不到就用只读链接，弹窗仍可观看
            }
        }
        if (!termModal.open)
            return; // 加载期间用户已关掉弹窗
        const bodyEl = termModal.querySelector('.term-modal-body');
        bodyEl.innerHTML = `<iframe class="term-modal-frame" src="${escapeHtml(url)}" allow="clipboard-read; clipboard-write"></iframe>`;
        const tab = termModal.querySelector('#term-modal-tab');
        if (tab)
            tab.href = url;
    }
    function filtered() {
        const f = new FormData(filtersForm);
        const q = (f.get('q') ?? '').toLowerCase();
        const cli = f.getAll('cli');
        const cliFilterActive = cli.length > 0 && cli.length < CLI_FILTER_OPTIONS.length;
        const status = f.get('status');
        const adopt = f.get('adopt');
        const active = !!f.get('active');
        // 看板视图的「已完成」列收纳已关闭会话——「仅活跃」开关不再把它们整体
        // 滤掉，否则该列永远是空的。
        const keepClosed = viewMode === 'kanban';
        const rows = [...store.sessions.values()]
            .filter(s => !cliFilterActive || cli.includes(s.cliId ?? 'unknown'))
            .filter(s => !status || s.status === status)
            .filter(s => !adopt || (adopt === 'yes') === !!s.adopt)
            .filter(s => !active || keepClosed || s.status !== 'closed')
            .filter(s => !q || JSON.stringify(s).toLowerCase().includes(q));
        rows.sort(compareRows);
        return rows;
    }
    function sortValue(s, key) {
        if (key === 'spawnedAt' || key === 'lastMessageAt')
            return Number(s[key] ?? 0);
        if (key === 'tokenIn')
            return tokenCount(s.tokenUsage?.in) ?? -1;
        if (key === 'tokenOut')
            return tokenCount(s.tokenUsage?.out) ?? -1;
        if (key === 'adopt')
            return !!s.adopt;
        return String(s[key] ?? '').toLowerCase();
    }
    function compareRows(a, b) {
        const av = sortValue(a, sortKey);
        const bv = sortValue(b, sortKey);
        let cmp = 0;
        if (typeof av === 'number' && typeof bv === 'number')
            cmp = av - bv;
        else if (typeof av === 'boolean' && typeof bv === 'boolean')
            cmp = Number(av) - Number(bv);
        else
            cmp = String(av).localeCompare(String(bv));
        if (cmp === 0)
            cmp = Number(a.lastMessageAt ?? 0) - Number(b.lastMessageAt ?? 0);
        return sortDir === 'asc' ? cmp : -cmp;
    }
    function paintSortHeaders() {
        table.querySelectorAll('th[data-sort]').forEach(header => {
            const active = header.dataset.sort === sortKey;
            header.classList.toggle('sorted', active);
            header.setAttribute('aria-sort', active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none');
            const label = header.dataset.label ?? header.textContent?.trim() ?? '';
            header.textContent = active ? `${label} ${sortDir === 'asc' ? '▲' : '▼'}` : label;
        });
    }
    function syncBulkUi(rows) {
        bulkBar.hidden = selected.size === 0;
        bulkCountSpan.textContent = t('sessions.selectedCount', { count: selected.size });
        const selectable = rows.filter(r => r.status !== 'closed');
        if (selectable.length === 0) {
            selectAllBox.checked = false;
            selectAllBox.indeterminate = false;
            selectAllBox.disabled = true;
            return;
        }
        selectAllBox.disabled = false;
        const selectedInView = selectable.filter(r => selected.has(r.sessionId)).length;
        selectAllBox.checked = selectedInView === selectable.length;
        selectAllBox.indeterminate = selectedInView > 0 && selectedInView < selectable.length;
    }
    function paintViewToggle() {
        viewButtons.forEach(btn => {
            const active = btn.dataset.view === viewMode;
            btn.classList.toggle('active', active);
            btn.setAttribute('aria-pressed', String(active));
        });
        groupByBox.hidden = viewMode !== 'kanban';
        teamSelect.hidden = !(viewMode === 'kanban' && kanbanGroupBy === 'team');
        teamStats.hidden = teamSelect.hidden || !kanbanTeamsLoaded;
        groupByBox.querySelectorAll('[data-groupby]').forEach(btn => {
            const active = btn.dataset.groupby === kanbanGroupBy;
            btn.classList.toggle('active', active);
            btn.setAttribute('aria-pressed', String(active));
        });
    }
    // CLI 下拉 chip 上的已选计数：全选时显示「全部」，否则显示 N/总数
    function paintCliFilterCount() {
        const countEl = filtersForm.querySelector('#cli-filter-count');
        if (!countEl)
            return;
        const boxes = [...filtersForm.querySelectorAll('input[name="cli"]')];
        const checked = boxes.filter(b => b.checked).length;
        countEl.textContent = checked === boxes.length ? t('common.all') : `${checked}/${boxes.length}`;
        countEl.classList.toggle('cli-filter-active', checked !== boxes.length);
    }
    function rerender() {
        const rows = filtered();
        for (const sid of [...selected]) {
            const s = store.sessions.get(sid);
            if (!s || s.status === 'closed')
                selected.delete(sid);
        }
        const boardRows = rows.filter(r => r.status !== 'closed');
        const visibleRows = viewMode === 'table' ? rows : boardRows;
        table.hidden = viewMode !== 'table';
        board.hidden = viewMode !== 'board';
        kanban.hidden = viewMode !== 'kanban';
        if (viewMode === 'table') {
            const tableHtml = rows.length
                ? rows.map(rowHtml).join('')
                : `<tr><td colspan="12" class="empty">${t('sessions.empty')}</td></tr>`;
            if (tableHtml !== lastTableHtml) {
                lastTableHtml = tableHtml;
                tbody.innerHTML = tableHtml;
            }
        }
        else if (viewMode === 'kanban') {
            renderKanban(rows);
        }
        else {
            renderBoard(boardRows);
        }
        paintViewToggle();
        paintSortHeaders();
        paintCliFilterCount();
        syncBulkUi(visibleRows);
    }
    async function locateSession(s, locateBtn) {
        if (locateBtn) {
            locateBtn.disabled = true;
            locateBtn.textContent = t('sessions.locating');
        }
        try {
            const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/locate`, { method: 'POST' });
            const body = await r.json();
            if (body.ok) {
                if (!locateBtn)
                    return;
                let left = 30;
                locateBtn.textContent = t('sessions.cooldown', { seconds: left });
                const tick = setInterval(() => {
                    left -= 1;
                    if (left <= 0) {
                        clearInterval(tick);
                        locateBtn.disabled = false;
                        locateBtn.textContent = t('sessions.locate');
                    }
                    else {
                        locateBtn.textContent = t('sessions.cooldown', { seconds: left });
                    }
                }, 1000);
            }
            else {
                alert(`Locate failed: ${body.error ?? r.status}`);
                if (locateBtn) {
                    locateBtn.disabled = false;
                    locateBtn.textContent = t('sessions.locate');
                }
            }
        }
        catch (e) {
            alert(`Locate error: ${e}`);
            if (locateBtn) {
                locateBtn.disabled = false;
                locateBtn.textContent = t('sessions.locate');
            }
        }
    }
    async function closeSession(s, closeBtn) {
        if (!confirm(t('sessions.closeConfirm')))
            return false;
        if (closeBtn)
            closeBtn.disabled = true;
        try {
            // 与批量关闭同口径：401（只读访客）/ 5xx 都不能当成功——否则抽屉
            // 静默关闭、board 重绘，用户以为关掉了。401 的提示由全局 fetch
            // patch 弹只读 toast，这里只负责不误报成功。
            const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/close`, { method: 'POST' });
            const body = await r.json().catch(() => ({}));
            if (!r.ok || body?.ok === false) {
                if (r.status !== 401)
                    alert(`Close failed: ${body?.error ?? r.status}`);
                return false;
            }
            return true;
        }
        catch (e) {
            alert(`Close error: ${e}`);
            return false;
        }
        finally {
            if (closeBtn)
                closeBtn.disabled = false;
        }
    }
    function openDrawer(s) {
        const closed = s.status === 'closed';
        const terminal = terminalHref(s);
        drawer.innerHTML = `<article>
      <header>
        <h3>${escapeHtml(stripMentionPrefix(s.title) || s.sessionId)}</h3>
        <span class="status status-${escapeHtml(s.status ?? 'unknown')}">${escapeHtml(s.status ?? 'unknown')}</span>
        <p><code>${escapeHtml(s.sessionId)}</code> <button data-copy="${escapeHtml(s.sessionId)}">${t('sessions.copy')}</button></p>
      </header>
      <p><b>${t('sessions.bot')}:</b> ${escapeHtml(botDisplayName(s))} · <b>${t('sessions.cli')}:</b> ${escapeHtml(s.cliId ?? '?')}</p>
      ${chatDisplayTitle(s) ? `<p><b>${t('sessions.chat')}:</b> ${escapeHtml(chatDisplayTitle(s))}</p>` : ''}
      <p><b>chatId:</b> <code>${escapeHtml(s.chatId ?? '')}</code> <button data-copy="${escapeHtml(s.chatId ?? '')}">${t('sessions.copy')}</button></p>
      <p><b>rootMessageId:</b> <code>${escapeHtml(s.rootMessageId ?? '')}</code> <button data-copy="${escapeHtml(s.rootMessageId ?? '')}">${t('sessions.copy')}</button></p>
      ${s.threadId ? `<p><b>threadId:</b> <code>${escapeHtml(s.threadId)}</code></p>` : ''}
      <p><b>${t('sessions.workingDir')}:</b> ${escapeHtml(s.workingDir ?? '-')}</p>
      <div class="actions">
        ${chatScopeLink(s) ?? `<button id="locate-btn" type="button">${t('sessions.locate')}</button>`}
        <button id="history-drawer-btn" type="button">${t('sessions.history.title')}</button>
        ${terminalControlsHtml(terminal)}
        ${closed ? `<button id="resume-btn" type="button" class="primary">${t('sessions.resume')}</button>` : ''}
        ${!closed ? `<button id="close-btn" type="button" class="contrast">${t('sessions.close')}</button>` : ''}
        <button id="land-btn" type="button">${t('sessions.land')}</button>
      </div>
      <div id="land-area"></div>
      <form method="dialog"><button>${t('sessions.dismiss')}</button></form>
    </article>`;
        drawer.querySelectorAll('[data-copy]').forEach(btn => {
            btn.onclick = () => {
                navigator.clipboard.writeText(btn.dataset.copy ?? '');
                btn.textContent = t('sessions.copied');
                setTimeout(() => { btn.textContent = t('sessions.copy'); }, 800);
            };
        });
        const locateBtn = drawer.querySelector('#locate-btn');
        if (locateBtn) {
            locateBtn.onclick = () => void locateSession(s, locateBtn);
        }
        const historyBtn = drawer.querySelector('#history-drawer-btn');
        if (historyBtn) {
            historyBtn.onclick = () => void openHistoryModal(s);
        }
        // Writable-terminal segment (.term-write) lives inside the drawer, outside
        // the board's click delegation — wire it directly.
        const writeBtn = drawer.querySelector('.term-write');
        if (writeBtn) {
            writeBtn.onclick = () => void openWriteLink(s, writeBtn);
        }
        const resumeBtn = drawer.querySelector('#resume-btn');
        if (resumeBtn) {
            resumeBtn.onclick = async () => {
                resumeBtn.disabled = true;
                try {
                    const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/resume`, { method: 'POST' });
                    const body = await r.json().catch(() => ({}));
                    if (!r.ok || body.ok === false) {
                        alert(`${t('sessions.resumeFailed')}: ${body?.error ?? r.status}`);
                        resumeBtn.disabled = false;
                        return;
                    }
                    drawer.close();
                }
                catch (e) {
                    alert(`${t('sessions.resumeFailed')}: ${e}`);
                    resumeBtn.disabled = false;
                }
            };
        }
        const closeBtn = drawer.querySelector('#close-btn');
        if (closeBtn) {
            closeBtn.onclick = async () => {
                if (await closeSession(s, closeBtn))
                    drawer.close();
            };
        }
        // Sandbox landing: fetch the clone's diff, show it, then apply/discard.
        const landBtn = drawer.querySelector('#land-btn');
        const landArea = drawer.querySelector('#land-area');
        if (landBtn && landArea) {
            landBtn.onclick = async () => {
                landBtn.disabled = true;
                landArea.innerHTML = `<p>${t('sessions.landLoading')}</p>`;
                try {
                    const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/sandbox-diff`);
                    const d = await r.json().catch(() => ({}));
                    if (!d.ok) {
                        landArea.innerHTML = `<p>${t('sessions.landUnavailable')}: ${escapeHtml(d.error ?? String(r.status))}</p>`;
                        landBtn.disabled = false;
                        return;
                    }
                    if (d.empty) {
                        landArea.innerHTML = `<p>${t('sessions.landEmpty')}</p>`;
                        landBtn.disabled = false;
                        return;
                    }
                    const full = String(d.patch ?? '');
                    const patch = full.slice(0, 20000) + (full.length > 20000 ? '\n…(truncated)' : '');
                    landArea.innerHTML = `
            <p><b>${d.files}</b> files (+${d.insertions}/-${d.deletions}) → <code>${escapeHtml(String(d.workingDir ?? ''))}</code></p>
            <pre style="max-height:320px;overflow:auto;white-space:pre-wrap">${escapeHtml(patch)}</pre>
            <div class="actions">
              <button id="land-apply" type="button" class="primary">${t('sessions.landApply')}</button>
              <button id="land-discard" type="button" class="contrast">${t('sessions.landDiscard')}</button>
            </div>`;
                    const applyBtn = landArea.querySelector('#land-apply');
                    const discardBtn = landArea.querySelector('#land-discard');
                    applyBtn.onclick = async () => {
                        applyBtn.disabled = true;
                        discardBtn.disabled = true;
                        const rr = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/sandbox-land/apply`, { method: 'POST' });
                        const res = await rr.json().catch(() => ({}));
                        landArea.innerHTML = res.ok
                            ? `<p>✅ ${t('sessions.landApplied')}: ${res.files} files (+${res.insertions}/-${res.deletions}) → <code>${escapeHtml(String(res.workingDir ?? ''))}</code></p>`
                            : `<p>❌ ${t('sessions.landFailed')}: ${escapeHtml(res.error ?? String(rr.status))}</p>`;
                    };
                    discardBtn.onclick = async () => {
                        await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/sandbox-land/discard`, { method: 'POST' });
                        landArea.innerHTML = `<p>🗑 ${t('sessions.landDiscarded')}</p>`;
                    };
                }
                catch (e) {
                    landArea.innerHTML = `<p>${t('sessions.landUnavailable')}: ${escapeHtml(String(e))}</p>`;
                    landBtn.disabled = false;
                }
            };
        }
        drawer.showModal();
    }
    tbody.addEventListener('click', e => {
        const target = e.target;
        if (target.classList.contains('row-select')) {
            const tr = target.closest('tr[data-id]');
            if (!tr)
                return;
            const cb = target;
            if (cb.checked)
                selected.add(tr.dataset.id);
            else
                selected.delete(tr.dataset.id);
            syncBulkUi(filtered());
            return;
        }
        const td = target.closest('td');
        if (td && td.querySelector('.row-select'))
            return;
        const tr = target.closest('tr[data-id]');
        if (!tr)
            return;
        const s = store.sessions.get(tr.dataset.id);
        if (s)
            openDrawer(s);
    });
    board.addEventListener('click', e => {
        const target = e.target;
        // 列头 ‹/› 按钮：交换列顺序（拖拽的键盘/触屏兜底）
        const moveBtn = target.closest('button[data-move-col]');
        if (moveBtn) {
            moveColumn(moveBtn.dataset.moveCol, Number(moveBtn.dataset.dir));
            return;
        }
        const card = target.closest('.session-card[data-id]');
        if (!card)
            return;
        const s = store.sessions.get(card.dataset.id);
        if (!s)
            return;
        const actionButton = target.closest('button[data-action]');
        if (actionButton) {
            const action = actionButton.dataset.action;
            if (action === 'details')
                openDrawer(s);
            else if (action === 'write-link')
                void openWriteLink(s, actionButton);
            else if (action === 'locate')
                void locateSession(s, actionButton);
            else if (action === 'close')
                void closeSession(s, actionButton).then(ok => {
                    if (ok) {
                        selected.delete(s.sessionId);
                        rerender();
                    }
                });
            return;
        }
        // Clicking the card body toggles selection (the Feishu topic link lives
        // behind the 定位 button) — no checkbox, the whole card is the target.
        if (target.closest('a, button, input, label'))
            return;
        if (selected.has(s.sessionId))
            selected.delete(s.sessionId);
        else
            selected.add(s.sessionId);
        card.classList.toggle('selected', selected.has(s.sessionId));
        card.setAttribute('aria-pressed', String(selected.has(s.sessionId)));
        syncBulkUi(filtered().filter(r => r.status !== 'closed'));
    });
    // ── 列头拖拽排序：拖起一列，放到目标列的位置 ─────────────────────────────
    board.addEventListener('dragstart', e => {
        const header = e.target.closest('.session-board-column > header[draggable]');
        const col = header?.closest('.session-board-column');
        if (!col?.dataset.col)
            return;
        dragColId = col.dataset.col;
        col.classList.add('dragging');
        if (e.dataTransfer) {
            e.dataTransfer.effectAllowed = 'move';
            e.dataTransfer.setData('text/plain', dragColId);
        }
    });
    board.addEventListener('dragover', e => {
        if (!dragColId)
            return;
        e.preventDefault();
        if (e.dataTransfer)
            e.dataTransfer.dropEffect = 'move';
        const target = e.target.closest('.session-board-column');
        board.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
        if (target && target.dataset.col !== dragColId)
            target.classList.add('drag-over');
    });
    board.addEventListener('drop', e => {
        if (!dragColId)
            return;
        e.preventDefault();
        const target = e.target.closest('.session-board-column');
        const from = dragColId;
        dragColId = null;
        if (target?.dataset.col)
            moveColumnTo(from, target.dataset.col);
    });
    board.addEventListener('dragend', () => {
        dragColId = null;
        board.querySelectorAll('.drag-over, .dragging').forEach(el => el.classList.remove('drag-over', 'dragging'));
    });
    viewButtons.forEach(btn => {
        btn.addEventListener('click', () => {
            const next = normalizeSessionsViewMode(btn.dataset.view) ?? 'board';
            if (next === viewMode)
                return;
            viewMode = next;
            writeStoredSessionsViewMode(window.localStorage, viewMode);
            rerender();
        });
    });
    groupByBox.querySelectorAll('[data-groupby]').forEach(btn => {
        btn.addEventListener('click', () => {
            const raw = btn.dataset.groupby;
            const next = raw === 'bot' ? 'bot' : raw === 'team' ? 'team' : 'flow';
            if (next === kanbanGroupBy)
                return;
            kanbanGroupBy = next;
            writeStoredKanbanGroupBy(window.localStorage, next);
            lastKanbanHtml = '';
            rerender();
        });
    });
    teamSelect.addEventListener('change', () => {
        kanbanTeamKey = teamSelect.value;
        try {
            window.localStorage.setItem(KANBAN_TEAM_STORAGE_KEY, kanbanTeamKey);
        }
        catch { /* 仅当前页生效 */ }
        lastKanbanHtml = '';
        rerender();
    });
    // ── 看板交互：单击开终端弹窗（延迟仲裁让位双击）、铅笔/双击改标题、
    //    「详情」进抽屉、整卡拖拽换列与排序 ─────────────────────────────────────
    function cancelKanbanOpen() {
        if (kanbanOpenTimer !== null) {
            clearTimeout(kanbanOpenTimer);
            kanbanOpenTimer = null;
        }
    }
    kanban.addEventListener('click', e => {
        const target = e.target;
        const card = target.closest('.kanban-card[data-id]');
        if (!card)
            return;
        const s = store.sessions.get(card.dataset.id);
        if (!s)
            return;
        const actionButton = target.closest('button[data-action]');
        if (actionButton) {
            if (actionButton.dataset.action === 'details')
                openDrawer(s);
            else if (actionButton.dataset.action === 'rename')
                startKanbanRename(card, s);
            else if (actionButton.dataset.action === 'history')
                void openHistoryModal(s);
            return;
        }
        if (target.closest('a, button, input, label'))
            return;
        cancelKanbanOpen();
        kanbanOpenTimer = setTimeout(() => {
            kanbanOpenTimer = null;
            void openTerminalModal(s);
        }, 220);
    });
    kanban.addEventListener('dblclick', e => {
        const target = e.target;
        const titleEl = target.closest('.kanban-card-title');
        const card = target.closest('.kanban-card[data-id]');
        if (!titleEl || !card)
            return;
        cancelKanbanOpen();
        const s = store.sessions.get(card.dataset.id);
        if (s)
            startKanbanRename(card, s);
    });
    kanban.addEventListener('keydown', e => {
        if (e.key !== 'Enter' && e.key !== ' ')
            return;
        const target = e.target;
        if (!target.classList?.contains('kanban-card'))
            return;
        e.preventDefault();
        const s = store.sessions.get(target.dataset.id);
        if (s)
            void openTerminalModal(s);
    });
    // ── 看板卡片拖拽 ──────────────────────────────────────────────────────────
    kanban.addEventListener('dragstart', e => {
        if (kanbanGroupBy === 'bot')
            return; // 机器人视角只读：会话不能拖给别的 bot
        const target = e.target;
        // 拖群组容器头部 = 整簇搬运
        const clusterHeader = target.closest('.kanban-cluster > header[draggable]');
        if (clusterHeader) {
            const cluster = clusterHeader.closest('.kanban-cluster');
            const col = cluster.closest('.kanban-column')?.dataset.col;
            if (!cluster.dataset.chat || !col)
                return;
            cancelKanbanOpen();
            kanbanDragClusterChat = cluster.dataset.chat;
            kanbanDragClusterCol = col;
            cluster.classList.add('dragging');
            if (e.dataTransfer) {
                e.dataTransfer.effectAllowed = 'move';
                e.dataTransfer.setData('text/plain', `cluster:${kanbanDragClusterChat}`);
            }
            return;
        }
        const card = target.closest('.kanban-card[data-id]');
        if (!card)
            return;
        cancelKanbanOpen();
        kanbanDragId = card.dataset.id;
        card.classList.add('dragging');
        if (e.dataTransfer) {
            e.dataTransfer.effectAllowed = 'move';
            e.dataTransfer.setData('text/plain', kanbanDragId);
        }
    });
    kanban.addEventListener('dragover', e => {
        if (!kanbanDragId && !kanbanDragClusterChat)
            return;
        e.preventDefault();
        if (e.dataTransfer)
            e.dataTransfer.dropEffect = 'move';
        const column = e.target.closest('.kanban-column');
        kanban.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
        kanban.querySelectorAll('.drop-before').forEach(el => el.classList.remove('drop-before'));
        if (!column)
            return;
        column.classList.add('drag-over');
        kanbanInsertBeforeCard(column, e.clientY)?.classList.add('drop-before');
    });
    kanban.addEventListener('drop', e => {
        const clusterChat = kanbanDragClusterChat;
        const clusterCol = kanbanDragClusterCol;
        const dragId = kanbanDragId;
        if (!dragId && !clusterChat)
            return;
        e.preventDefault();
        kanbanDragId = null;
        kanbanDragClusterChat = null;
        kanbanDragClusterCol = null;
        clearKanbanDragMarks();
        const column = e.target.closest('.kanban-column');
        const targetCol = column?.dataset.col;
        if (!column || !targetCol)
            return;
        const beforeCard = kanbanInsertBeforeCard(column, e.clientY);
        // ── 整簇搬运：源列里该群的全部卡片保持相对顺序插到落点 ──────────────────
        if (clusterChat && clusterCol) {
            const members = (lastKanbanGroups.get(clusterCol) ?? [])
                .filter((r) => String(r.chatId) === clusterChat)
                // 已关闭会话固定在「已完成」：整簇挪去别的列时留下它们
                .filter((r) => !(r.status === 'closed' && targetCol !== 'done'));
            if (!members.length)
                return;
            const memberIds = new Set(members.map((r) => r.sessionId));
            const colRows = (lastKanbanGroups.get(targetCol) ?? []).filter((r) => !memberIds.has(r.sessionId));
            let index = beforeCard ? colRows.findIndex((r) => r.sessionId === beforeCard.dataset.id) : colRows.length;
            if (index < 0)
                index = colRows.length;
            const prevRow = index > 0 ? colRows[index - 1] : null;
            const nextRow = index < colRows.length ? colRows[index] : null;
            const base = computeDropPosition(prevRow ? effectiveKanbanPosition(prevRow) : null, nextRow ? effectiveKanbanPosition(nextRow) : null);
            members.forEach((m, i) => {
                const pos = base + i * 0.001;
                if (kanbanGroupBy === 'team') {
                    // 团队模式写 host 的共享编排，不动各会话的个人看板字段
                    applyTeamBoardMove(String(m.sessionId), targetCol, pos);
                }
                else {
                    const prev = { column: m.kanbanColumn, position: m.kanbanPosition };
                    m.kanbanColumn = targetCol;
                    m.kanbanPosition = pos;
                    void persistBoardMove(m, targetCol, pos, prev);
                }
            });
            lastKanbanHtml = '';
            rerender();
            return;
        }
        // ── 单卡搬运 ─────────────────────────────────────────────────────────────
        // 对方部署的行不在 store 里——团队看板的远程缓存兜底
        const s = store.sessions.get(dragId) ?? kanbanRemoteRows.get(dragId);
        if (!s)
            return;
        // 已关闭会话固定在「已完成」列，只允许列内重排。
        if (s.status === 'closed' && targetCol !== 'done')
            return;
        const colRows = (lastKanbanGroups.get(targetCol) ?? []).filter((r) => r.sessionId !== dragId);
        let index = beforeCard ? colRows.findIndex((r) => r.sessionId === beforeCard.dataset.id) : colRows.length;
        if (index < 0)
            index = colRows.length;
        const prevRow = index > 0 ? colRows[index - 1] : null;
        const nextRow = index < colRows.length ? colRows[index] : null;
        const position = computeDropPosition(prevRow ? effectiveKanbanPosition(prevRow) : null, nextRow ? effectiveKanbanPosition(nextRow) : null);
        if (kanbanGroupBy === 'team') {
            applyTeamBoardMove(String(s.sessionId), targetCol, position);
            lastKanbanHtml = '';
            rerender();
            return;
        }
        const prev = { column: s.kanbanColumn, position: s.kanbanPosition };
        s.kanbanColumn = targetCol;
        s.kanbanPosition = position;
        lastKanbanHtml = '';
        rerender();
        void persistBoardMove(s, targetCol, position, prev);
    });
    kanban.addEventListener('dragend', () => {
        kanbanDragId = null;
        kanbanDragClusterChat = null;
        kanbanDragClusterCol = null;
        clearKanbanDragMarks();
        lastKanbanHtml = '';
        rerender();
    });
    // 点弹窗 backdrop 关闭；关闭时清空内容，立刻断开 iframe 里的终端 WebSocket。
    termModal.addEventListener('click', e => {
        if (e.target === termModal)
            termModal.close();
    });
    termModal.addEventListener('close', () => {
        termModal.innerHTML = '';
    });
    historyModal.addEventListener('click', e => {
        if (e.target === historyModal)
            historyModal.close();
    });
    historyModal.addEventListener('close', () => {
        historyModal.innerHTML = '';
    });
    selectAllBox.addEventListener('change', () => {
        const rows = filtered().filter(r => r.status !== 'closed');
        for (const row of rows) {
            if (selectAllBox.checked)
                selected.add(row.sessionId);
            else
                selected.delete(row.sessionId);
        }
        rerender();
    });
    bulkClearBtn.addEventListener('click', () => {
        selected.clear();
        rerender();
    });
    bulkCloseBtn.addEventListener('click', async () => {
        const ids = [...selected];
        if (ids.length === 0)
            return;
        if (!confirm(t('sessions.closeBulkConfirm', { count: ids.length })))
            return;
        bulkCloseBtn.disabled = true;
        bulkClearBtn.disabled = true;
        const original = bulkCloseBtn.textContent;
        let done = 0;
        let failed = 0;
        const queue = [...ids];
        bulkCloseBtn.textContent = `0/${ids.length}`;
        async function worker() {
            while (queue.length) {
                const sid = queue.shift();
                try {
                    const r = await fetch(`/api/sessions/${encodeURIComponent(sid)}/close`, { method: 'POST' });
                    const body = await r.json().catch(() => ({}));
                    if (!r.ok || body?.ok === false)
                        failed += 1;
                }
                catch {
                    failed += 1;
                }
                finally {
                    done += 1;
                    bulkCloseBtn.textContent = `${done}/${ids.length}`;
                }
            }
        }
        await Promise.all(Array.from({ length: Math.min(6, ids.length) }, () => worker()));
        bulkCloseBtn.textContent = original;
        bulkCloseBtn.disabled = false;
        bulkClearBtn.disabled = false;
        selected.clear();
        rerender();
        if (failed > 0)
            alert(`Failed: ${failed}/${ids.length}`);
    });
    table.querySelectorAll('th[data-sort]').forEach(header => {
        header.addEventListener('click', () => {
            const key = header.dataset.sort;
            if (sortKey === key)
                sortDir = sortDir === 'asc' ? 'desc' : 'asc';
            else {
                sortKey = key;
                sortDir = key === 'spawnedAt' || key === 'lastMessageAt' ? 'desc' : 'asc';
            }
            rerender();
        });
    });
    filtersForm.addEventListener('input', rerender);
    store.on(rerender);
    // 团队看板 30s 软刷新（拉对方部署的会话快照与共享编排）；页面切走后
    // kanban 脱离 DOM，定时器自清。
    const teamBoardTimer = setInterval(() => {
        if (!document.body.contains(kanban)) {
            clearInterval(teamBoardTimer);
            return;
        }
        if (viewMode === 'kanban' && kanbanGroupBy === 'team') {
            lastKanbanHtml = '';
            rerender();
        }
    }, 30_000);
    rerender();
    // bot 友好名 / 群聊标题异步解析，回来后补一次重绘（首帧先显示原值）
    void loadNameMaps().then(rerender);
}
//# sourceMappingURL=sessions.js.map