sync: update from private repo (f6d625db)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-26 03:35:45 +00:00
parent 29ccaf1e92
commit b857c33ef6
371 changed files with 31312 additions and 8172 deletions
+389 -191
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSpaces, useUpdateSpace, useArchiveSpace } from '../../hooks/useSpaces';
import { useSpaces, useArchiveSpace } from '../../hooks/useSpaces';
import { useLocalTaskList } from '../../hooks/useTaskList';
import { useLocalTask, useLocalTaskComments } from '../../hooks/useTaskDetail';
import { useTaskOperations } from '../../hooks/useTaskOperations';
@@ -19,8 +19,10 @@ import type { DetailTabId, SortMode, StatusColumn } from '../../lib/urlState';
import { filterTasksForSpace } from '../../lib/spaceTasks';
import { filterAndSortTasks, groupTasksByStatus, statusCounts, totalTaskCount } from '../../lib/taskFilter';
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
import { workspaceDirRole } from '../../lib/workspaceDirs';
import { FilterBar } from '../list/FilterBar';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable } from '../../lib/utils';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
import type { OfficePreviewDescriptor } from '../files/FilePreview';
import { CreateTaskDialog } from '../create/CreateTaskDialog';
import { LocalTaskListItem } from '../list/TaskListItem';
import { ChatPane } from '../chat/ChatPane';
@@ -30,13 +32,19 @@ import { SchedulesPage } from '../../pages/SchedulesPage';
import { SpaceApps } from './SpaceApps';
import { useAuthState } from '../../App';
import { SkeletonChatPane } from '../shared/Skeleton';
import { EmptyState } from '../shared/EmptyState';
import { SwipeableTabs } from '../mobile/SwipeableTabs';
import { FilePreview } from '../files/FilePreview';
import { FileTileGrid } from '../files/FileTileGrid';
import { FileDetailList } from '../files/FileDetailList';
import { useFileView } from '../../hooks/useFileView';
import { FileBreadcrumb } from '../files/FileBreadcrumb';
import { FileActions, FileSelectionBar, FileDropzone } from '../files/FileToolbar';
import { MoveTargetDialog } from '../files/MoveTargetDialog';
import { resolveMoves } from '../../lib/fileMove';
import { FileActions, FileSelectionBar, FileDropzone, FileViewToggle, FileSortMenu, type FileSort } from '../files/FileToolbar';
import { filesToBase64 } from '../../lib/fileBase64';
import { AppRunner } from './AppRunner';
import { createSpaceGateway } from './app-file-gateway';
import { detectAppEntry } from './app-bridge';
import { ChatDetailSplit } from './ChatDetailSplit';
import { OutputPreviewProvider } from '../../lib/output-preview-context';
@@ -47,13 +55,27 @@ import {
fetchSpaceFiles,
fetchSpaceFileContent,
getSpaceFileRawUrl,
getSpaceFileOfficePreviewUrl,
getSpaceTrustedHtmlUrl,
uploadSpaceFiles,
deleteSpaceFiles,
createSpaceFolder,
moveSpaceFile,
downloadSpaceFilesZip,
type CreateLocalTaskInput,
type LocalFileEntry,
type Space,
} from '../../api';
import { SpaceFormDialog } from './SpaceFormDialog';
/** Filter state for the space chat list. Persisted in the URL by App (urlState)
* so it survives tab switches, reloads and bookmarks. */
export interface SpaceChatFilter {
search: string;
status: 'all' | StatusColumn;
sort: SortMode;
scope: TaskScope;
}
interface SpaceDetailProps {
spaceId?: string;
@@ -62,11 +84,14 @@ interface SpaceDetailProps {
onSelectSpaceTask: (id: number) => void;
onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
onOpenTask: (id: number) => void;
chatFilter: SpaceChatFilter;
onChatFilterChange: (next: Partial<SpaceChatFilter>) => void;
}
type SpaceTab = 'chat' | 'files' | 'apps' | 'calendar' | 'schedules' | 'settings';
export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask }: SpaceDetailProps) {
export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpaceDetailProps) {
const { t } = useTranslation('spaces');
const { data: spaces } = useSpaces();
const [tab, setTab] = useState<SpaceTab>('chat');
const containerRef = useRef<HTMLDivElement>(null);
@@ -98,8 +123,11 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
if (!spaceId || !space) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-slate-500">
<div className="flex h-full">
<EmptyState
title={t('detail.empty.title')}
hint={t('detail.empty.hint')}
/>
</div>
);
}
@@ -118,8 +146,7 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
<div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface px-4 py-3`}>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: dot }} aria-hidden />
<SpaceHeaderTitle
spaceId={spaceId}
title={space.title}
space={space}
canManage={canManage}
/>
{space.kind === 'case' && (
@@ -136,12 +163,12 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
{/* 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')}></TabButton>
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}></TabButton>
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}></TabButton>
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}></TabButton>
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}></TabButton>
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}></TabButton>
<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>
{/* Body */}
@@ -156,6 +183,8 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
spaceTaskId={spaceTaskId}
onSelectSpaceTask={onSelectSpaceTask}
onCreateTask={onCreateTask}
filter={chatFilter}
onFilterChange={onChatFilterChange}
/>
)}
{/* key={spaceId}: ワークスペースを切り替えたら detail のサブツリーを remount し、
@@ -164,7 +193,7 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
ローカル state は remount しないと前のワークスペースの値が残る(空内容で上書き
されない AGENTS.md の stale 表示など)。 */}
{tab === 'files' && <SpaceFiles key={spaceId} spaceId={spaceId} canManage={canEditFiles} />}
{tab === 'apps' && <SpaceApps key={spaceId} spaceId={spaceId} />}
{tab === 'apps' && <SpaceApps key={spaceId} spaceId={spaceId} canManage={canManage} />}
{tab === 'calendar' && (
<SpaceCalendar
key={spaceId}
@@ -181,100 +210,46 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
}
/**
* ヘッダーのワークスペース名。管理権限があればインラインで名前変更できる
* 鉛筆ボタン → 入力 → 保存で PATCH → spaces を invalidate。権限が無ければ
* 単なるタイトル表示。
* ヘッダーのワークスペース名 + 説明。タイトル右に説明文を薄字で表示し、管理権限が
* あれば鉛筆ボタンで編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。
*/
function SpaceHeaderTitle({
spaceId,
title,
space,
canManage,
}: {
spaceId: string;
title: string;
space: Space;
canManage: boolean;
}) {
const updateSpace = useUpdateSpace();
const { t } = useTranslation('spaces');
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
// 編集を開始したら現在のタイトルを入れ、フォーカスする。
const startEdit = useCallback(() => {
setValue(title);
setEditing(true);
}, [title]);
useEffect(() => {
if (editing) inputRef.current?.select();
}, [editing]);
const save = useCallback(async () => {
const next = value.trim();
if (!next || next === title) { setEditing(false); return; }
try {
await updateSpace.mutateAsync({ id: spaceId, patch: { title: next } });
setEditing(false);
} catch {
// 失敗時は編集状態のまま(入力を消さない)。
}
}, [value, title, spaceId, updateSpace]);
if (!editing) {
return (
<div className="flex min-w-0 flex-1 items-center gap-1">
<h1 className="min-w-0 truncate text-[15px] font-bold text-slate-800">{title}</h1>
{canManage && (
<button
type="button"
data-testid="space-rename"
onClick={startEdit}
title="ワークスペース名を変更"
aria-label="ワークスペース名を変更"
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface hover:text-slate-700"
>
<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="M11 2.5l2.5 2.5M3 13l.5-2.5L11 3l2 2-7.5 7.5L3 13z" />
</svg>
</button>
)}
</div>
);
}
return (
<div className="flex min-w-0 flex-1 items-center gap-1">
<input
ref={inputRef}
data-testid="space-rename-input"
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); void save(); }
else if (e.key === 'Escape') { e.preventDefault(); setEditing(false); }
}}
className="min-w-0 flex-1 rounded-md border border-hairline bg-canvas px-2 py-1 text-[15px] font-bold text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
/>
<button
type="button"
data-testid="space-rename-save"
onClick={() => void save()}
disabled={updateSpace.isPending}
title="保存"
aria-label="保存"
className="inline-flex h-7 items-center rounded-md bg-accent px-2 text-xs font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => setEditing(false)}
title="キャンセル"
aria-label="キャンセル"
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-xs font-medium text-slate-600 transition-colors hover:bg-surface"
>
</button>
<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>
)}
{canManage && (
<button
type="button"
data-testid="space-edit"
onClick={() => setEditing(true)}
title={t('detail.editSpace')}
aria-label={t('detail.editSpace')}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface hover:text-slate-700"
>
<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="M11 2.5l2.5 2.5M3 13l.5-2.5L11 3l2 2-7.5 7.5L3 13z" />
</svg>
</button>
)}
{editing && <SpaceFormDialog space={space} onClose={() => setEditing(false)} />}
</div>
);
}
@@ -293,6 +268,7 @@ function SpaceDeleteButton({
title: string;
onDeleted: () => void;
}) {
const { t } = useTranslation('spaces');
const archiveSpace = useArchiveSpace();
const [confirming, setConfirming] = useState(false);
@@ -309,7 +285,7 @@ function SpaceDeleteButton({
if (confirming) {
return (
<div className="flex shrink-0 items-center gap-1">
<span className="hidden text-2xs text-slate-500 sm:inline">{title}</span>
<span className="hidden text-2xs text-slate-500 sm:inline">{t('detail.deletePrompt', { title })}</span>
<button
type="button"
data-testid="space-delete-confirm"
@@ -317,14 +293,14 @@ function SpaceDeleteButton({
disabled={archiveSpace.isPending}
className="inline-flex h-7 items-center rounded-md bg-red-600 px-2 text-xs font-bold text-white transition-colors hover:bg-red-700 disabled:opacity-50"
>
{t('detail.deleteConfirmButton')}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-xs font-medium text-slate-600 transition-colors hover:bg-surface"
>
{t('common:cancel')}
</button>
</div>
);
@@ -335,8 +311,8 @@ function SpaceDeleteButton({
type="button"
data-testid="space-delete"
onClick={() => setConfirming(true)}
title="ワークスペースを削除"
aria-label="ワークスペースを削除"
title={t('detail.deleteSpace')}
aria-label={t('detail.deleteSpace')}
className="inline-flex h-7 w-7 shrink-0 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">
@@ -352,6 +328,7 @@ function SpaceDeleteButton({
* 重なり表示で先頭から最大 4 名、超過は「+N」。クリックで設定タブ(メンバー管理)へ。
*/
function SpaceMemberAvatars({ spaceId, onManage }: { spaceId: string; onManage: () => void }) {
const { t } = useTranslation('spaces');
// キーは SpaceMembersPanel と共有する(招待/ロール変更/除去の invalidate が
// ヘッダーのアバター列にも即時反映されるように)。
const { data: members } = useQuery({
@@ -366,7 +343,7 @@ function SpaceMemberAvatars({ spaceId, onManage }: { spaceId: string; onManage:
const MAX = 4;
const shown = members.slice(0, MAX);
const overflow = members.length - shown.length;
const label = `共有メンバー ${members.length} 名: ${members.map(m => m.name ?? m.userId).join(', ')}`;
const label = t('detail.sharedMembers', { count: members.length, names: members.map(m => m.name ?? m.userId).join(', ') });
return (
<button
@@ -433,25 +410,34 @@ function SpaceChat({
isPersonalSpace,
spaceTaskId,
onSelectSpaceTask,
filter,
onFilterChange,
}: {
spaceId: string;
isPersonalSpace: boolean;
spaceTaskId?: number;
onSelectSpaceTask: (id: number) => void;
onCreateTask: SpaceDetailProps['onCreateTask'];
filter: SpaceChatFilter;
onFilterChange: (next: Partial<SpaceChatFilter>) => void;
}) {
const { t } = useTranslation('spaces');
const qc = useQueryClient();
const auth = useAuthState();
const { data: allTasks } = useLocalTaskList();
const [showCreate, setShowCreate] = useState(false);
const [scope, setScope] = useState<TaskScope>('mine');
const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter;
const setScope = (val: TaskScope) => onFilterChange({ scope: val });
const setSearchQuery = (val: string) => onFilterChange({ search: val });
const setSelectedStatus = (val: 'all' | StatusColumn) => onFilterChange({ status: val });
const setSortMode = (val: SortMode) => onFilterChange({ sort: val });
const spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace);
// 自分/他メンバーの切替。共有ワークスペースで「他の人のタスク」が存在するときだけ
// 出す(個人ワークスペースや単独利用では意味がないので隠す)。スコープ分割は Tasks
// ページと同じ filterTasksByScope を再利用する(owner_id null は others 側、という
// 既存規約に合わせる)。SpaceChat は key={spaceId} で remount されるので、スペース
// 切り替えると scope は 'mine' に戻る
// 既存規約に合わせる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の
// onSelectSpace が search/status/sort/scope を明示リセットする(remount 依存ではない)
const userId = auth.mode === 'authenticated' ? auth.user.id : null;
const hasOthersTasks = userId != null && spaceTasks.some(t => t.ownerId !== userId);
const tasks = userId != null && hasOthersTasks
@@ -459,10 +445,8 @@ function SpaceChat({
: spaceTasks;
// 検索・ステータス・ソート(Tasks ページの FilterBar と同じ挙動を共有ヘルパーで再現)。
// scope(自分/他メンバー)が最外側のフィルタで、その結果に対して絞り込む。
const [searchQuery, setSearchQuery] = useState('');
const [selectedStatus, setSelectedStatus] = useState<'all' | StatusColumn>('all');
const [sortMode, setSortMode] = useState<SortMode>('updated');
// scope(自分/他メンバー)が最外側のフィルタで、その結果に対して絞り込む。state は
// URL 永続化のため SpaceDetail(親)→ App の urlState から流す。
const statusColumns = groupTasksByStatus(tasks);
const counts = statusCounts(statusColumns);
const totalCount = totalTaskCount(statusColumns);
@@ -490,14 +474,14 @@ function SpaceChat({
}`}
>
<div className="flex items-center justify-between gap-2 px-3 py-2.5 border-b border-hairline">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500"></span>
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500">{t('chat.listHeading')}</span>
<button
type="button"
data-testid="space-new-chat-btn"
onClick={() => setShowCreate(true)}
className="shrink-0 rounded-md bg-accent px-2.5 py-1.5 text-xs font-bold text-accent-fg transition-colors hover:opacity-90"
>
{t('chat.new')}
</button>
</div>
@@ -506,7 +490,7 @@ function SpaceChat({
data-testid="space-chat-scope-toggle"
className="flex items-center gap-1 border-b border-hairline px-3 py-1.5"
>
{([['mine', '自分'], ['others', '他のメンバー']] as const).map(([val, label]) => (
{(['mine', 'others'] as const).map((val) => (
<button
key={val}
type="button"
@@ -519,7 +503,7 @@ function SpaceChat({
: 'text-slate-500 hover:bg-surface hover:text-slate-700'
}`}
>
{label}
{t(`chat.scope.${val}`)}
</button>
))}
</div>
@@ -542,14 +526,21 @@ function SpaceChat({
<div className="flex-1 min-h-0 overflow-y-auto p-2">
{totalCount === 0 ? (
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
{hasOthersTasks && scope === 'others'
? '他のメンバーが作成したチャットはありません。'
: 'このワークスペースにはまだチャットがありません。「+ 新規」で始めましょう。'}
</p>
hasOthersTasks && scope === 'others' ? (
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
{t('chat.empty.others')}
</p>
) : (
<div className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
<p>{t('chat.empty.none')}</p>
<p className="mt-1.5 text-2xs text-slate-400 leading-relaxed">
{t('chat.empty.filesHint')}
</p>
</div>
)
) : visibleTasks.length === 0 ? (
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
{t('chat.empty.noMatch')}
</p>
) : (
<div className="space-y-1.5">
@@ -580,7 +571,7 @@ function SpaceChat({
/>
) : (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
{t('chat.selectHint')}
</div>
)}
</div>
@@ -602,6 +593,7 @@ function SpaceChat({
// スペース内インライン会話。App の Tasks 詳細と同じ hook / handler を使い、挙動
// (追加指示送信・キャンセル・ライブ表示)を完全一致させる。
function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => void }) {
const { t: ts } = useTranslation('spaces');
const { toast, showToast } = useToast();
const taskQuery = useLocalTask(taskId, true);
const commentsQuery = useLocalTaskComments(taskId, true);
@@ -637,7 +629,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
// 削除(確認ダイアログ付き)。confirm をキャンセルしたら何もしない。
const confirmAndDelete = useCallback(async () => {
if (!window.confirm('このチャットを削除しますか?この操作は取り消せません。')) return;
if (!window.confirm(ts('conversation.deleteConfirm'))) return;
await handleDelete();
}, [handleDelete]);
@@ -788,7 +780,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 4l-4 4 4 4" />
</svg>
{ts('conversation.backToList')}
</button>
{/* アクション行: 削除 / 共有 / 継続 / 可視性。Tasks ページの DetailHeader と
@@ -804,10 +796,10 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
メンバーだけに公開される。セレクタの代わりに静的な説明チップを置く。 */}
<span
data-testid="space-chat-visibility-note"
title="このワークスペースのメンバーだけが閲覧できます"
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">
@@ -826,8 +818,8 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
type="button"
data-testid="space-chat-delete"
onClick={() => void confirmAndDelete()}
title="削除"
aria-label="削除"
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">
@@ -930,6 +922,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
section={previewState.section}
filePath={previewState.filePath}
editable={previewState.editable}
office={previewState.office}
/>
)}
{toast && (
@@ -953,6 +946,7 @@ interface SpacePreviewState {
imageSrc: string;
markdownImageBaseUrl?: string;
trustedHtmlUrl?: string;
office?: OfficePreviewDescriptor;
}
// ソースライブラリの curated 一覧 UI は撤去した(#6)。エージェントが取得した資料は
@@ -964,6 +958,7 @@ interface SpacePreviewState {
// 自分のワークスペースなので削除可)。共有ワークスペースでは SpaceDetail が
// owner/admin 判定を渡す。サーバ側も canEditInSpace で再度ゲートする。
export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) {
const { t } = useTranslation('spaces');
const [currentPath, setCurrentPath] = useState('');
const [entries, setEntries] = useState<LocalFileEntry[]>([]);
const [loadError, setLoadError] = useState('');
@@ -977,6 +972,9 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const [isDeleting, setIsDeleting] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isMoving, setIsMoving] = useState(false);
// 「移動」ダイアログで移動する対象(null = 閉じている)。
const [moveDialogSources, setMoveDialogSources] = useState<string[] | null>(null);
const load = useCallback(async () => {
setIsRefreshing(true);
@@ -986,7 +984,7 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
setLoadError('');
} catch {
setEntries([]);
setLoadError('ファイルの取得に失敗しました');
setLoadError(t('files.loadError'));
} finally {
setIsRefreshing(false);
}
@@ -999,6 +997,16 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
const handlePreview = useCallback(async (filePath: string, name: string) => {
try {
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
setPreview({
name,
content: '',
imageSrc: '',
office: { kind, url: getSpaceFileOfficePreviewUrl(spaceId, filePath), downloadUrl: getSpaceFileRawUrl(spaceId, filePath) },
});
return;
}
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
const imageSrc = getSpaceFileRawUrl(spaceId, filePath);
const trustedHtmlUrl = isHtmlPreviewable(name) ? getSpaceTrustedHtmlUrl(spaceId, filePath) : undefined;
@@ -1013,7 +1021,7 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
}
setPreview({ name, content, imageSrc: '', markdownImageBaseUrl });
} catch {
setLoadError('ファイルの読み込みに失敗しました');
setLoadError(t('files.previewError'));
}
}, [spaceId]);
@@ -1025,14 +1033,88 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
const payload = await filesToBase64(fileList);
const r = await uploadSpaceFiles(spaceId, currentPath, payload);
await load();
setUploadMsg({ text: `${r.uploaded.length} 件のファイルを追加しました`, kind: 'ok' });
setUploadMsg({ text: t('files.uploadedCount', { count: r.uploaded.length }), kind: 'ok' });
} catch (e) {
setUploadMsg({ text: `アップロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
setUploadMsg({ text: t('files.uploadFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
} finally {
setIsUploading(false);
}
}, [spaceId, currentPath, load]);
// 現在フォルダに空フォルダを作る。既存スペースに readonly/ を後付けする用途も兼ねる。
const createFolder = useCallback(async () => {
const name = window.prompt(t('files.newFolderPrompt'))?.trim();
if (!name) return;
if (/[\\/]/.test(name)) {
setUploadMsg({ text: t('files.folderNameInvalid'), kind: 'error' });
return;
}
const rel = currentPath ? `${currentPath}/${name}` : name;
setUploadMsg(null);
try {
await createSpaceFolder(spaceId, rel);
await load();
setUploadMsg({ text: t('files.folderCreated', { name }), kind: 'ok' });
} catch (e) {
setUploadMsg({ text: t('files.folderCreateFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
}
}, [spaceId, currentPath, load]);
// ファイル/フォルダのリネーム(move エンドポイント経由)。構造ディレクトリ
// input/output/logs/apps/readonly)はサーバ側で拒否され、UI でも行アクションを出さない。
const renameEntry = useCallback(async (entry: LocalFileEntry) => {
const next = window.prompt(t('files.renamePrompt'), entry.name)?.trim();
if (!next || next === entry.name) return;
if (/[\\/]/.test(next)) {
setUploadMsg({ text: t('files.nameInvalid'), kind: 'error' });
return;
}
const slash = entry.path.lastIndexOf('/');
const parent = slash >= 0 ? entry.path.slice(0, slash) : '';
const to = parent ? `${parent}/${next}` : next;
setUploadMsg(null);
try {
const r = await moveSpaceFile(spaceId, entry.path, to);
await load();
setUploadMsg({ text: t('files.renamed', { from: entry.name, to: r.to.split('/').pop() }), kind: 'ok' });
} catch (e) {
setUploadMsg({ text: t('files.renameFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
}
}, [spaceId, load]);
// ファイル/フォルダをフォルダへ移動する(ドラッグ移動・複数選択移動の共通経路)。
// resolveMoves が no-op / 自己内移動を事前除外し、残りを move エンドポイントへ順に投げる。
// 衝突はサーバが自動リネームするため、ここでは件数だけ報告する。
const moveInto = useCallback(async (sourcePaths: string[], destDir: string) => {
const { moves, skipped } = resolveMoves(sourcePaths, destDir);
if (moves.length === 0) {
if (skipped.length > 0) setUploadMsg({ text: t('files.alreadyThere'), kind: 'ok' });
return;
}
setIsMoving(true);
setUploadMsg(null);
let moved = 0;
let failed = 0;
for (const m of moves) {
try {
await moveSpaceFile(spaceId, m.from, m.to);
moved++;
} catch {
failed++;
}
}
setSelected(new Set());
await load();
const where = destDir ? t('files.moveTargetFolder', { name: destDir.split('/').pop() }) : t('files.moveTargetRoot');
setUploadMsg(
failed > 0
// 一部でも失敗したら赤で示す(成功緑だと失敗を見落とすため)。
? { text: t('files.movedWithFailures', { moved, where, failed }), kind: 'error' }
: { text: t('files.moved', { moved, where }), kind: 'ok' },
);
setIsMoving(false);
}, [spaceId, load]);
// フォルダ移動・ワークスペース切替で選択をクリア(別ディレクトリのパスを持ち越さない)。
useEffect(() => { setSelected(new Set()); }, [currentPath, spaceId]);
@@ -1048,16 +1130,16 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
// ガード + canEditInSpace で再ゲートするが、UI は相対パスだけを送る。
const deleteSelected = useCallback(async (paths: string[]) => {
if (paths.length === 0) return;
if (!window.confirm(`${paths.length} 件のファイルを削除しますか?この操作は取り消せません。`)) return;
if (!window.confirm(t('files.deleteConfirm', { count: paths.length }))) return;
setIsDeleting(true);
setUploadMsg(null);
try {
const r = await deleteSpaceFiles(spaceId, paths);
setSelected(new Set());
await load();
setUploadMsg({ text: `${r.deleted.length} 件のファイルを削除しました`, kind: 'ok' });
setUploadMsg({ text: t('files.deletedCount', { count: r.deleted.length }), kind: 'ok' });
} catch (e) {
setUploadMsg({ text: `削除に失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
setUploadMsg({ text: t('files.deleteFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
} finally {
setIsDeleting(false);
}
@@ -1071,46 +1153,98 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
try {
await downloadSpaceFilesZip(spaceId, paths);
} catch (e) {
setUploadMsg({ text: `ダウンロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
setUploadMsg({ text: t('files.downloadFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
} finally {
setIsDownloading(false);
}
}, [spaceId]);
const pathSegments = currentPath ? currentPath.split('/').filter(Boolean) : [];
const dirs = entries.filter(e => e.kind === 'directory');
// source/index.jsonl は「ソース」グループのデータ源なので、生のファイル一覧には
// 出さない(ノイズ回避)。source/ フォルダ自体は通常どおりブラウズできる。
const files = entries.filter(
e => e.kind !== 'directory' && !(currentPath === 'source' && e.name === 'index.jsonl'),
const visibleEntries = entries.filter(
e => !(e.kind !== 'directory' && currentPath === 'source' && e.name === 'index.jsonl'),
);
const sorted = [
...dirs.sort((a, b) => a.name.localeCompare(b.name)),
...files.sort((a, b) => a.name.localeCompare(b.name)),
];
const { viewMode, setViewMode, sort, setSort, toggleSort, sortedEntries } = useFileView(visibleEntries);
// アイコン表示のドロップダウン(名前順/新しい順)は共有ソート状態に縮約して乗せる。
// サイズ順は詳細表示の列見出しから操作する(タスク窓の FileBrowser と同方針)。
const menuSort: FileSort = sort.key === 'modified' ? 'newest' : 'name';
const onMenuSort = (s: FileSort) =>
setSort(s === 'newest' ? { key: 'modified', dir: 'desc' } : { key: 'name', dir: 'asc' });
// 選択可能なのはファイルのみ(ディレクトリは削除対象外)。
const selectablePaths = files.map(f => f.path);
// 選択可能なのはファイル+ユーザー作成フォルダ(構造フォルダ=workspaceDirRole 有 は保護で除外)。
const selectablePaths = visibleEntries
.filter(e => e.kind !== 'directory' || workspaceDirRole(e.path, e.name, e.kind) == null)
.map(f => f.path);
const allSelected = selectablePaths.length > 0 && selectablePaths.every(p => selected.has(p));
const selectedInView = selectablePaths.filter(p => selected.has(p));
// リネームボタン(詳細表示・アイコン表示で共有)。構造ディレクトリ
// input/output/logs/apps/readonly)はサーバ側で拒否されるため出さない。
const renameButton = (entry: LocalFileEntry) => {
const isStructural = workspaceDirRole(entry.path, entry.name, entry.kind) != null;
if (!canManage || isStructural) return null;
return (
<button
type="button"
data-testid={`space-file-rename-${entry.name}`}
onClick={() => void renameEntry(entry)}
title={t('files.rename')}
aria-label={t('files.renameAria', { name: entry.name })}
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M10.5 2.5l3 3L6 13l-3.5.5L3 10z" />
</svg>
</button>
);
};
return (
<div data-testid="space-files" className="flex flex-col gap-3">
{/* パンくず(現在地)+ 操作。パスはパンくずのみで表す(テキスト二重表示を廃止)。 */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 pt-1 font-mono text-2xs text-slate-500 break-all">
/files{currentPath ? `/${currentPath}` : ''}
<div className="min-w-0 flex-1 pt-0.5">
<FileBreadcrumb
testid="space-files-breadcrumb"
pathSegments={pathSegments}
onNavigate={setCurrentPath}
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{viewMode === 'icon' && <FileSortMenu sort={menuSort} onChange={onMenuSort} />}
<FileViewToggle idPrefix="space" mode={viewMode} onChange={setViewMode} />
{canManage && (
<button
type="button"
data-testid="space-files-mkdir-btn"
onClick={() => void createFolder()}
className="inline-flex items-center gap-1 px-2 h-7 rounded text-2xs font-medium border border-hairline bg-canvas text-slate-600 hover:bg-surface transition-colors"
title={t('files.mkdirTitle')}
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M2 4.5A1.5 1.5 0 013.5 3h2.6l1.2 1.6h5.2A1.5 1.5 0 0116 6.1V12a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 012 12V4.5zM8 7.5v4M6 9.5h4" />
</svg>
{t('files.newFolder')}
</button>
)}
<FileActions
idPrefix="space"
canUpload={canManage}
onUploadFiles={files => void uploadFiles(files)}
onRefresh={() => void load()}
isRefreshing={isRefreshing}
isUploading={isUploading}
/>
</div>
<FileActions
idPrefix="space"
canUpload={canManage}
onUploadFiles={files => void uploadFiles(files)}
onRefresh={() => void load()}
isRefreshing={isRefreshing}
isUploading={isUploading}
/>
</div>
<FileBreadcrumb testid="space-files-breadcrumb" pathSegments={pathSegments} onNavigate={setCurrentPath} />
{!currentPath && !loadError && selectablePaths.length === 0 && (
<p className="text-2xs text-slate-500 leading-relaxed">
{t('files.inputHint')}
</p>
)}
{canManage && selectablePaths.length > 0 && (
<FileSelectionBar
@@ -1120,8 +1254,10 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
selectedCount={selectedInView.length}
onDeleteSelected={() => void deleteSelected(selectedInView)}
onDownloadSelected={() => void downloadSelected(selectedInView)}
onMoveSelected={() => setMoveDialogSources(selectedInView)}
isDeleting={isDeleting}
isDownloading={isDownloading}
isMoving={isMoving}
/>
)}
@@ -1135,39 +1271,87 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
enabled={canManage}
isUploading={isUploading}
onDropFiles={files => void uploadFiles(files)}
onRejectFolder={() => setUploadMsg({ text: 'フォルダは未対応です。ファイルを選んでください。', kind: 'error' })}
onRejectFolder={() => setUploadMsg({ text: t('files.folderNotSupported'), kind: 'error' })}
>
<FileTileGrid
entries={sorted}
idPrefix="space"
canManage={canManage}
selected={selected}
onToggleSelect={toggleSelect}
onOpenDir={setCurrentPath}
onOpenFile={(path, name) => void handlePreview(path, name)}
onDeleteOne={path => void deleteSelected([path])}
isDeleting={isDeleting}
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
renderTileOverlay={entry => {
// apps/{name}/index.html なら「アプリとして実行」を出す(Stage 1 起動経路)。
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
if (!appEntry) return null;
return (
<button
type="button"
data-testid={`space-app-run-${appEntry.appName}`}
onClick={() => setAppToRun(appEntry)}
title="アプリとして実行"
className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded bg-[var(--brand-primary)] px-2 py-0.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100"
>
</button>
);
}}
emptyHint={loadError ? null : (canManage
? 'ファイルがありません。ここにドラッグ&ドロップ、または「+ 追加」で追加できます。'
: 'ファイルがありません。')}
/>
{viewMode === 'detail' ? (
<FileDetailList
entries={sortedEntries}
idPrefix="space"
canManage={canManage}
selected={selected}
onToggleSelect={toggleSelect}
onOpenDir={setCurrentPath}
onOpenFile={(path, name) => void handlePreview(path, name)}
onDeleteOne={path => void deleteSelected([path])}
isDeleting={isDeleting}
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
onDownloadDir={path => void downloadSelected([path])}
sort={sort}
onSort={toggleSort}
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
renderRowAction={entry => {
// 詳細表示でも apps/{name}/index.html は「実行」で起動できるようにする
// (アイコン表示の renderTileOverlay と対)。
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
const rename = renameButton(entry);
if (!appEntry && !rename) return null;
return (
<>
{rename}
{appEntry && (
<button
type="button"
data-testid={`space-app-run-${appEntry.appName}`}
onClick={() => setAppToRun(appEntry)}
title={t('files.runAsApp')}
className="inline-flex h-5 items-center rounded bg-[var(--brand-primary)] px-1.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-90 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
>
{t('files.run')}
</button>
)}
</>
);
}}
emptyHint={loadError ? null : (canManage
? t('files.emptyManage')
: t('files.empty'))}
/>
) : (
<FileTileGrid
entries={sortedEntries}
idPrefix="space"
canManage={canManage}
selected={selected}
onToggleSelect={toggleSelect}
onOpenDir={setCurrentPath}
onOpenFile={(path, name) => void handlePreview(path, name)}
onDeleteOne={path => void deleteSelected([path])}
isDeleting={isDeleting}
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
onDownloadDir={path => void downloadSelected([path])}
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
renderEntryAction={renameButton}
renderTileOverlay={entry => {
// apps/{name}/index.html なら「アプリとして実行」を出す(Stage 1 起動経路)。
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
if (!appEntry) return null;
return (
<button
type="button"
data-testid={`space-app-run-${appEntry.appName}`}
onClick={() => setAppToRun(appEntry)}
title={t('files.runAsApp')}
className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded bg-[var(--brand-primary)] px-2 py-0.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
>
{t('files.runAsApp')}
</button>
);
}}
emptyHint={loadError ? null : (canManage
? t('files.emptyManage')
: t('files.empty'))}
/>
)}
</FileDropzone>
{preview && (
@@ -1177,18 +1361,32 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
imageSrc={preview.imageSrc}
markdownImageBaseUrl={preview.markdownImageBaseUrl}
trustedHtmlUrl={preview.trustedHtmlUrl}
office={preview.office}
onClose={() => setPreview(null)}
/>
)}
{appToRun && (
<AppRunner
spaceId={spaceId}
gateway={createSpaceGateway(spaceId)}
appName={appToRun.appName}
entryPath={appToRun.entryPath}
onClose={() => setAppToRun(null)}
/>
)}
{moveDialogSources && (
<MoveTargetDialog
spaceId={spaceId}
sourcePaths={moveDialogSources}
isMoving={isMoving}
onClose={() => setMoveDialogSources(null)}
onConfirm={dest => {
setMoveDialogSources(null);
void moveInto(moveDialogSources, dest);
}}
/>
)}
</div>
);
}