This commit is contained in:
+8
-1
@@ -22,6 +22,7 @@ import { TopBar } from './components/layout/TopBar';
|
||||
import { NavDrawer } from './components/layout/NavDrawer';
|
||||
import { useEdgeSwipe } from './hooks/useEdgeSwipe';
|
||||
import { visibleNavItemsFor } from './components/layout/TopBar';
|
||||
import { useVisibleDetailTabs } from './components/detail/detailTabs';
|
||||
import { ResizeHandle } from './components/layout/ResizeHandle';
|
||||
import { TaskListPanel } from './components/list/TaskListPanel';
|
||||
import { ChatPane } from './components/chat/ChatPane';
|
||||
@@ -168,6 +169,12 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
// nav drawer / edge-swipe stay in sync with whether the hamburger is shown.
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
||||
// Detail tabs (with live Browser/SSH gating) for the tablet chat header.
|
||||
const visibleDetailTabs = useVisibleDetailTabs(localTaskId);
|
||||
const openDetailTab = useCallback((id: string) => {
|
||||
setUrlState(prev => ({ ...prev, detailTab: id as typeof detailTab }));
|
||||
setTabletDetailOpen(true);
|
||||
}, [setUrlState]);
|
||||
|
||||
const openNavDrawer = () => {
|
||||
setTabletDetailOpen(false);
|
||||
@@ -581,7 +588,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
</div>
|
||||
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
|
||||
{chatReady ? (
|
||||
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} onOpenDetail={() => setTabletDetailOpen(true)} />
|
||||
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} detailTabs={visibleDetailTabs} activeDetailTab={detailTab} onSelectDetailTab={openDetailTab} />
|
||||
) : panelOpen ? (
|
||||
<SkeletonChatPane />
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useRef, useEffect, useMemo, useCallback, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalTask, LocalTaskComment } from '../../api';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
@@ -27,11 +27,16 @@ interface ChatPaneProps {
|
||||
comments: LocalTaskComment[];
|
||||
onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
||||
onCancel?: () => Promise<void>;
|
||||
onOpenDetail?: () => void;
|
||||
/** Tablet: the detail tabs. Tapping one opens the detail drawer to that tab,
|
||||
* replacing the single "詳細" button so you jump straight where you want. */
|
||||
detailTabs?: Array<{ id: string; labelKey: string }>;
|
||||
activeDetailTab?: string;
|
||||
onSelectDetailTab?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: ChatPaneProps) {
|
||||
export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const { t: dt } = useTranslation('detail');
|
||||
const [draft, setDraft] = useState('');
|
||||
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -259,17 +264,34 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{onOpenDetail && (
|
||||
<button
|
||||
onClick={onOpenDetail}
|
||||
className="px-2.5 h-7 text-2xs font-medium text-slate-700 border border-hairline bg-canvas hover:bg-surface rounded-md transition-colors"
|
||||
title={t('pane.viewDetail')}
|
||||
>
|
||||
{t('pane.detail')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tablet: detail tabs as direct buttons (instead of one "詳細" button)
|
||||
so a tap opens the drawer straight to the chosen tab. */}
|
||||
{detailTabs && detailTabs.length > 0 && onSelectDetailTab && (
|
||||
<div className="mt-2 flex flex-wrap gap-x-1.5 gap-y-1">
|
||||
{detailTabs.map(tab => {
|
||||
const active = tab.id === activeDetailTab;
|
||||
const live = tab.id === 'browser' || tab.id === 'ssh';
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onSelectDetailTab(tab.id)}
|
||||
aria-pressed={active}
|
||||
style={live ? ({ '--flow-color': tab.id === 'ssh' ? '#34d399' : '#38bdf8' } as CSSProperties) : undefined}
|
||||
className={`whitespace-nowrap px-2 h-7 inline-flex items-center rounded-md text-2xs font-medium transition-colors ${
|
||||
live ? 'live-flow-border' : 'border border-hairline'
|
||||
} ${
|
||||
active ? 'text-slate-900 font-semibold bg-surface' : 'text-slate-600 bg-canvas hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
{dt(tab.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { DetailTabId } from '../../lib/urlState';
|
||||
@@ -231,6 +231,10 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
|
||||
{tabs.map(tab => {
|
||||
const active = activeTab === tab.id;
|
||||
const pending = active && tabTransitionPending;
|
||||
// Browser/SSH tabs only appear while their session is LIVE (gated in
|
||||
// DetailPanel), so any browser/ssh tab here is running — give it the
|
||||
// flowing-light border to signal "this is live right now".
|
||||
const live = tab.id === 'browser' || tab.id === 'ssh';
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -243,7 +247,16 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
|
||||
: 'border-transparent text-slate-500 font-medium hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
{live ? (
|
||||
<span
|
||||
className="live-flow-border px-1.5 py-0.5"
|
||||
style={{ '--flow-color': tab.id === 'ssh' ? '#34d399' : '#38bdf8' } as CSSProperties}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</span>
|
||||
) : (
|
||||
t(tab.labelKey)
|
||||
)}
|
||||
{pending && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { relativeTime } from '../../lib/utils';
|
||||
import { ownerDisplayName } from '../../lib/owner';
|
||||
import { DetailTabId } from '../../lib/urlState';
|
||||
import { DetailHeader } from './DetailHeader';
|
||||
import { useVisibleDetailTabs } from './detailTabs';
|
||||
import { ContinueWithPieceDialog } from './ContinueWithPieceDialog';
|
||||
import { SkeletonDetailPanel } from '../shared/Skeleton';
|
||||
import { OverviewTab } from './tabs/OverviewTab';
|
||||
@@ -15,7 +16,6 @@ import { TraceTab } from './tabs/TraceTab';
|
||||
import { BrowserTab } from './tabs/BrowserTab';
|
||||
import { ConsoleTab } from './tabs/ConsoleTab';
|
||||
import { BrowserSessionPanel } from '../browser/BrowserSessionPanel';
|
||||
import type { ConsoleStatus } from '../../lib/ssh-console-types';
|
||||
import { useAuthState } from '../../App';
|
||||
import type { SubtaskFilePreviewHandler } from './tabs/SubtasksPanel';
|
||||
|
||||
@@ -46,14 +46,6 @@ interface LocalDetailPanelProps {
|
||||
onShareChange?: () => void;
|
||||
}
|
||||
|
||||
const LOCAL_TABS: Array<{ id: DetailTabId; labelKey: string }> = [
|
||||
{ id: 'overview', labelKey: 'tabs.overview' },
|
||||
{ id: 'activity', labelKey: 'tabs.activity' },
|
||||
{ id: 'files', labelKey: 'tabs.files' },
|
||||
{ id: 'trace', labelKey: 'tabs.trace' },
|
||||
{ id: 'browser', labelKey: 'tabs.browser' },
|
||||
{ id: 'ssh', labelKey: 'tabs.ssh' },
|
||||
];
|
||||
|
||||
export function LocalDetailPanel({
|
||||
task, taskId, section, currentPath, entries, pathSegments,
|
||||
@@ -91,40 +83,9 @@ export function LocalDetailPanel({
|
||||
enabled: editingVisibility,
|
||||
});
|
||||
|
||||
// SSH console tab visibility: show whenever an active console session exists for this task.
|
||||
// (Piece-level pre-show via latestJob.allowedTools is not currently exposed by the API; this
|
||||
// fallback covers the real case where the AI has actually opened a session.)
|
||||
const { data: consoleStatus } = useQuery<ConsoleStatus>({
|
||||
queryKey: ['console-status', task?.id],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`/api/local/tasks/${task!.id}/console/status`);
|
||||
return r.ok ? r.json() : { active: false };
|
||||
},
|
||||
enabled: !!task,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const showSshTab = consoleStatus?.active === true;
|
||||
|
||||
// Browser tab visibility: mirror SSH — show only once a viewable browser
|
||||
// session is live for this task. `available: true` means a noVNC session
|
||||
// exists (the agent has actually used the browser); every other case
|
||||
// (no_session / headless_mode / display_unavailable / novnc_not_installed)
|
||||
// is non-viewable, so the tab would show nothing useful and stays hidden.
|
||||
// Shares the ['task-session', id] query with BrowserTab (deduped by key).
|
||||
const { data: browserSession } = useQuery<{ available: boolean }>({
|
||||
queryKey: ['task-session', task?.id],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`/api/local/browser/sessions/task-session/${task!.id}`);
|
||||
return r.ok ? r.json() : { available: false };
|
||||
},
|
||||
enabled: !!task,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const showBrowserTab = browserSession?.available === true;
|
||||
|
||||
const visibleTabs = LOCAL_TABS.filter(
|
||||
(t) => (t.id !== 'ssh' || showSshTab) && (t.id !== 'browser' || showBrowserTab),
|
||||
);
|
||||
// Visible tabs (incl. Browser/SSH only while their session is live). Shared
|
||||
// with the tablet chat header via useVisibleDetailTabs so the two agree.
|
||||
const visibleTabs = useVisibleDetailTabs(task?.id ?? null);
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (!task) return;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { DetailTabId } from '../../lib/urlState';
|
||||
|
||||
export interface DetailTab {
|
||||
id: DetailTabId;
|
||||
labelKey: string; // resolves against the 'detail' i18n namespace
|
||||
}
|
||||
|
||||
/** All detail tabs in display order. Browser/SSH are conditional — see
|
||||
* useVisibleDetailTabs. */
|
||||
export const LOCAL_TABS: DetailTab[] = [
|
||||
{ id: 'overview', labelKey: 'tabs.overview' },
|
||||
{ id: 'activity', labelKey: 'tabs.activity' },
|
||||
{ id: 'files', labelKey: 'tabs.files' },
|
||||
{ id: 'trace', labelKey: 'tabs.trace' },
|
||||
{ id: 'browser', labelKey: 'tabs.browser' },
|
||||
{ id: 'ssh', labelKey: 'tabs.ssh' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The detail tabs visible for a task. Browser and SSH only appear while their
|
||||
* session is live (so the tab carries the live signal). Single source of truth
|
||||
* shared by the detail panel and the tablet chat header so the two never
|
||||
* disagree. The console-status / task-session queries are keyed the same as the
|
||||
* ones BrowserTab/ConsoleTab use, so react-query dedupes — no extra fetch.
|
||||
*/
|
||||
export function useVisibleDetailTabs(taskId: number | null | undefined): DetailTab[] {
|
||||
const { data: consoleStatus } = useQuery<{ active: boolean }>({
|
||||
queryKey: ['console-status', taskId],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`/api/local/tasks/${taskId}/console/status`);
|
||||
return r.ok ? r.json() : { active: false };
|
||||
},
|
||||
enabled: taskId != null,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const { data: browserSession } = useQuery<{ available: boolean }>({
|
||||
queryKey: ['task-session', taskId],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`/api/local/browser/sessions/task-session/${taskId}`);
|
||||
return r.ok ? r.json() : { available: false };
|
||||
},
|
||||
enabled: taskId != null,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const showSsh = consoleStatus?.active === true;
|
||||
const showBrowser = browserSession?.available === true;
|
||||
return LOCAL_TABS.filter(
|
||||
(t) => (t.id !== 'ssh' || showSsh) && (t.id !== 'browser' || showBrowser),
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"pane": {
|
||||
"viewDetail": "View detail",
|
||||
"detail": "Detail",
|
||||
"empty": "No messages yet",
|
||||
"newMessages": "{{count}} new",
|
||||
"toLatest": "Jump to latest",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"pane": {
|
||||
"viewDetail": "詳細を表示",
|
||||
"detail": "詳細",
|
||||
"empty": "メッセージはまだありません",
|
||||
"newMessages": "{{count}} 件の新着",
|
||||
"toLatest": "最新へ",
|
||||
|
||||
@@ -251,6 +251,40 @@
|
||||
.animate-mobile-tab-swap { animation: none; }
|
||||
}
|
||||
|
||||
/* Live "flowing light" border for Browser/SSH tabs while a session is live.
|
||||
A bright arc travels around a thin rounded ring: a conic gradient rotated
|
||||
via the animatable --flow-angle custom property, masked to the border only.
|
||||
If @property is unsupported the angle stays at 0deg → a static glow ring
|
||||
(graceful degradation, no spin). */
|
||||
.live-flow-border {
|
||||
position: relative;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.live-flow-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1.5px;
|
||||
background: conic-gradient(
|
||||
from var(--flow-angle, 0deg),
|
||||
transparent 0deg,
|
||||
var(--flow-color, #38bdf8) 50deg,
|
||||
#e0f2fe 72deg,
|
||||
transparent 112deg,
|
||||
transparent 360deg
|
||||
);
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
animation: flowBorderSpin 2.4s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.live-flow-border::before { animation: none; opacity: 0.65; }
|
||||
}
|
||||
|
||||
.tool-spark-burst {
|
||||
position: absolute;
|
||||
right: calc(var(--pet-size, 64px) * 0.5);
|
||||
@@ -324,6 +358,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Animatable angle for the live-flow-border conic gradient. Registering it as
|
||||
<angle> is what lets the rotation actually animate (a plain custom property
|
||||
animates discretely). Unsupported browsers ignore this and fall back to the
|
||||
0deg initial value → static ring. */
|
||||
@property --flow-angle {
|
||||
syntax: '<angle>';
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
@keyframes flowBorderSpin {
|
||||
to { --flow-angle: 360deg; }
|
||||
}
|
||||
|
||||
@keyframes petIdle {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg) scale(1); }
|
||||
50% { transform: translateY(-6px) rotate(1deg) scale(1.03); }
|
||||
|
||||
Reference in New Issue
Block a user