sync: update from private repo (ddadfd71)
CI / build-and-test (push) Waiting to run

This commit is contained in:
oss-sync
2026-07-08 23:35:00 +00:00
parent b1292e34b2
commit 77ee3bc426
187 changed files with 19918 additions and 10938 deletions
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SpaceChatTabBar } from './SpaceChatTabBar';
const TABS = [
{ id: 'chat', labelKey: 'tabs.chat' },
{ id: 'overview', labelKey: 'tabs.overview' },
{ id: 'files', labelKey: 'tabs.files' },
];
function renderBar(overrides: Partial<Parameters<typeof SpaceChatTabBar>[0]> = {}) {
const onSelect = vi.fn();
const utils = render(
<SpaceChatTabBar
tabs={TABS}
activeTab="chat"
onSelect={onSelect}
ariaLabel="チャットタブ"
renderLabel={(tb) => tb.labelKey}
appearClass={() => ''}
actions={<button data-testid="space-chat-delete">del</button>}
{...overrides}
/>
);
return { onSelect, ...utils };
}
describe('SpaceChatTabBar', () => {
it('タブと actions を描画し、actions はスクロールする tablist の外にある', () => {
renderBar();
const tablist = screen.getByRole('tablist');
const del = screen.getByTestId('space-chat-delete');
// actions がタブのスクロールコンテナ(tablist)の子孫だと、タブが溢れたとき
// 一緒にスクロールして画面外に流れる。兄弟であることを構造で保証する。
expect(tablist.contains(del)).toBe(false);
expect(screen.getByTestId('space-chat-actions').contains(del)).toBe(true);
expect(tablist.className).toContain('overflow-x-auto');
});
it('ArrowRight で次のタブを選択しフォーカスを移す(末尾で wrap)', async () => {
const user = userEvent.setup();
const { onSelect } = renderBar();
screen.getByRole('tab', { name: 'tabs.chat' }).focus();
await user.keyboard('{ArrowRight}');
expect(onSelect).toHaveBeenCalledWith('overview');
});
it('End で末尾タブ、Home で先頭タブを選択する', async () => {
const user = userEvent.setup();
const { onSelect } = renderBar();
screen.getByRole('tab', { name: 'tabs.chat' }).focus();
await user.keyboard('{End}');
expect(onSelect).toHaveBeenCalledWith('files');
await user.keyboard('{Home}');
expect(onSelect).toHaveBeenCalledWith('chat');
});
it('aria-selected が activeTab にだけ付く', () => {
renderBar({ activeTab: 'overview' });
expect(screen.getByRole('tab', { name: 'tabs.overview' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'tabs.chat' })).toHaveAttribute('aria-selected', 'false');
});
it('actions なしのときアクションゾーンを描画しない', () => {
renderBar({ actions: undefined });
expect(screen.queryByTestId('space-chat-actions')).toBeNull();
});
});
@@ -0,0 +1,96 @@
/**
* SpaceChatTabBar — 会話画面の単一タブバー(2ゾーン構造)。
*
* 左: タブ群(overflow-x-auto のスクロールゾーン)
* 右: actionsshrink-0 の固定ゾーン。継続 / 共有 / 削除ボタンが入る)
*
* タブが溢れて横スクロールになっても、actions は右端に固定されたまま
* スクロールで流れない。これが2ゾーンに分ける理由(旧: アクション専用行
* space-chat-actions を廃止して縦約40pxを回収した)。
*/
import { useRef } from 'react';
export interface SpaceChatTabDef {
id: string;
labelKey: string;
}
interface SpaceChatTabBarProps {
tabs: SpaceChatTabDef[];
activeTab: string;
onSelect: (id: string) => void;
ariaLabel: string;
renderLabel: (tab: SpaceChatTabDef) => string;
/** browser/ssh タブの出現アニメ用クラス(detailTabs.tabAppearClass を渡す) */
appearClass: (id: string) => string;
/** 右端固定ゾーンに置くアクション群。無ければゾーンごと描画しない。 */
actions?: React.ReactNode;
}
export function SpaceChatTabBar({
tabs,
activeTab,
onSelect,
ariaLabel,
renderLabel,
appearClass,
actions,
}: SpaceChatTabBarProps) {
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
// 矢印キーでタブ間移動(端で wrap)+ Home/End。選択とフォーカスを同時に動かす
// role=tablist の標準操作)。SpaceDetail から移設。
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let next: number;
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
else return;
e.preventDefault();
onSelect(tabs[next].id);
tabRefs.current[next]?.focus();
};
return (
<div className="flex items-center border-b border-hairline pl-3 pr-2">
<div
role="tablist"
aria-label={ariaLabel}
data-testid="space-chat-tabs"
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
>
{tabs.map((tb, i) => {
const active = tb.id === activeTab;
return (
<button
key={tb.id}
ref={(el) => { tabRefs.current[i] = el; }}
type="button"
role="tab"
id={`space-chat-tab-${tb.id}`}
data-testid={`space-chat-tab-${tb.id}`}
aria-selected={active}
aria-controls="space-chat-tabpanel"
tabIndex={active ? 0 : -1}
onClick={() => onSelect(tb.id)}
onKeyDown={(e) => handleKeyDown(e, i)}
className={`-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${appearClass(tb.id)} ${
active
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{renderLabel(tb)}
</button>
);
})}
</div>
{actions && (
<div data-testid="space-chat-actions" className="ml-2 flex shrink-0 items-center gap-1.5">
{actions}
</div>
)}
</div>
);
}
+75 -137
View File
@@ -47,6 +47,7 @@ import { AppRunner } from './AppRunner';
import { createSpaceGateway } from './app-file-gateway';
import { detectAppEntry } from './app-bridge';
import { ChatDetailSplit } from './ChatDetailSplit';
import { SpaceChatTabBar } from './SpaceChatTabBar';
import { OutputPreviewProvider } from '../../lib/output-preview-context';
import { stripOutputPrefix } from '../../lib/output-path-detect';
import {
@@ -134,7 +135,7 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
const dot = space.brandColor ?? 'var(--brand-primary)';
// 狭幅でチャットを開いているときは、スペースタイトルと「チャット|ファイル」タブ行を
// 狭幅でチャットを開いているときは、ドット/タイトル/タブ群を1本化した統合ヘッダー行を
// 隠してヘッダーの段数を減らす(会話画面が複数バーに埋もれないように)。広幅(md+)では
// 常時表示、チャット未選択(一覧表示)の狭幅でも表示する。
const chatOpen = tab === 'chat' && spaceTaskId != null;
@@ -142,33 +143,32 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
return (
<div ref={containerRef} data-testid="space-detail" className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface px-4 py-3`}>
{/* Header + Tabs 統合行: 左からドット/タイトル/タブ群(スクロール)/右端固定
(アバター・削除)。2行(約86px)→1行(約40px)。タイトルは max-w-[28ch] で
truncate し、タブ群が flex-1 を持つ。 */}
<div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface pl-4 pr-2`}>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: dot }} aria-hidden />
<SpaceHeaderTitle
space={space}
canManage={canManage}
/>
{space.kind === 'case' && (
<SpaceMemberAvatars spaceId={spaceId} onManage={() => setTab('settings')} />
)}
{space.kind === 'case' && canManage && (
<SpaceDeleteButton
spaceId={spaceId}
title={space.title}
onDeleted={() => onSelectSpace?.(undefined)}
/>
)}
</div>
{/* Tabs */}
<div className={`${hideOnMobileWhenChatOpen} items-center gap-1 border-b border-hairline px-3`}>
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>{t('detail.tab.chat')}</TabButton>
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>{t('detail.tab.files')}</TabButton>
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>{t('detail.tab.apps')}</TabButton>
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>{t('detail.tab.calendar')}</TabButton>
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>{t('detail.tab.schedules')}</TabButton>
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>{t('detail.tab.settings')}</TabButton>
<SpaceHeaderTitle space={space} canManage={canManage} />
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>{t('detail.tab.chat')}</TabButton>
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>{t('detail.tab.files')}</TabButton>
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>{t('detail.tab.apps')}</TabButton>
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>{t('detail.tab.calendar')}</TabButton>
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>{t('detail.tab.schedules')}</TabButton>
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>{t('detail.tab.settings')}</TabButton>
</div>
<div className="flex shrink-0 items-center gap-2">
{space.kind === 'case' && (
<SpaceMemberAvatars spaceId={spaceId} onManage={() => setTab('settings')} />
)}
{space.kind === 'case' && canManage && (
<SpaceDeleteButton
spaceId={spaceId}
title={space.title}
onDeleted={() => onSelectSpace?.(undefined)}
/>
)}
</div>
</div>
{/* Body */}
@@ -210,8 +210,9 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
}
/**
* ヘッダーのワークスペース名 + 説明。タイトル右に説明文を薄字で表示し、管理権限が
* あれば鉛筆ボタンで編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。
* ヘッダーのワークスペース名。説明文はタイトルの title 属性(ツールチップ)に退避し、
* 行の幅は max-w-[28ch] で制約してタブ群に flex-1 を譲る。管理権限があれば鉛筆ボタンで
* 編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。
*/
function SpaceHeaderTitle({
space,
@@ -224,17 +225,13 @@ function SpaceHeaderTitle({
const [editing, setEditing] = useState(false);
return (
<div className="flex min-w-0 flex-1 items-center gap-2">
<h1 className="shrink truncate text-[15px] font-bold text-slate-800">{space.title}</h1>
{space.description && (
<span
data-testid="space-description"
title={space.description}
className="hidden min-w-0 shrink truncate text-xs text-slate-400 sm:inline"
>
{space.description}
</span>
)}
<div className="flex min-w-0 max-w-[28ch] shrink items-center gap-1.5">
<h1
title={space.description || undefined}
className="min-w-0 truncate text-[15px] font-bold text-slate-800"
>
{space.title}
</h1>
{canManage && (
<button
type="button"
@@ -394,7 +391,7 @@ function TabButton({
type="button"
data-testid={testid}
onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${
className={`-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${
active
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
@@ -667,7 +664,6 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
const detailTabs = useVisibleDetailTabs(taskId);
const [activeTab, setActiveTab] = useState<DetailTabId | 'chat'>('chat');
const tabs = [{ id: 'chat' as const, labelKey: 'tabs.chat' }, ...detailTabs];
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const fileBrowser = useFileBrowser(taskId);
// モバイル(<md)だけスワイプ UI を「単一 DOM 枝」としてマウントする。両枝を CSS で
// 隠して二重に描画すると ChatPane の textarea 等が複製され strict locator が壊れる
@@ -682,20 +678,6 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
if (!tabs.some(tb => tb.id === activeTab)) setActiveTab('chat');
}, [tabs, activeTab]);
// 矢印キーでタブ間移動(端で wrap)+ Home/End で先頭・末尾へ。選択とフォーカスを
// 同時に動かす(role=tablist の標準操作)。
const handleTabKeyDown = (e: React.KeyboardEvent, index: number) => {
let next: number;
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
else return;
e.preventDefault();
setActiveTab(tabs[next].id);
tabRefs.current[next]?.focus();
};
// タスクのファイルにアップロード/削除できるか。スペースメンバーでもタスク所有者で
// なければサーバ側 checkTaskOwnership が 403 を返すため、UI も owner/admin/no-auth に限る。
const auth = useAuthState();
@@ -801,87 +783,43 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
{ts('conversation.backToList')}
</button>
{/* アクション行: 削除 / 共有 / 継続 / 可視性。Tasks ページの DetailHeader と
同じ実体(ShareButton・ContinueButton・ContinueWithPieceDialog・
updateLocalTask)を再利用し、機能パリティを保つ。タブバーとは別行に置き、
狭幅でも横並びのまま収まるアイコン主体の密度にする。 */}
{chatReady && task && (
<div
data-testid="space-chat-actions"
className="flex items-center gap-1.5 border-b border-hairline px-3 py-1.5"
>
{/* 公開範囲は選択不可: スペース内のチャットは常にそのスペースの
メンバーだけに公開される。セレクタの代わりに静的な説明チップを置く。 */}
<span
data-testid="space-chat-visibility-note"
title={ts('conversation.visibilityTitle')}
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs text-slate-500"
>
{ts('conversation.visibilityNote')}
</span>
<div className="ml-auto flex items-center gap-1">
<ContinueButton
testid="space-chat-continue"
latestJobStatus={task.latestJob?.status ?? null}
onClick={() => setContinueOpen(true)}
/>
<ShareButton
testid="space-chat-share"
taskId={taskId}
shareToken={task.shareToken ?? null}
onShareChange={refetchTask}
/>
<button
type="button"
data-testid="space-chat-delete"
onClick={() => void confirmAndDelete()}
title={ts('common:delete')}
aria-label={ts('common:delete')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
</svg>
</button>
</div>
</div>
)}
{/* 単一インプレース・タブバー。会話+詳細タブ(ファイルを除く)を1本に集約し、
戻る導線を「会話」タブのみに統一する(オーバーレイ・✕ を廃止)。 */}
<div
role="tablist"
aria-label={t('chatTabsLabel')}
data-testid="space-chat-tabs"
className="flex items-center gap-1 overflow-x-auto border-b border-hairline px-3"
>
{tabs.map((tb, i) => {
const active = tb.id === activeTab;
return (
<button
key={tb.id}
ref={(el) => { tabRefs.current[i] = el; }}
type="button"
role="tab"
id={`space-chat-tab-${tb.id}`}
data-testid={`space-chat-tab-${tb.id}`}
aria-selected={active}
aria-controls="space-chat-tabpanel"
tabIndex={active ? 0 : -1}
onClick={() => setActiveTab(tb.id)}
onKeyDown={(e) => handleTabKeyDown(e, i)}
className={`-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${tabAppearClass(tb.id)} ${
active
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{t(tb.labelKey)}
</button>
);
})}
</div>
<SpaceChatTabBar
tabs={tabs}
activeTab={activeTab}
onSelect={(id) => setActiveTab(id as DetailTabId | 'chat')}
ariaLabel={t('chatTabsLabel')}
renderLabel={(tb) => t(tb.labelKey)}
appearClass={(id) => tabAppearClass(id as DetailTabId | 'chat')}
actions={
chatReady && task ? (
<>
<ContinueButton
testid="space-chat-continue"
latestJobStatus={task.latestJob?.status ?? null}
onClick={() => setContinueOpen(true)}
/>
<ShareButton
testid="space-chat-share"
taskId={taskId}
shareToken={task.shareToken ?? null}
onShareChange={refetchTask}
/>
<button
type="button"
data-testid="space-chat-delete"
onClick={() => void confirmAndDelete()}
title={ts('common:delete')}
aria-label={ts('common:delete')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
</svg>
</button>
</>
) : undefined
}
/>
<div
id="space-chat-tabpanel"