feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { DetailTabId } from '../../lib/urlState';
|
||||
import { shareTask, unshareTask } from '../../api';
|
||||
|
||||
interface Tab { id: DetailTabId; label: string; }
|
||||
|
||||
interface DetailHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
tabs: Tab[];
|
||||
activeTab: DetailTabId;
|
||||
/** True while the deferred content tab is still catching up to activeTab.
|
||||
* Used to render a subtle pulse on the active tab so the user knows the
|
||||
* click was registered even if the content takes a frame or two to paint. */
|
||||
tabTransitionPending?: boolean;
|
||||
onTabChange: (tab: DetailTabId) => void;
|
||||
onClose: () => void;
|
||||
detailWidth?: 'normal' | 'focused';
|
||||
onWidthToggle?: () => void;
|
||||
// 共有機能
|
||||
taskId?: number;
|
||||
shareToken?: string | null;
|
||||
onShareChange?: () => void;
|
||||
/** Status of the latest job for this task. The Continue button is shown
|
||||
* when latestJobStatus is provided and enabled only on terminal states. */
|
||||
latestJobStatus?: string | null;
|
||||
/** Click handler for the Continue button. When undefined, the button is
|
||||
* hidden entirely (e.g., shared/read-only views). */
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; shareToken: string | null; onShareChange?: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const shareMutation = useMutation({
|
||||
mutationFn: () => shareTask(taskId),
|
||||
onSuccess: (data) => {
|
||||
const url = `${window.location.origin}${data.shareUrl}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
qc.invalidateQueries({ queryKey: ['localTaskDetail', taskId] });
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
onShareChange?.();
|
||||
},
|
||||
});
|
||||
|
||||
const unshareMutation = useMutation({
|
||||
mutationFn: () => unshareTask(taskId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['localTaskDetail', taskId] });
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
onShareChange?.();
|
||||
},
|
||||
});
|
||||
|
||||
// Refero refresh: collapse share UI from text-button rows into compact
|
||||
// icon-only buttons. The title row was getting eaten by long Japanese
|
||||
// labels ("リンクコピー" / "共有停止") on narrow viewports; with icons
|
||||
// we get the same affordance in ~32px instead of ~80px each.
|
||||
const iconBtnBase =
|
||||
'inline-flex items-center justify-center w-7 h-7 border rounded-md transition-colors disabled:opacity-50';
|
||||
|
||||
if (!shareToken) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => shareMutation.mutate()}
|
||||
disabled={shareMutation.isPending}
|
||||
title={shareMutation.isPending ? '共有中...' : '公開リンクを発行'}
|
||||
aria-label="公開リンクを発行"
|
||||
className={`${iconBtnBase} border-hairline bg-white text-slate-600 hover:text-slate-900 hover:bg-surface`}
|
||||
>
|
||||
{shareMutation.isPending ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="4" cy="8" r="2" />
|
||||
<circle cx="12" cy="4" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<path d="M5.7 7l4.6-2M5.7 9l4.6 2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
const url = `${window.location.origin}/ui/shared/${shareToken}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title={copied ? 'コピーしました' : '共有リンクをコピー'}
|
||||
aria-label="共有リンクをコピー"
|
||||
className={`${iconBtnBase} ${copied ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-hairline bg-white text-slate-600 hover:text-slate-900 hover:bg-surface'}`}
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 8.5l3 3 7-7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5" />
|
||||
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => unshareMutation.mutate()}
|
||||
disabled={unshareMutation.isPending}
|
||||
title="共有を停止"
|
||||
aria-label="共有を停止"
|
||||
className={`${iconBtnBase} border-hairline bg-white text-slate-500 hover:text-red-700 hover:border-red-200 hover:bg-red-50`}
|
||||
>
|
||||
{unshareMutation.isPending ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 5l6 6M11 5l-6 6" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueButton({ latestJobStatus, onClick }: { latestJobStatus: string | null; onClick: () => void }) {
|
||||
// Mirror the spec/backend TERMINAL list (worker maps abort outcomes to
|
||||
// 'failed', so 'aborted' is intentionally absent).
|
||||
const TERMINAL = ['succeeded', 'failed', 'waiting_human', 'cancelled'];
|
||||
const enabled = latestJobStatus != null && TERMINAL.includes(latestJobStatus);
|
||||
const iconBtnBase =
|
||||
'inline-flex items-center justify-center w-7 h-7 border rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={!enabled}
|
||||
title={enabled ? '別 piece で続ける' : 'タスクが進行中のため続行できません'}
|
||||
aria-label="別 piece で続ける"
|
||||
className={`${iconBtnBase} border-hairline bg-white text-slate-600 hover:text-slate-900 hover:bg-surface`}
|
||||
>
|
||||
{/* arrow → divider: 「次のフェーズへ進む」cue。FileBrowser の refresh
|
||||
(循環矢印) と区別するためフラットな skip-forward 形状を採用 */}
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 8h9" />
|
||||
<path d="M8 5l3 3-3 3" />
|
||||
<path d="M13.5 4v8" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPending, onTabChange, onClose, detailWidth, onWidthToggle, taskId, shareToken, onShareChange, latestJobStatus, onContinue }: DetailHeaderProps) {
|
||||
// Mobile (< sm) hides the close button and tab bar because App.tsx
|
||||
// renders its own mobile-level top tab bar with the same controls.
|
||||
// Two close buttons / two tab bars on iPhone was visually redundant.
|
||||
return (
|
||||
<div className="flex-shrink-0 border-b border-hairline bg-white px-4 pt-3 pb-3 sm:pb-0" id="detail-panel-title">
|
||||
<div className="flex items-start justify-between gap-2 mb-0 sm:mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-slate-400">{subtitle}</div>
|
||||
<div className="font-semibold text-lg text-slate-900 mt-0.5 break-words leading-tight">{title}</div>
|
||||
</div>
|
||||
{/* Inline action cluster: width toggle + share + close. Share is
|
||||
now icon-only (32px) so it fits next to the title instead of
|
||||
occupying its own row. */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onContinue && taskId != null && (
|
||||
<ContinueButton
|
||||
latestJobStatus={latestJobStatus ?? null}
|
||||
onClick={onContinue}
|
||||
/>
|
||||
)}
|
||||
{taskId != null && (
|
||||
<ShareButton
|
||||
taskId={taskId}
|
||||
shareToken={shareToken ?? null}
|
||||
onShareChange={onShareChange}
|
||||
/>
|
||||
)}
|
||||
{onWidthToggle && detailWidth && (
|
||||
<button
|
||||
onClick={onWidthToggle}
|
||||
title={detailWidth === 'focused' ? '標準表示に戻る' : '集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)'}
|
||||
aria-label={detailWidth === 'focused' ? '標準表示に戻る' : '集中モードに切替'}
|
||||
aria-pressed={detailWidth === 'focused'}
|
||||
className="hidden sm:inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-500 hover:text-slate-700 hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||
>
|
||||
{detailWidth === 'focused' ? (
|
||||
// exit-fullscreen 様: 4 つの内向き角矢印
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 2v4H2M10 2v4h4M6 14v-4H2M10 14v-4h4" />
|
||||
</svg>
|
||||
) : (
|
||||
// enter-fullscreen 様: 4 つの外向き角矢印
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="詳細パネルを閉じる"
|
||||
className="hidden sm:inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tablist" aria-label="詳細タブ" className="hidden sm:flex gap-4 -mb-px">
|
||||
{tabs.map(tab => {
|
||||
const active = activeTab === tab.id;
|
||||
const pending = active && tabTransitionPending;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${
|
||||
active
|
||||
? 'border-accent text-slate-900 font-semibold'
|
||||
: 'border-transparent text-slate-500 font-medium hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{pending && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block w-2.5 h-2.5 border-2 border-accent border-t-transparent rounded-full animate-spin"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user