Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/dashboard/web/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ const zh: DashboardMessages = {
'sessions.locating': '发送中...',
'sessions.cooldown': '冷却 {seconds}s',
'sessions.openTerminal': '终端',
'sessions.openReadonlyTerminal': '打开只读终端',
'sessions.openWritableTerminal': '打开可操作 Web 终端',
'sessions.writeLink': '操作链接',
'sessions.writeLinkHint': '获取可操作终端链接(带写权限 token,新标签打开)',
'sessions.writeLinkFail': '获取操作链接失败',
Expand Down Expand Up @@ -2315,6 +2317,8 @@ const en: DashboardMessages = {
'sessions.locating': 'Sending...',
'sessions.cooldown': 'Cooldown {seconds}s',
'sessions.openTerminal': 'Terminal',
'sessions.openReadonlyTerminal': 'Open read-only terminal',
'sessions.openWritableTerminal': 'Open interactive web terminal',
'sessions.writeLink': 'Write Link',
'sessions.writeLinkHint': 'Get a writable terminal link (carries a write-access token; opens in a new tab)',
'sessions.writeLinkFail': 'Failed to get write link',
Expand Down
12 changes: 11 additions & 1 deletion src/dashboard/web/sessions-kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface SessionsKanbanIcons {
details: string;
feishu: string;
history: string;
key: string;
lock: string;
restart: string;
terminal: string;
Expand Down Expand Up @@ -79,6 +80,7 @@ export interface SessionsKanbanCallbacks {
onNeedTeamBoard: (team: SessionsKanbanTeam) => void;
onNeedTeams: () => void;
onOpenTerminal?: (row: any) => void;
onOpenWritableTerminal?: (row: any, button: HTMLButtonElement) => void;
onRename: (row: any, title: string) => void;
onRestart: (row: any, button: HTMLButtonElement) => void;
onTeamScope: (scope: { chats: number; sessions: number } | null) => void;
Expand Down Expand Up @@ -431,10 +433,18 @@ function KanbanCard(props: {
<CardActButton
action="terminal"
icon={callbacks.icons.terminal}
label={t('sessions.openTerminal')}
label={t('sessions.openReadonlyTerminal')}
onClick={() => callbacks.onOpenTerminal?.(row)}
/>
) : null}
{row.webPort && callbacks.onOpenWritableTerminal ? (
<CardActButton
action="write-link"
icon={callbacks.icons.key}
label={t('sessions.openWritableTerminal')}
onClick={button => callbacks.onOpenWritableTerminal?.(row, button)}
/>
) : null}
{row.feishuChatLink ? (
<a
className="card-act kanban-card-act"
Expand Down
57 changes: 25 additions & 32 deletions src/dashboard/web/sessions-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,34 +244,34 @@ function IconActionButton(props: {

function TerminalControls(props: { row: any; url: string | null }): JSX.Element | null {
if (!props.url || !dashboardShellAllowsWebTerminal()) return null;
const readOnly = !shouldOpenWritableTerminal();
const canOpenWritable = shouldOpenWritableTerminal();
return (
<span className={`term-pill${readOnly ? ' readonly' : ' writable'}`}>
{readOnly ? (
<a
className="term-btn term-open"
href={props.url}
target="_blank"
rel="noopener"
data-tip={t('sessions.openTerminal')}
aria-label={t('sessions.openTerminal')}
onClick={event => event.stopPropagation()}
dangerouslySetInnerHTML={rawHtml(ICON.terminal)}
/>
) : (
<span className="term-pill">
<a
className="term-btn term-open"
href={props.url}
target="_blank"
rel="noopener"
data-action="terminal"
data-tip={t('sessions.openReadonlyTerminal')}
aria-label={t('sessions.openReadonlyTerminal')}
onClick={event => event.stopPropagation()}
dangerouslySetInnerHTML={rawHtml(ICON.terminal)}
/>
{canOpenWritable ? (
<button
type="button"
className="term-btn term-write"
data-action="write-link"
data-tip={t('sessions.openTerminal')}
aria-label={t('sessions.openTerminal')}
data-tip={t('sessions.openWritableTerminal')}
aria-label={t('sessions.openWritableTerminal')}
onClick={(event) => {
event.stopPropagation();
void openWriteLink(props.row, event.currentTarget);
}}
dangerouslySetInnerHTML={rawHtml(ICON.terminal)}
dangerouslySetInnerHTML={rawHtml(ICON.key)}
/>
)}
) : null}
</span>
);
}
Expand Down Expand Up @@ -2725,20 +2725,11 @@ function SessionsPage(): JSX.Element {
setDrawerSessionId(row.sessionId);
return;
}
setTermState({ sessionId: row.sessionId, url: readonlyUrl, loading: true });
void (async () => {
let url = readonlyUrl;
if (shouldOpenWritableTerminal()) {
try {
const r = await fetch(`/api/sessions/${encodeURIComponent(row.sessionId)}/write-link`);
const body = await r.json().catch(() => ({}));
if (r.ok && body?.ok !== false && body?.url) url = body.url;
} catch {
// fallback to read-only URL
}
}
setTermState(prev => prev?.sessionId === row.sessionId ? { sessionId: row.sessionId, url, loading: false } : prev);
})();
setTermState({ sessionId: row.sessionId, url: readonlyUrl, loading: false });
}, []);

const openWritableTerminal = useCallback((row: any, button?: HTMLButtonElement): void => {
void openWriteLink(row, button);
}, []);

const runBulkClose = useCallback(async (): Promise<void> => {
Expand Down Expand Up @@ -3225,6 +3216,7 @@ function SessionsPage(): JSX.Element {
details: ICON.details,
feishu: ICON.feishu,
history: ICON.history,
key: ICON.key,
lock: ICON.lock,
restart: ICON.restart,
terminal: ICON.terminal,
Expand All @@ -3238,6 +3230,7 @@ function SessionsPage(): JSX.Element {
onNeedTeamBoard={team => { void ensureTeamBoard(team); }}
onNeedTeams={() => { void loadKanbanTeams(); }}
onOpenTerminal={dashboardShellAllowsWebTerminal() ? openTerminalModal : undefined}
onOpenWritableTerminal={dashboardShellAllowsWebTerminal() && shouldOpenWritableTerminal() ? openWritableTerminal : undefined}
onRename={(row, title) => { const s = store.sessions.get(String(row.sessionId)); if (s) void persistRename(s, title); }}
onRestart={(row, button) => { const s = store.sessions.get(String(row.sessionId)); if (s) void restartSession(s, button); }}
onTeamScope={scope => setTeamScopeText(scope ? t('sessions.kanban.teamScope', { chats: scope.chats, sessions: scope.sessions }) : '')}
Expand Down
4 changes: 2 additions & 2 deletions src/dashboard/web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -3687,11 +3687,11 @@ button.primary {
}
.card-act:active { transform: scale(0.9); }

/* 单一终端入口:私有 dashboard 默认打开可写终端;显式开启公共只读时,
同一个图标降级为只读链接,避免并排「终端 + 钥匙」造成两个主操作。 */
/* 终端与可操作 Web 终端分成两个明确入口:钥匙按钮只在有写权限时显示。 */
.term-pill {
display: inline-flex;
align-items: center;
gap: 6px;
}
.term-pill .term-btn {
width: 30px;
Expand Down
21 changes: 21 additions & 0 deletions test/dashboard-sessions-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const kanbanCallbacks: SessionsKanbanCallbacks = {
details: '<svg></svg>',
feishu: '<svg></svg>',
history: '<svg></svg>',
key: '<svg></svg>',
lock: '<svg></svg>',
restart: '<svg></svg>',
terminal: '<svg></svg>',
Expand All @@ -36,6 +37,7 @@ const kanbanCallbacks: SessionsKanbanCallbacks = {
onNeedTeamBoard: () => {},
onNeedTeams: () => {},
onOpenTerminal: () => {},
onOpenWritableTerminal: () => {},
onRename: () => {},
onRestart: () => {},
onTeamScope: () => {},
Expand Down Expand Up @@ -406,6 +408,7 @@ describe('dashboard sessions kanban react view', () => {
host: null,
...kanbanCallbacks,
onOpenTerminal: undefined,
onOpenWritableTerminal: undefined,
rows: [{
sessionId: 'embedded-session',
status: 'working',
Expand All @@ -424,5 +427,23 @@ describe('dashboard sessions kanban react view', () => {
}));

expect(html).not.toContain('data-action="terminal"');
expect(html).not.toContain('data-action="write-link"');
});

it('renders separate read-only and writable terminal actions when both are available', () => {
const html = renderKanban({
rows: [{
sessionId: 'dual-terminal-session',
status: 'working',
cliId: 'codex',
title: 'Dual terminal',
botName: 'Bot A',
lastMessageAt: 1000,
webPort: 3001,
}],
});

expect(html).toContain('data-action="terminal"');
expect(html).toContain('data-action="write-link"');
});
});