remove the dead multi-user surface
Officer is single-user: the server owner is the only account, created once by /auth/bootstrap. Everything that existed to serve additional users was unreachable, so it is gone rather than left looking like it does something. Accounts: drop the invite / resend-invite / delete / list-users routes and the Users settings screen, the inert /auth/signup handler, and the account verification chain it fed (verify, resend-verification, VerifyScreen, the UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token survives for password resets only, and now requires a reset-password token rather than accepting any signed JWT. Roles: drop the users.role column and the four-value USER_ROLES enum. The permissions table granted every role identical methods, and every role === 'Super Admin' check was permanently true. The JWT no longer carries a role claim. Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected only for non-Super-Admin users, so it never ran. It was also not a usable agent jail as written — --share-net, the project root (with .env) bound read-only, and runuser dropping to the server's own uid. Rebuilding it for agent containment would be a different construction, and git history keeps this one. getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the owner's real login home, which is what terminals, chats and task runs use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
92de996412
commit
044aacf4d5
@@ -1,4 +1,3 @@
|
||||
export const USER_ROLES = ['Member', 'Admin', 'Owner', 'Super Admin'] as const;
|
||||
export const USER_STATUSES = ['Unverified', 'Active', 'Prospect', 'Invited', 'Blocked', 'Banned', 'Deleted'] as const;
|
||||
|
||||
export const COMPANY_SIZES = ['1-10', '11-30', '31-50', '50+'] as const;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Body, Html, Head, Container, Img, Tailwind } from '@react-email/components';
|
||||
import { Text, Button } from '@react-email/components';
|
||||
|
||||
type EmailProps = {
|
||||
invitedBy: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const Email = ({ invitedBy, url }: EmailProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Tailwind>
|
||||
<Body className="mx-auto my-12 bg-white font-sans">
|
||||
<Container className="rounded-lg bg-white p-8 shadow-lg">
|
||||
<Img
|
||||
className="mx-auto block"
|
||||
src="/og-image.jpg"
|
||||
width="480"
|
||||
alt="officer.dev"
|
||||
/>
|
||||
<Text className="pt-4 text-2xl">You're invited to officer.dev</Text>
|
||||
<Text>You have been invited by {invitedBy || '<invitedBy>'} to join officer.dev.</Text>
|
||||
<Text>Click the button below to set up your account.</Text>
|
||||
<Button href={url || 'https://example.com'}>Accept Invitation</Button>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default Email;
|
||||
@@ -1,21 +0,0 @@
|
||||
import Layout from './layouts/MainLayout.jsx';
|
||||
import { Container } from '@react-email/components';
|
||||
import { Text, Button } from '@react-email/components';
|
||||
|
||||
type EmailProps = {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
const Email = ({ name, url }: EmailProps) => {
|
||||
return (
|
||||
<Layout>
|
||||
<Container>
|
||||
<Text className="pt-4 text-2xl">Welcome, {name || 'there'}</Text>
|
||||
<Text className="text-xs">You can ignore this email if you didn't signup for our site.</Text>
|
||||
<Button href={url || 'https://example.com'}>Click here to set up your admin account</Button>
|
||||
</Container>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Email;
|
||||
@@ -1,21 +0,0 @@
|
||||
import Layout from './layouts/MainLayout.jsx';
|
||||
import { Container } from '@react-email/components';
|
||||
import { Text, Button } from '@react-email/components';
|
||||
|
||||
type EmailProps = {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
const Email = ({ name, url }: EmailProps) => {
|
||||
return (
|
||||
<Layout>
|
||||
<Container>
|
||||
<Text className="pt-4 text-2xl">Welcome, {name || 'there'}</Text>
|
||||
<Text className="text-xs">You can ignore this email if you didn't signup for our site.</Text>
|
||||
<Button href={url || 'https://example.com'}>Click here to Confirm your Registration</Button>
|
||||
</Container>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Email;
|
||||
@@ -1,9 +1,3 @@
|
||||
export type SignupUserForm = {
|
||||
email?: string;
|
||||
name?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type UpdateUserPayload = { name: string; username?: string; avatar: string };
|
||||
|
||||
export type ResetPasswordPayload = { password: string; verificationCode: string };
|
||||
|
||||
@@ -15,7 +15,6 @@ export const useAuth = (props: UseAuthProps = {}) => {
|
||||
const apiClient = useClient(apiUrl);
|
||||
const passKeyManager = usePasskeys();
|
||||
|
||||
|
||||
const { isLoading } = useQuery<UserWithToken | null>({
|
||||
queryKey: ['CURRENT_USER'],
|
||||
refetchOnMount: false,
|
||||
@@ -70,22 +69,10 @@ export const useAuth = (props: UseAuthProps = {}) => {
|
||||
localStorage.removeItem('CURRENT_USER');
|
||||
};
|
||||
|
||||
const signup = async (newUser: SignupUserForm) => {
|
||||
return await authClient.post('/signup', newUser);
|
||||
};
|
||||
|
||||
const resetPassword = async (payload: ResetPasswordPayload) => {
|
||||
await authClient.post('/reset-password', payload);
|
||||
};
|
||||
|
||||
const verify = async (payload: VerifyPayload) => {
|
||||
const data = await authClient.post('/verify', payload);
|
||||
if (data.token) {
|
||||
localStorage.setItem('BEARER_TOKEN', data.token);
|
||||
queryClient.invalidateQueries({ queryKey: ['CURRENT_USER'] });
|
||||
}
|
||||
};
|
||||
|
||||
const updateUser = async (payload: UpdateUserPayload) => {
|
||||
await apiClient.put('/users', payload);
|
||||
localStorage.removeItem('CURRENT_USER');
|
||||
@@ -112,8 +99,6 @@ export const useAuth = (props: UseAuthProps = {}) => {
|
||||
refreshUser,
|
||||
signin,
|
||||
signout,
|
||||
signup,
|
||||
verify,
|
||||
resetPassword,
|
||||
updateUser,
|
||||
changePassword,
|
||||
@@ -122,12 +107,6 @@ export const useAuth = (props: UseAuthProps = {}) => {
|
||||
};
|
||||
};
|
||||
|
||||
export type SignupUserForm = {
|
||||
email?: string;
|
||||
name?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type UpdateUserPayload = {
|
||||
name: string;
|
||||
username?: string;
|
||||
@@ -145,11 +124,4 @@ export type ChangePasswordPayload = {
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
export type VerifyPayload = {
|
||||
verificationCode: string;
|
||||
name?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
export type ForgotPasswordPayload = { email: string };
|
||||
|
||||
@@ -6,7 +6,6 @@ import { EmbeddableChat } from './EmbeddableChat';
|
||||
|
||||
type ChatPanelInnerProps = {
|
||||
scoped: boolean;
|
||||
sandboxed: boolean;
|
||||
cwdParam?: { root?: string; path: string };
|
||||
promptPrefix?: string;
|
||||
chatContext: Record<string, string | undefined>;
|
||||
@@ -14,7 +13,14 @@ type ChatPanelInnerProps = {
|
||||
onTurnComplete?: (hadToolCalls: boolean) => void;
|
||||
};
|
||||
|
||||
const ChatPanelInner = ({ scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession, onTurnComplete }: ChatPanelInnerProps) => {
|
||||
const ChatPanelInner = ({
|
||||
scoped,
|
||||
cwdParam,
|
||||
promptPrefix,
|
||||
chatContext,
|
||||
setActiveSession,
|
||||
onTurnComplete,
|
||||
}: ChatPanelInnerProps) => {
|
||||
const chat = useChat(undefined, undefined, {
|
||||
replaceUrl: false,
|
||||
projectScoped: scoped,
|
||||
@@ -31,7 +37,6 @@ const ChatPanelInner = ({ scoped, sandboxed, cwdParam, promptPrefix, chatContext
|
||||
className="h-full"
|
||||
chat={chat}
|
||||
cwd={cwdParam}
|
||||
sandboxed={sandboxed}
|
||||
replaceUrl={false}
|
||||
promptPrefix={promptPrefix}
|
||||
{...chatContext}
|
||||
@@ -42,8 +47,6 @@ const ChatPanelInner = ({ scoped, sandboxed, cwdParam, promptPrefix, chatContext
|
||||
export const ChatPanelWrapper = () => {
|
||||
const { dashboardId, cwd, root, promptPrefix } = useWorkspace();
|
||||
const scoped = cwd !== '~';
|
||||
const hostRoot = root === '~' || root === 'officer.dev';
|
||||
const sandboxed = !hostRoot;
|
||||
|
||||
const chatContext =
|
||||
dashboardId === 'email' || dashboardId === 'screens/email'
|
||||
@@ -73,7 +76,6 @@ export const ChatPanelWrapper = () => {
|
||||
return (
|
||||
<ChatPanelInner
|
||||
scoped={scoped}
|
||||
sandboxed={sandboxed}
|
||||
cwdParam={cwdParam}
|
||||
promptPrefix={promptPrefix}
|
||||
chatContext={chatContext}
|
||||
|
||||
@@ -16,7 +16,6 @@ type EmbeddableChatProps = {
|
||||
promptPrefix?: string;
|
||||
className?: string;
|
||||
cwd?: { root?: string; path: string };
|
||||
sandboxed?: boolean;
|
||||
replaceUrl?: boolean;
|
||||
autoSend?: boolean;
|
||||
chat?: UseChatType;
|
||||
|
||||
@@ -17,7 +17,6 @@ type UseEmbeddableChatParams = {
|
||||
defaultInput?: string;
|
||||
promptPrefix?: string;
|
||||
cwd?: { root?: string; path: string };
|
||||
sandboxed?: boolean;
|
||||
replaceUrl?: boolean;
|
||||
autoSend?: boolean;
|
||||
chat?: UseChatType;
|
||||
@@ -26,15 +25,7 @@ type UseEmbeddableChatParams = {
|
||||
};
|
||||
|
||||
export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) {
|
||||
const {
|
||||
initialMessage,
|
||||
defaultInput = '',
|
||||
promptPrefix,
|
||||
cwd,
|
||||
sandboxed,
|
||||
autoSend = false,
|
||||
chat: externalChat,
|
||||
} = params;
|
||||
const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params;
|
||||
|
||||
const internalChat = useChat(params.sessionId, params.initialModel, {
|
||||
replaceUrl: params.replaceUrl ?? false,
|
||||
@@ -111,7 +102,6 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
images.length > 0 ? images : undefined,
|
||||
cwd,
|
||||
undefined,
|
||||
sandboxed,
|
||||
thinkingLevel,
|
||||
displayText,
|
||||
);
|
||||
@@ -208,7 +198,6 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
initialMessage.images,
|
||||
initialMessage.cwd,
|
||||
undefined,
|
||||
sandboxed,
|
||||
);
|
||||
}
|
||||
}, [initialMessage, isConnected]);
|
||||
|
||||
@@ -60,8 +60,6 @@ type NewChatProps = {
|
||||
function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatProps) {
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.role === 'Super Admin';
|
||||
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
|
||||
|
||||
// Refresh the /chat list — Claude has just written/appended this session's transcript.
|
||||
@@ -79,7 +77,6 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatPro
|
||||
context: 'chat',
|
||||
});
|
||||
|
||||
const sandboxed = !isSuperAdmin;
|
||||
// Run the session in the pwd chosen in the Sessions panel; null → backend default (general_chat_sessions).
|
||||
const [activeCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
|
||||
const cwd = activeCwd ? { path: activeCwd } : locationState?.cwd;
|
||||
@@ -103,7 +100,6 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatPro
|
||||
initialMessage={initialMessage}
|
||||
defaultInput={locationState?.prefillInput ?? ''}
|
||||
cwd={cwd}
|
||||
sandboxed={sandboxed}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { DesktopView } from './DesktopView';
|
||||
|
||||
export const DesktopWrapper = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (user?.role !== 'Super Admin') {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
||||
Remote Desktop requires Super Admin permissions.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DesktopView className="h-full w-full" />;
|
||||
};
|
||||
export const DesktopWrapper = () => <DesktopView className="h-full w-full" />;
|
||||
|
||||
@@ -32,13 +32,5 @@ export const CliampPanelBody = () => {
|
||||
});
|
||||
}, [setSearchParams]);
|
||||
|
||||
return (
|
||||
<TerminalView
|
||||
className="h-full w-full"
|
||||
wsPath={wsPath}
|
||||
sandboxed={false}
|
||||
onExit={handleExit}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
return <TerminalView className="h-full w-full" wsPath={wsPath} onExit={handleExit} autoFocus />;
|
||||
};
|
||||
|
||||
+401
-201
@@ -12,7 +12,13 @@ import { useClient } from 'hooks/useClient';
|
||||
import type { TaskSummary } from '../../useTasks';
|
||||
import { useTaskRunner } from './useTaskRunner';
|
||||
import { usePipelineRunner } from './usePipelineRunner';
|
||||
import { useFilesAPI, type AudioTrack, type SubtitleTrack, type FolderProbe, type FolderTrackGroup } from '../../../../hooks/useFilesAPI';
|
||||
import {
|
||||
useFilesAPI,
|
||||
type AudioTrack,
|
||||
type SubtitleTrack,
|
||||
type FolderProbe,
|
||||
type FolderTrackGroup,
|
||||
} from '../../../../hooks/useFilesAPI';
|
||||
|
||||
const playDing = () => {
|
||||
const ctx = new AudioContext();
|
||||
@@ -46,11 +52,17 @@ type AgenticTaskRunnerProps = {
|
||||
cwd: { root?: string; path: string };
|
||||
initialModel: string | null;
|
||||
taskInfo: TaskInfo;
|
||||
sandboxed?: boolean;
|
||||
context: Record<string, string>;
|
||||
};
|
||||
|
||||
const AgenticTaskRunner = ({ taskDirName, defaultInput, cwd, initialModel, taskInfo, sandboxed, context }: AgenticTaskRunnerProps) => {
|
||||
const AgenticTaskRunner = ({
|
||||
taskDirName,
|
||||
defaultInput,
|
||||
cwd,
|
||||
initialModel,
|
||||
taskInfo,
|
||||
context,
|
||||
}: AgenticTaskRunnerProps) => {
|
||||
const [phase, setPhase] = useState<Phase>('ready');
|
||||
const chat = useChat(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||
const availableModels = useUserVisibleModels();
|
||||
@@ -159,7 +171,7 @@ const AgenticTaskRunner = ({ taskDirName, defaultInput, cwd, initialModel, taskI
|
||||
prompt = `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`;
|
||||
}
|
||||
setPhase('running');
|
||||
chat.sendPrompt(prompt, undefined, undefined, cwd, undefined, sandboxed);
|
||||
chat.sendPrompt(prompt, undefined, undefined, cwd, undefined);
|
||||
};
|
||||
|
||||
const hasConfigurableInputs = inputDefs && Object.keys(inputDefs).length > 0;
|
||||
@@ -272,8 +284,10 @@ const trackLabel = (t: { title: string; lang: string; id: number }) =>
|
||||
t.title || (t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '') || `Track ${t.id + 1}`;
|
||||
|
||||
// Channel count → friendly layout name; audio meta → "stereo · aac 193k"-style detail for a track row.
|
||||
const chLabel = (n: number) => (n === 1 ? 'mono' : n === 2 ? 'stereo' : n === 6 ? '5.1' : n === 8 ? '7.1' : n ? `${n}ch` : '');
|
||||
const audioMeta = (t: AudioTrack) => [chLabel(t.channels), t.codec].filter(Boolean).join(' ') + (t.bitrate ? ` ${t.bitrate}k` : '');
|
||||
const chLabel = (n: number) =>
|
||||
n === 1 ? 'mono' : n === 2 ? 'stereo' : n === 6 ? '5.1' : n === 8 ? '7.1' : n ? `${n}ch` : '';
|
||||
const audioMeta = (t: AudioTrack) =>
|
||||
[chLabel(t.channels), t.codec].filter(Boolean).join(' ') + (t.bitrate ? ` ${t.bitrate}k` : '');
|
||||
|
||||
// subtitle_edit input: per-subtitle keep flag + editable label, serialized to JSON in the form value.
|
||||
type SubtitleEditEntry = { id: number; keep: boolean; label: string };
|
||||
@@ -288,7 +302,17 @@ const parseSubtitleSpec = (raw?: string): SubtitleEditEntry[] => {
|
||||
}
|
||||
};
|
||||
|
||||
const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTracks, subtitleTracks, probing, entryType, hideTrackPickers }: TaskInputFormProps) => {
|
||||
const TaskInputForm = ({
|
||||
inputDefs,
|
||||
values,
|
||||
onChange,
|
||||
autoFilledKeys,
|
||||
audioTracks,
|
||||
subtitleTracks,
|
||||
probing,
|
||||
entryType,
|
||||
hideTrackPickers,
|
||||
}: TaskInputFormProps) => {
|
||||
const configurableInputs = Object.entries(inputDefs).filter(([key]) => !autoFilledKeys.has(key));
|
||||
if (configurableInputs.length === 0) return null;
|
||||
|
||||
@@ -298,16 +322,21 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
|
||||
if (def.type === 'subtitle_edit') {
|
||||
const tracks = subtitleTracks ?? [];
|
||||
const byId = new Map(parseSubtitleSpec(values[key]).map((e) => [e.id, e]));
|
||||
const entryFor = (t: SubtitleTrack): SubtitleEditEntry => byId.get(t.id) ?? { id: t.id, keep: true, label: trackLabel(t) };
|
||||
const entryFor = (t: SubtitleTrack): SubtitleEditEntry =>
|
||||
byId.get(t.id) ?? { id: t.id, keep: true, label: trackLabel(t) };
|
||||
const update = (id: number, patch: Partial<SubtitleEditEntry>) =>
|
||||
onChange(key, JSON.stringify(tracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t)))));
|
||||
onChange(
|
||||
key,
|
||||
JSON.stringify(tracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t)))),
|
||||
);
|
||||
const keptCount = tracks.filter((t) => entryFor(t).keep).length;
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
|
||||
{probing ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
|
||||
<Loader2 className="h-3 w-3 animate-spin" />{' '}
|
||||
{entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
|
||||
</span>
|
||||
) : tracks.length === 0 ? (
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">No subtitles</span>
|
||||
@@ -372,18 +401,32 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
|
||||
{probing ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
|
||||
<Loader2 className="h-3 w-3 animate-spin" />{' '}
|
||||
{entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
|
||||
</span>
|
||||
) : tracks.length === 0 ? (
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">None</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
|
||||
{tracks.map((t) => (
|
||||
<label key={t.id} className="flex items-center gap-1.5 text-sm cursor-pointer text-duck-dark dark:text-foreground">
|
||||
<input type="checkbox" checked={selected.has(t.id)} onChange={() => toggle(t.id)} className="accent-duck-teal cursor-pointer" />
|
||||
<label
|
||||
key={t.id}
|
||||
className="flex items-center gap-1.5 text-sm cursor-pointer text-duck-dark dark:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(t.id)}
|
||||
onChange={() => toggle(t.id)}
|
||||
className="accent-duck-teal cursor-pointer"
|
||||
/>
|
||||
<span className="whitespace-nowrap">
|
||||
{trackLabel(t)}
|
||||
{isAudio && <span className="text-duck-dark/40 dark:text-foreground/40"> · {(t as AudioTrack).channels}ch</span>}
|
||||
{isAudio && (
|
||||
<span className="text-duck-dark/40 dark:text-foreground/40">
|
||||
{' '}
|
||||
· {(t as AudioTrack).channels}ch
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -398,7 +441,9 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
{def.description ?? key}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 bg-duck-dark/5 dark:bg-foreground/5 rounded-lg p-0.5">
|
||||
<button
|
||||
@@ -441,7 +486,9 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
|
||||
// Default: text input for string/number
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-duck-dark/70 dark:text-foreground/70">{def.description ?? key}</label>
|
||||
<label className="text-xs font-medium text-duck-dark/70 dark:text-foreground/70">
|
||||
{def.description ?? key}
|
||||
</label>
|
||||
<input
|
||||
type={def.type === 'number' ? 'number' : 'text'}
|
||||
value={values[key] ?? ''}
|
||||
@@ -515,14 +562,27 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5 text-sm text-duck-dark dark:text-foreground">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input type="radio" checked={!keepAll} onChange={() => onKeepAllChange(false)} className="accent-duck-teal cursor-pointer mt-0.5" />
|
||||
<input
|
||||
type="radio"
|
||||
checked={!keepAll}
|
||||
onChange={() => onKeepAllChange(false)}
|
||||
className="accent-duck-teal cursor-pointer mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Convert the {majorityCount} matching
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50"> — pick tracks below; the other {skipped.length} are skipped</span>
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{' '}
|
||||
— pick tracks below; the other {skipped.length} are skipped
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input type="radio" checked={keepAll} onChange={() => onKeepAllChange(true)} className="accent-duck-teal cursor-pointer mt-0.5" />
|
||||
<input
|
||||
type="radio"
|
||||
checked={keepAll}
|
||||
onChange={() => onKeepAllChange(true)}
|
||||
className="accent-duck-teal cursor-pointer mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Convert all {fileCount} — keep every track
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50"> — nothing skipped</span>
|
||||
@@ -552,7 +612,14 @@ type PickerKinds = { audio: boolean; subs: boolean; subEdit: boolean };
|
||||
// Per-group selection: audio/subs are kept-id csv (or 'none'); subEdit is the keep+label list.
|
||||
type GroupSel = { audio: string; subs: string; subEdit: SubtitleEditEntry[] };
|
||||
|
||||
const parseCsv = (csv: string) => new Set(csv.split(',').filter(Boolean).map(Number).filter((n) => !Number.isNaN(n)));
|
||||
const parseCsv = (csv: string) =>
|
||||
new Set(
|
||||
csv
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
.filter((n) => !Number.isNaN(n)),
|
||||
);
|
||||
|
||||
const pickerKinds = (defs: Record<string, TaskInputDef> | null): PickerKinds => ({
|
||||
audio: Object.values(defs ?? {}).some((d) => d.type === 'audio_tracks'),
|
||||
@@ -568,8 +635,10 @@ const defaultGroupSel = (g: FolderTrackGroup): GroupSel => ({
|
||||
|
||||
// Does a group's selection change anything vs keep-all + original labels?
|
||||
const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds): boolean => {
|
||||
if (has.audio && (s.audio === 'none' ? g.audioTracks.length > 0 : parseCsv(s.audio).size < g.audioTracks.length)) return true;
|
||||
if (has.subs && (s.subs === 'none' ? g.subtitleTracks.length > 0 : parseCsv(s.subs).size < g.subtitleTracks.length)) return true;
|
||||
if (has.audio && (s.audio === 'none' ? g.audioTracks.length > 0 : parseCsv(s.audio).size < g.audioTracks.length))
|
||||
return true;
|
||||
if (has.subs && (s.subs === 'none' ? g.subtitleTracks.length > 0 : parseCsv(s.subs).size < g.subtitleTracks.length))
|
||||
return true;
|
||||
if (has.subEdit) {
|
||||
for (const t of g.subtitleTracks) {
|
||||
const e = s.subEdit.find((x) => x.id === t.id);
|
||||
@@ -582,7 +651,8 @@ const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds): boole
|
||||
// Build the JSON group config the scripts consume ($INPUT_GROUP_CONFIG). allFiles=true (convert) keeps
|
||||
// every group; otherwise only groups that actually change are included.
|
||||
const buildGroupConfig = (groups: FolderTrackGroup[], sel: GroupSel[], has: PickerKinds, allFiles: boolean) => {
|
||||
const spec = (csv: string, count: number) => (csv === 'none' ? 'none' : parseCsv(csv).size >= count ? 'all' : [...parseCsv(csv)].sort((a, b) => a - b).join(','));
|
||||
const spec = (csv: string, count: number) =>
|
||||
csv === 'none' ? 'none' : parseCsv(csv).size >= count ? 'all' : [...parseCsv(csv)].sort((a, b) => a - b).join(',');
|
||||
return groups
|
||||
.map((g, gi) => ({ g, s: sel[gi] ?? defaultGroupSel(g) }))
|
||||
.filter(({ g, s }) => allFiles || groupChanges(g, s, has))
|
||||
@@ -590,7 +660,13 @@ const buildGroupConfig = (groups: FolderTrackGroup[], sel: GroupSel[], has: Pick
|
||||
files: g.files,
|
||||
...(has.audio ? { audio: spec(s.audio, g.audioTracks.length) } : {}),
|
||||
...(has.subs ? { subs: spec(s.subs, g.subtitleTracks.length) } : {}),
|
||||
...(has.subEdit ? { subEdit: g.subtitleTracks.map((t) => s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) }) } : {}),
|
||||
...(has.subEdit
|
||||
? {
|
||||
subEdit: g.subtitleTracks.map(
|
||||
(t) => s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) },
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -617,7 +693,11 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const label = (kind: string) => <span className="text-[11px] font-medium uppercase tracking-wide text-duck-dark/40 dark:text-foreground/40">{kind}</span>;
|
||||
const label = (kind: string) => (
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-duck-dark/40 dark:text-foreground/40">
|
||||
{kind}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
@@ -637,14 +717,23 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
|
||||
n.has(id) ? n.delete(id) : n.add(id);
|
||||
onChange(gi, { subs: n.size ? [...n].sort((a, b) => a - b).join(',') : 'none' });
|
||||
};
|
||||
const entryFor = (t: SubtitleTrack): SubtitleEditEntry => s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) };
|
||||
const entryFor = (t: SubtitleTrack): SubtitleEditEntry =>
|
||||
s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) };
|
||||
const updateEdit = (id: number, patch: Partial<SubtitleEditEntry>) =>
|
||||
onChange(gi, { subEdit: g.subtitleTracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t))) });
|
||||
onChange(gi, {
|
||||
subEdit: g.subtitleTracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t))),
|
||||
});
|
||||
return (
|
||||
<div key={g.signature + gi} className="flex flex-col gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div
|
||||
key={g.signature + gi}
|
||||
className="flex flex-col gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
|
||||
>
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
Group {gi + 1}
|
||||
<span className="text-duck-dark/50 dark:text-foreground/50 font-normal"> · {g.count} file{g.count !== 1 ? 's' : ''}</span>
|
||||
<span className="text-duck-dark/50 dark:text-foreground/50 font-normal">
|
||||
{' '}
|
||||
· {g.count} file{g.count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{has.audio && (
|
||||
@@ -654,8 +743,16 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
|
||||
) : (
|
||||
g.audioTracks.map((t) => (
|
||||
<label key={t.id} className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground">
|
||||
<input type="checkbox" checked={aSel.has(t.id)} onChange={() => toggleA(t.id)} className="accent-duck-teal cursor-pointer shrink-0" />
|
||||
<label
|
||||
key={t.id}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aSel.has(t.id)}
|
||||
onChange={() => toggleA(t.id)}
|
||||
className="accent-duck-teal cursor-pointer shrink-0"
|
||||
/>
|
||||
<span className="whitespace-nowrap">
|
||||
{trackLabel(t)}
|
||||
<span className="text-duck-dark/40 dark:text-foreground/40"> · {audioMeta(t)}</span>
|
||||
@@ -673,8 +770,16 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
|
||||
) : (
|
||||
g.subtitleTracks.map((t) => (
|
||||
<label key={t.id} className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground">
|
||||
<input type="checkbox" checked={sSel.has(t.id)} onChange={() => toggleS(t.id)} className="accent-duck-teal cursor-pointer shrink-0" />
|
||||
<label
|
||||
key={t.id}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sSel.has(t.id)}
|
||||
onChange={() => toggleS(t.id)}
|
||||
className="accent-duck-teal cursor-pointer shrink-0"
|
||||
/>
|
||||
<span className="whitespace-nowrap">
|
||||
{trackLabel(t)}
|
||||
<span className="text-duck-dark/40 dark:text-foreground/40"> · {t.codec}</span>
|
||||
@@ -696,8 +801,15 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
|
||||
return (
|
||||
<div key={t.id} className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer shrink-0">
|
||||
<input type="checkbox" checked={e.keep} onChange={() => updateEdit(t.id, { keep: !e.keep })} className="accent-duck-teal cursor-pointer" />
|
||||
<span className="text-[10px] font-mono uppercase w-9 text-duck-dark/40 dark:text-foreground/40">{t.lang || 'und'}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={e.keep}
|
||||
onChange={() => updateEdit(t.id, { keep: !e.keep })}
|
||||
className="accent-duck-teal cursor-pointer"
|
||||
/>
|
||||
<span className="text-[10px] font-mono uppercase w-9 text-duck-dark/40 dark:text-foreground/40">
|
||||
{t.lang || 'und'}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -743,7 +855,16 @@ type ScriptRunnerProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePath, selectedNames, onClose }: ScriptRunnerProps) => {
|
||||
const ScriptRunner = ({
|
||||
taskDirName,
|
||||
autoInputs,
|
||||
context,
|
||||
cwd,
|
||||
entryType,
|
||||
filePath,
|
||||
selectedNames,
|
||||
onClose,
|
||||
}: ScriptRunnerProps) => {
|
||||
const runner = useTaskRunner();
|
||||
const client = useClient();
|
||||
const navigate = useNavigate();
|
||||
@@ -779,101 +900,105 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
// Fetch task detail to get input definitions
|
||||
useEffect(() => {
|
||||
client
|
||||
.get<{ inline?: boolean; inputs?: Record<string, TaskInputDef>; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean } }>(`/tasks/${taskDirName}`)
|
||||
.get<{
|
||||
inline?: boolean;
|
||||
inputs?: Record<string, TaskInputDef>;
|
||||
config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean };
|
||||
}>(`/tasks/${taskDirName}`)
|
||||
.then((task) => {
|
||||
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
||||
setInline(task.inline === true);
|
||||
setFolderKeepAll(task.config?.folderKeepAll === true);
|
||||
setPerGroupTracks(task.config?.perGroupTracks === true);
|
||||
setPerGroupAllFiles(task.config?.perGroupAllFiles === true);
|
||||
setInputDefs(defs);
|
||||
// Initialize from autofill context, then defaults
|
||||
const initial: Record<string, string> = {};
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
|
||||
else if (def.default !== undefined) initial[key] = def.default;
|
||||
}
|
||||
setFormValues(initial);
|
||||
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
||||
setInline(task.inline === true);
|
||||
setFolderKeepAll(task.config?.folderKeepAll === true);
|
||||
setPerGroupTracks(task.config?.perGroupTracks === true);
|
||||
setPerGroupAllFiles(task.config?.perGroupAllFiles === true);
|
||||
setInputDefs(defs);
|
||||
// Initialize from autofill context, then defaults
|
||||
const initial: Record<string, string> = {};
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
|
||||
else if (def.default !== undefined) initial[key] = def.default;
|
||||
}
|
||||
setFormValues(initial);
|
||||
|
||||
// Multi-selection: scope the run to exactly the selected files. Even tasks without pickers
|
||||
// (which return early below) need this so the batch only touches the selection.
|
||||
if (selectedNames && selectedNames.length > 0) setIncludeFiles(selectedNames.join('\n'));
|
||||
// Multi-selection: scope the run to exactly the selected files. Even tasks without pickers
|
||||
// (which return early below) need this so the batch only touches the selection.
|
||||
if (selectedNames && selectedNames.length > 0) setIncludeFiles(selectedNames.join('\n'));
|
||||
|
||||
// Track pickers: audio defaults to keep-all, subtitles to keep-none. A folder probes every
|
||||
// video and drives the pickers off the largest matching group (the rest convert separately).
|
||||
const hasTrackPickers = Object.values(defs).some(
|
||||
(d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks' || d.type === 'subtitle_edit',
|
||||
);
|
||||
if (!hasTrackPickers || !filePath) return;
|
||||
// Track pickers: audio defaults to keep-all, subtitles to keep-none. A folder probes every
|
||||
// video and drives the pickers off the largest matching group (the rest convert separately).
|
||||
const hasTrackPickers = Object.values(defs).some(
|
||||
(d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks' || d.type === 'subtitle_edit',
|
||||
);
|
||||
if (!hasTrackPickers || !filePath) return;
|
||||
|
||||
const seedTracks = (aud: AudioTrack[], sub: SubtitleTrack[]) => {
|
||||
setAudioTracks(aud);
|
||||
setSubtitleTracks(sub);
|
||||
setFormValues((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
if (def.type === 'audio_tracks') next[key] = aud.map((t) => t.id).join(',') || 'none';
|
||||
if (def.type === 'subtitle_tracks') next[key] = 'none';
|
||||
if (def.type === 'subtitle_edit')
|
||||
next[key] = JSON.stringify(sub.map((t) => ({ id: t.id, keep: false, label: trackLabel(t) })));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
setProbing(true);
|
||||
if (entryType === 'directory') {
|
||||
files
|
||||
.probeFolder(filePath)
|
||||
.then((probe: FolderProbe) => {
|
||||
// Restrict grouping to the selection when this is a multi-selection run — a file matches
|
||||
// if it's selected directly, or sits under a selected folder.
|
||||
const sel = selectedNames && selectedNames.length > 0 ? new Set(selectedNames) : null;
|
||||
const inSel = (f: string) => {
|
||||
if (sel!.has(f)) return true;
|
||||
let p = f;
|
||||
for (let i = p.lastIndexOf('/'); i >= 0; i = p.lastIndexOf('/')) {
|
||||
p = p.slice(0, i);
|
||||
if (sel!.has(p)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const groups = sel
|
||||
? probe.groups
|
||||
.map((g) => ({ ...g, files: g.files.filter(inSel) }))
|
||||
.filter((g) => g.files.length > 0)
|
||||
.map((g) => ({ ...g, count: g.files.length }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
: probe.groups;
|
||||
const fileCount = sel ? groups.reduce((n, g) => n + g.files.length, 0) : probe.fileCount;
|
||||
|
||||
// Per-group mode uses every group with its own pickers, each defaulting to keep-all.
|
||||
setAllGroups(groups);
|
||||
setGroupSel(groups.map(defaultGroupSel));
|
||||
|
||||
const majority = groups[0];
|
||||
if (!majority) {
|
||||
setFolder({ fileCount, majorityCount: 0, skipped: [] });
|
||||
return;
|
||||
const seedTracks = (aud: AudioTrack[], sub: SubtitleTrack[]) => {
|
||||
setAudioTracks(aud);
|
||||
setSubtitleTracks(sub);
|
||||
setFormValues((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
if (def.type === 'audio_tracks') next[key] = aud.map((t) => t.id).join(',') || 'none';
|
||||
if (def.type === 'subtitle_tracks') next[key] = 'none';
|
||||
if (def.type === 'subtitle_edit')
|
||||
next[key] = JSON.stringify(sub.map((t) => ({ id: t.id, keep: false, label: trackLabel(t) })));
|
||||
}
|
||||
seedTracks(majority.audioTracks, majority.subtitleTracks);
|
||||
const skipped = groups.slice(1).flatMap((g) => g.files);
|
||||
setFolder({ fileCount, majorityCount: majority.count, skipped });
|
||||
// Pin the include list when leaving files out — always, for a multi-selection (so it
|
||||
// never spills onto unselected files), otherwise only on a mixed-layout folder.
|
||||
setIncludeFiles(sel || skipped.length > 0 ? majority.files.join('\n') : '');
|
||||
})
|
||||
.catch(() => setFolder({ fileCount: 0, majorityCount: 0, skipped: [] }))
|
||||
.finally(() => setProbing(false));
|
||||
} else {
|
||||
Promise.all([
|
||||
files.audioTracks(filePath).catch(() => [] as AudioTrack[]),
|
||||
files.subtitles(filePath).catch(() => [] as SubtitleTrack[]),
|
||||
])
|
||||
.then(([aud, sub]: [AudioTrack[], SubtitleTrack[]]) => seedTracks(aud, sub))
|
||||
.finally(() => setProbing(false));
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
setProbing(true);
|
||||
if (entryType === 'directory') {
|
||||
files
|
||||
.probeFolder(filePath)
|
||||
.then((probe: FolderProbe) => {
|
||||
// Restrict grouping to the selection when this is a multi-selection run — a file matches
|
||||
// if it's selected directly, or sits under a selected folder.
|
||||
const sel = selectedNames && selectedNames.length > 0 ? new Set(selectedNames) : null;
|
||||
const inSel = (f: string) => {
|
||||
if (sel!.has(f)) return true;
|
||||
let p = f;
|
||||
for (let i = p.lastIndexOf('/'); i >= 0; i = p.lastIndexOf('/')) {
|
||||
p = p.slice(0, i);
|
||||
if (sel!.has(p)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const groups = sel
|
||||
? probe.groups
|
||||
.map((g) => ({ ...g, files: g.files.filter(inSel) }))
|
||||
.filter((g) => g.files.length > 0)
|
||||
.map((g) => ({ ...g, count: g.files.length }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
: probe.groups;
|
||||
const fileCount = sel ? groups.reduce((n, g) => n + g.files.length, 0) : probe.fileCount;
|
||||
|
||||
// Per-group mode uses every group with its own pickers, each defaulting to keep-all.
|
||||
setAllGroups(groups);
|
||||
setGroupSel(groups.map(defaultGroupSel));
|
||||
|
||||
const majority = groups[0];
|
||||
if (!majority) {
|
||||
setFolder({ fileCount, majorityCount: 0, skipped: [] });
|
||||
return;
|
||||
}
|
||||
seedTracks(majority.audioTracks, majority.subtitleTracks);
|
||||
const skipped = groups.slice(1).flatMap((g) => g.files);
|
||||
setFolder({ fileCount, majorityCount: majority.count, skipped });
|
||||
// Pin the include list when leaving files out — always, for a multi-selection (so it
|
||||
// never spills onto unselected files), otherwise only on a mixed-layout folder.
|
||||
setIncludeFiles(sel || skipped.length > 0 ? majority.files.join('\n') : '');
|
||||
})
|
||||
.catch(() => setFolder({ fileCount: 0, majorityCount: 0, skipped: [] }))
|
||||
.finally(() => setProbing(false));
|
||||
} else {
|
||||
Promise.all([
|
||||
files.audioTracks(filePath).catch(() => [] as AudioTrack[]),
|
||||
files.subtitles(filePath).catch(() => [] as SubtitleTrack[]),
|
||||
])
|
||||
.then(([aud, sub]: [AudioTrack[], SubtitleTrack[]]) => seedTracks(aud, sub))
|
||||
.finally(() => setProbing(false));
|
||||
}
|
||||
});
|
||||
}, [taskDirName]);
|
||||
|
||||
const autoFilledKeys = new Set(Object.keys(autoInputs));
|
||||
@@ -912,13 +1037,18 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
// Non-inline tasks become jobs — check if one is already running so we can offer Queue.
|
||||
useEffect(() => {
|
||||
if (inline) return;
|
||||
client.get<Array<unknown>>('/jobs?live=1').then((live) => setJobRunning(live.length > 0)).catch(() => {});
|
||||
client
|
||||
.get<Array<unknown>>('/jobs?live=1')
|
||||
.then((live) => setJobRunning(live.length > 0))
|
||||
.catch(() => {});
|
||||
}, [inline]);
|
||||
|
||||
// Which per-group pickers this task declares, and whether the current selection is real work.
|
||||
const has = pickerKinds(inputDefs);
|
||||
const perGroupHasWork =
|
||||
perGroupTracks && entryType === 'directory' && buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles).length > 0;
|
||||
perGroupTracks &&
|
||||
entryType === 'directory' &&
|
||||
buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles).length > 0;
|
||||
|
||||
// Collect the final input map (per-group config / include list / keep-all overrides all fold in here).
|
||||
const buildAllInputs = (): Record<string, string> => {
|
||||
@@ -940,7 +1070,12 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
const runInline = () => runner.run(taskDirName, buildAllInputs(), cwd);
|
||||
const submitJob = async (action: 'start' | 'queue') => {
|
||||
try {
|
||||
const { jobId } = await client.post<{ jobId: string }>('/jobs', { taskDirName, inputs: buildAllInputs(), cwd, action });
|
||||
const { jobId } = await client.post<{ jobId: string }>('/jobs', {
|
||||
taskDirName,
|
||||
inputs: buildAllInputs(),
|
||||
cwd,
|
||||
action,
|
||||
});
|
||||
setCreated({ jobId, action });
|
||||
} catch {
|
||||
/* stays on the modal so the user can retry */
|
||||
@@ -956,12 +1091,17 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
{created.action === 'queue' ? 'Job queued' : 'Job started'}
|
||||
</div>
|
||||
<div className="text-sm text-duck-dark/50 dark:text-foreground/50 mt-1">
|
||||
{created.action === 'queue' ? 'It will run when the current job finishes.' : "It’s running in the background."}
|
||||
{created.action === 'queue'
|
||||
? 'It will run when the current job finishes.'
|
||||
: 'It’s running in the background.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => { onClose(); navigate(created.action === 'queue' ? '/jobs' : `/jobs/${created.jobId}`); }}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(created.action === 'queue' ? '/jobs' : `/jobs/${created.jobId}`);
|
||||
}}
|
||||
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-duck-teal text-white text-sm font-medium hover:bg-duck-teal/90 cursor-pointer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" /> {created.action === 'queue' ? 'View queue' : 'View job'}
|
||||
@@ -986,46 +1126,53 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{perGroupTracks && entryType === 'directory' ? (
|
||||
<PerGroupTrackConfig
|
||||
groups={allGroups}
|
||||
has={has}
|
||||
sel={groupSel}
|
||||
onChange={(gi, patch) => setGroupSel((prev) => prev.map((s, i) => (i === gi ? { ...s, ...patch } : s)))}
|
||||
probing={probing}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{entryType === 'directory' && folder && !probing && (
|
||||
<FolderSummary
|
||||
folder={folder}
|
||||
keepAll={keepAll}
|
||||
onKeepAllChange={folderKeepAll && hasSelectablePickers && folder.skipped.length > 0 ? setKeepAll : undefined}
|
||||
/>
|
||||
)}
|
||||
{inputDefs && (
|
||||
<TaskInputForm
|
||||
inputDefs={inputDefs}
|
||||
values={formValues}
|
||||
onChange={handleInputChange}
|
||||
autoFilledKeys={autoFilledKeys}
|
||||
audioTracks={audioTracks}
|
||||
subtitleTracks={subtitleTracks}
|
||||
probing={probing}
|
||||
entryType={entryType}
|
||||
hideTrackPickers={keepAll}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{perGroupTracks && entryType === 'directory' ? (
|
||||
<PerGroupTrackConfig
|
||||
groups={allGroups}
|
||||
has={has}
|
||||
sel={groupSel}
|
||||
onChange={(gi, patch) => setGroupSel((prev) => prev.map((s, i) => (i === gi ? { ...s, ...patch } : s)))}
|
||||
probing={probing}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{entryType === 'directory' && folder && !probing && (
|
||||
<FolderSummary
|
||||
folder={folder}
|
||||
keepAll={keepAll}
|
||||
onKeepAllChange={
|
||||
folderKeepAll && hasSelectablePickers && folder.skipped.length > 0 ? setKeepAll : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{inputDefs && (
|
||||
<TaskInputForm
|
||||
inputDefs={inputDefs}
|
||||
values={formValues}
|
||||
onChange={handleInputChange}
|
||||
autoFilledKeys={autoFilledKeys}
|
||||
audioTracks={audioTracks}
|
||||
subtitleTracks={subtitleTracks}
|
||||
probing={probing}
|
||||
entryType={entryType}
|
||||
hideTrackPickers={keepAll}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-center gap-3 border-t border-duck-dark/10 py-4">
|
||||
{(() => {
|
||||
const noWork = perGroupTracks && entryType === 'directory' && !perGroupHasWork;
|
||||
const base = 'flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer';
|
||||
const base =
|
||||
'flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer';
|
||||
if (inline) {
|
||||
return (
|
||||
<button onClick={runInline} disabled={!runner.isConnected || !inputDefs || probing || noWork} className={`${base} bg-duck-teal text-white hover:bg-duck-teal/90`}>
|
||||
<button
|
||||
onClick={runInline}
|
||||
disabled={!runner.isConnected || !inputDefs || probing || noWork}
|
||||
className={`${base} bg-duck-teal text-white hover:bg-duck-teal/90`}
|
||||
>
|
||||
<Play className="h-4 w-4" /> Run
|
||||
</button>
|
||||
);
|
||||
@@ -1034,7 +1181,11 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
return (
|
||||
<>
|
||||
{jobRunning && (
|
||||
<button onClick={() => submitJob('queue')} disabled={jobDisabled} className={`${base} bg-duck-dark/10 dark:bg-foreground/10 text-duck-dark dark:text-foreground hover:bg-duck-dark/15 dark:hover:bg-foreground/15`}>
|
||||
<button
|
||||
onClick={() => submitJob('queue')}
|
||||
disabled={jobDisabled}
|
||||
className={`${base} bg-duck-dark/10 dark:bg-foreground/10 text-duck-dark dark:text-foreground hover:bg-duck-dark/15 dark:hover:bg-foreground/15`}
|
||||
>
|
||||
Queue
|
||||
</button>
|
||||
)}
|
||||
@@ -1134,20 +1285,25 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
|
||||
// Fetch task detail for inputs + check for concurrent steps
|
||||
useEffect(() => {
|
||||
client.get<{ inputs?: Record<string, TaskInputDef>; config?: { steps?: Array<{ task: string; foreach?: string; concurrency?: string | boolean }> } }>(`/tasks/${taskDirName}`).then((task) => {
|
||||
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
||||
setInputDefs(defs);
|
||||
const initial: Record<string, string> = {};
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
|
||||
else if (def.default !== undefined) initial[key] = def.default;
|
||||
}
|
||||
setFormValues(initial);
|
||||
client
|
||||
.get<{
|
||||
inputs?: Record<string, TaskInputDef>;
|
||||
config?: { steps?: Array<{ task: string; foreach?: string; concurrency?: string | boolean }> };
|
||||
}>(`/tasks/${taskDirName}`)
|
||||
.then((task) => {
|
||||
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
||||
setInputDefs(defs);
|
||||
const initial: Record<string, string> = {};
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
|
||||
else if (def.default !== undefined) initial[key] = def.default;
|
||||
}
|
||||
setFormValues(initial);
|
||||
|
||||
const steps = task.config?.steps ?? [];
|
||||
setPipelineSteps(steps.map((s) => ({ task: s.task, foreach: s.foreach })));
|
||||
setHasConcurrentSteps(steps.some((s: { concurrency?: string | boolean }) => s.concurrency));
|
||||
});
|
||||
const steps = task.config?.steps ?? [];
|
||||
setPipelineSteps(steps.map((s) => ({ task: s.task, foreach: s.foreach })));
|
||||
setHasConcurrentSteps(steps.some((s: { concurrency?: string | boolean }) => s.concurrency));
|
||||
});
|
||||
}, [taskDirName]);
|
||||
|
||||
const handleInputChange = (key: string, value: string) => {
|
||||
@@ -1290,9 +1446,7 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
{pipeline.currentStep.status === 'running' && (
|
||||
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
)}
|
||||
{pipeline.currentStep.status === 'complete' && (
|
||||
<span className="ml-auto text-xs text-green-600">done</span>
|
||||
)}
|
||||
{pipeline.currentStep.status === 'complete' && <span className="ml-auto text-xs text-green-600">done</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1310,9 +1464,7 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
{pDone + pError < pTotal && (
|
||||
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
)}
|
||||
{pDone + pError === pTotal && pTotal > 0 && (
|
||||
<span className="ml-auto text-xs text-green-600">done</span>
|
||||
)}
|
||||
{pDone + pError === pTotal && pTotal > 0 && <span className="ml-auto text-xs text-green-600">done</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1339,11 +1491,15 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
{ps.iterations.map((it) => (
|
||||
<div key={it.label} className="flex items-center gap-2 py-1 px-2 rounded text-sm">
|
||||
{it.status === 'pending' && <span className="h-4 w-4 rounded-full border border-duck-dark/20 shrink-0" />}
|
||||
{it.status === 'pending' && (
|
||||
<span className="h-4 w-4 rounded-full border border-duck-dark/20 shrink-0" />
|
||||
)}
|
||||
{it.status === 'running' && <Loader2 className="h-4 w-4 text-amber-500 animate-spin shrink-0" />}
|
||||
{it.status === 'complete' && <CircleCheck className="h-4 w-4 text-green-500 shrink-0" />}
|
||||
{it.status === 'error' && <AlertCircle className="h-4 w-4 text-red-500 shrink-0" />}
|
||||
<span className={`font-mono text-xs truncate ${it.status === 'running' ? 'text-duck-dark' : it.status === 'error' ? 'text-red-500' : 'text-duck-dark/60'}`}>
|
||||
<span
|
||||
className={`font-mono text-xs truncate ${it.status === 'running' ? 'text-duck-dark' : it.status === 'error' ? 'text-red-500' : 'text-duck-dark/60'}`}
|
||||
>
|
||||
{it.label}
|
||||
</span>
|
||||
{it.cost && (
|
||||
@@ -1398,7 +1554,9 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
)}
|
||||
{pipeline.totalCost && (
|
||||
<span className="text-xs text-duck-dark/40 font-mono tabular-nums">
|
||||
{formatElapsed(pipeline.elapsed)} · {(pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens).toLocaleString()} tokens · ${pipeline.totalCost.totalUSD.toFixed(3)}
|
||||
{formatElapsed(pipeline.elapsed)} ·{' '}
|
||||
{(pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens).toLocaleString()} tokens · $
|
||||
{pipeline.totalCost.totalUSD.toFixed(3)}
|
||||
</span>
|
||||
)}
|
||||
{pipeline.skippedItems.length > 0 && (
|
||||
@@ -1408,7 +1566,10 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
)}
|
||||
{pipeline.jobId && (
|
||||
<button
|
||||
onClick={() => { onOpenChange(false); navigate(`/jobs/${pipeline.jobId}`); }}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
navigate(`/jobs/${pipeline.jobId}`);
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs text-duck-teal hover:text-duck-teal/80 transition-colors mt-1 cursor-pointer"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
@@ -1430,12 +1591,23 @@ type TaskRunnerModalProps = {
|
||||
cwd?: { root?: string; path: string };
|
||||
promptOverride?: string;
|
||||
description?: string;
|
||||
sandboxed?: boolean;
|
||||
selectedNames?: string[];
|
||||
folderFullPath?: string;
|
||||
};
|
||||
|
||||
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFullPath, entryType, cwd = { path: '' }, promptOverride, description, sandboxed, selectedNames, folderFullPath }: TaskRunnerModalProps) => {
|
||||
export const TaskRunnerModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
task,
|
||||
entryName,
|
||||
entryFullPath,
|
||||
entryType,
|
||||
cwd = { path: '' },
|
||||
promptOverride,
|
||||
description,
|
||||
selectedNames,
|
||||
folderFullPath,
|
||||
}: TaskRunnerModalProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { settings } = useSettings();
|
||||
const taskSettings = settings.tasks;
|
||||
@@ -1448,16 +1620,27 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
||||
const effectiveEntryType = multi ? 'directory' : entryType;
|
||||
|
||||
// Agentic mode prompt (fallback if task has no body)
|
||||
const defaultInput = promptOverride
|
||||
?? (entryRef && entryType
|
||||
const defaultInput =
|
||||
promptOverride ??
|
||||
(entryRef && entryType
|
||||
? `Execute the task "${task.name}" (${task.dirName}) on the ${entryType}: ${entryRef}`
|
||||
: `Execute the task "${task.name}" (${task.dirName})`);
|
||||
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
|
||||
const taskInfo: TaskInfo = {
|
||||
taskName: task.name,
|
||||
taskDirName: task.dirName,
|
||||
entryName: entryName ?? '',
|
||||
entryType: entryType ?? 'file',
|
||||
};
|
||||
|
||||
// Context values for autofill
|
||||
// Build absolute path the agent sees (sandboxed: /data/home/..., non-sandboxed: ~/...)
|
||||
const homePrefix = sandboxed ? '/data/home' : '~';
|
||||
const entryRelPath = entryName && cwd.path ? `${homePrefix}/${cwd.path}/${entryName}` : entryName ? `${homePrefix}/${entryName}` : undefined;
|
||||
// Path the agent sees, relative to the owner's home.
|
||||
const homePrefix = '~';
|
||||
const entryRelPath =
|
||||
entryName && cwd.path
|
||||
? `${homePrefix}/${cwd.path}/${entryName}`
|
||||
: entryName
|
||||
? `${homePrefix}/${entryName}`
|
||||
: undefined;
|
||||
const autofillContext: Record<string, string> = {};
|
||||
if (entryName) autofillContext.entry_name = entryName;
|
||||
if (entryRelPath) autofillContext.entry_path = entryRelPath;
|
||||
@@ -1498,7 +1681,13 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
||||
key="pipeline"
|
||||
taskDirName={task.dirName}
|
||||
context={autofillContext}
|
||||
cwd={entryType === 'directory' && entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : cwd.path || undefined}
|
||||
cwd={
|
||||
entryType === 'directory' && entryName
|
||||
? cwd.path
|
||||
? `${cwd.path}/${entryName}`
|
||||
: entryName
|
||||
: cwd.path || undefined
|
||||
}
|
||||
/>
|
||||
) : isScript ? (
|
||||
<ScriptRunner
|
||||
@@ -1508,7 +1697,15 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
||||
context={autofillContext}
|
||||
cwd={cwd.path || undefined}
|
||||
entryType={effectiveEntryType}
|
||||
filePath={multi ? cwd.path || undefined : entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : undefined}
|
||||
filePath={
|
||||
multi
|
||||
? cwd.path || undefined
|
||||
: entryName
|
||||
? cwd.path
|
||||
? `${cwd.path}/${entryName}`
|
||||
: entryName
|
||||
: undefined
|
||||
}
|
||||
selectedNames={multi ? selectedNames : undefined}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
@@ -1517,10 +1714,13 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
||||
key="agentic"
|
||||
taskDirName={task.dirName}
|
||||
defaultInput={defaultInput}
|
||||
cwd={entryType === 'directory' && entryName ? { ...cwd, path: cwd.path ? `${cwd.path}/${entryName}` : entryName } : cwd}
|
||||
cwd={
|
||||
entryType === 'directory' && entryName
|
||||
? { ...cwd, path: cwd.path ? `${cwd.path}/${entryName}` : entryName }
|
||||
: cwd
|
||||
}
|
||||
initialModel={null}
|
||||
taskInfo={taskInfo}
|
||||
sandboxed={sandboxed}
|
||||
context={autofillContext}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -49,7 +49,11 @@ export const useFileBrowserApp = (
|
||||
const [cloneUrl, setCloneUrl] = useState('');
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry; selectedNames?: string[] } | null>(null);
|
||||
const [runningTask, setRunningTask] = useState<{
|
||||
task: TaskSummary;
|
||||
entry: DirEntry;
|
||||
selectedNames?: string[];
|
||||
} | null>(null);
|
||||
const [showVideoDownload, setShowVideoDownload] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [audioOnly, setAudioOnly] = useState(false);
|
||||
@@ -65,7 +69,7 @@ export const useFileBrowserApp = (
|
||||
filesRef.current = files;
|
||||
const currentPathRef = useRef(currentPath);
|
||||
currentPathRef.current = currentPath;
|
||||
const hiddenForced = user?.role === 'Super Admin' && currentPath === '/';
|
||||
const hiddenForced = currentPath === '/';
|
||||
const visibleEntries = showHidden && !hiddenForced ? entries : entries.filter((e) => !e.name.startsWith('.'));
|
||||
|
||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||
|
||||
@@ -9,7 +9,6 @@ export type PreviewContextValue = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
stopped: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
iframeKey: number;
|
||||
projects: ProjectDefinition[];
|
||||
startServer: (slug: string) => void;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Globe, RefreshCw, Square, Play } from 'lucide-react';
|
||||
import { usePreview } from './PreviewContext';
|
||||
|
||||
export const PreviewHeader = () => {
|
||||
const { slug, url, port, stopped, isSuperAdmin, stopServer, restartServer } = usePreview();
|
||||
const { slug, url, port, stopped, stopServer, restartServer } = usePreview();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -19,9 +19,7 @@ export const PreviewHeader = () => {
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{slug}</span>
|
||||
{isSuperAdmin && port && (
|
||||
<span className="text-[10px] font-mono opacity-40 shrink-0">:{port}</span>
|
||||
)}
|
||||
{port && <span className="text-[10px] font-mono opacity-40 shrink-0">:{port}</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopServer}
|
||||
|
||||
@@ -26,26 +26,29 @@ export const PreviewProvider = ({ panelId, children }: PreviewProviderProps) =>
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
const [stopped, setStopped] = useState(false);
|
||||
|
||||
const isSuperAdmin = user?.role === 'Super Admin';
|
||||
const cwdSlug = extractSlug(cwd);
|
||||
const slug = cwdSlug ?? selectedSlug;
|
||||
|
||||
const startServer = useCallback(async (targetSlug: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStopped(false);
|
||||
try {
|
||||
const res = await client.post<DevServerResponse>('/dev-server/start', { slug: targetSlug });
|
||||
const token = client.token;
|
||||
setUrl(token ? `${res.url}?token=${encodeURIComponent(token)}` : res.url);
|
||||
setPort(res.port);
|
||||
} catch (err: unknown) {
|
||||
const msg = err && typeof err === 'object' && 'message' in err ? String(err.message) : 'Failed to start dev server';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
const startServer = useCallback(
|
||||
async (targetSlug: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStopped(false);
|
||||
try {
|
||||
const res = await client.post<DevServerResponse>('/dev-server/start', { slug: targetSlug });
|
||||
const token = client.token;
|
||||
setUrl(token ? `${res.url}?token=${encodeURIComponent(token)}` : res.url);
|
||||
setPort(res.port);
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'message' in err ? String(err.message) : 'Failed to start dev server';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
const stopServer = useCallback(async () => {
|
||||
if (!slug) return;
|
||||
@@ -100,7 +103,9 @@ export const PreviewProvider = ({ panelId, children }: PreviewProviderProps) =>
|
||||
};
|
||||
check();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
// Poll status while a server is supposedly running — auto-restart if it died (e.g. idle timeout)
|
||||
@@ -130,8 +135,21 @@ export const PreviewProvider = ({ panelId, children }: PreviewProviderProps) =>
|
||||
return (
|
||||
<PreviewContext
|
||||
value={{
|
||||
slug, cwdSlug, url, port, loading, error, stopped, isSuperAdmin, iframeKey, projects,
|
||||
startServer, stopServer, restartServer, refresh, setSelectedSlug, clearError,
|
||||
slug,
|
||||
cwdSlug,
|
||||
url,
|
||||
port,
|
||||
loading,
|
||||
error,
|
||||
stopped,
|
||||
iframeKey,
|
||||
projects,
|
||||
startServer,
|
||||
stopServer,
|
||||
restartServer,
|
||||
refresh,
|
||||
setSelectedSlug,
|
||||
clearError,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import type { TerminalConnectionState } from './Terminal';
|
||||
@@ -9,10 +8,12 @@ import { TerminalView } from './Terminal';
|
||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||
|
||||
export const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { user } = useAuth();
|
||||
const { dashboardId, cwd } = useWorkspace();
|
||||
const stateKey = dashboardId ? `ws-host-terminals-${dashboardId}` : 'ws-host-terminals-default';
|
||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(
|
||||
stateKey,
|
||||
EMPTY_TERMINALS,
|
||||
);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
@@ -33,14 +34,6 @@ export const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
};
|
||||
}, [panelId]);
|
||||
|
||||
if (user?.role !== 'Super Admin') {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
||||
Host Terminal requires Super Admin permissions.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [, setConnState] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
|
||||
const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);
|
||||
|
||||
@@ -50,7 +43,6 @@ export const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
<TerminalView
|
||||
className="h-full w-full p-2"
|
||||
sessionId={sessionId}
|
||||
sandboxed={false}
|
||||
cwd={cwd}
|
||||
onConnectionChange={onConnectionChange}
|
||||
/>
|
||||
|
||||
@@ -19,7 +19,6 @@ export type TerminalViewProps = {
|
||||
style?: CSSProperties;
|
||||
wsPath?: string;
|
||||
sessionId?: string;
|
||||
sandboxed?: boolean;
|
||||
cwd?: string;
|
||||
command?: string;
|
||||
initialInput?: string;
|
||||
@@ -42,13 +41,19 @@ const DEFAULT_THEME: Required<TerminalTheme> = {
|
||||
selectionBackground: '#3a3a5e',
|
||||
};
|
||||
|
||||
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string, command?: string, cols?: number, rows?: number) => {
|
||||
const buildWsUrl = (
|
||||
wsPath: string,
|
||||
sessionId?: string,
|
||||
cwd?: string,
|
||||
command?: string,
|
||||
cols?: number,
|
||||
rows?: number,
|
||||
) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||
const separator = wsPath.includes('?') ? '&' : '?';
|
||||
let url = `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
|
||||
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
|
||||
if (sandboxed === false) url += '&sandboxed=false';
|
||||
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
|
||||
if (command) url += `&command=${encodeURIComponent(command)}`;
|
||||
if (cols) url += `&cols=${cols}`;
|
||||
@@ -61,7 +66,6 @@ export const TerminalView = ({
|
||||
style,
|
||||
wsPath = '/api/terminal/ws',
|
||||
sessionId,
|
||||
sandboxed = true,
|
||||
cwd,
|
||||
command,
|
||||
initialInput,
|
||||
@@ -155,7 +159,7 @@ export const TerminalView = ({
|
||||
const cols = term.cols;
|
||||
const rows = term.rows;
|
||||
|
||||
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command, cols, rows));
|
||||
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, cwd, command, cols, rows));
|
||||
wsRef.current = ws;
|
||||
|
||||
const cleanupWs = () => {
|
||||
@@ -201,9 +205,15 @@ export const TerminalView = ({
|
||||
if (markerMatch) {
|
||||
const exitCode = Number(markerMatch[1]);
|
||||
const raw = stripAnsi(commandOutput).slice(0, markerMatch.index);
|
||||
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20)));
|
||||
const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim();
|
||||
const output = lines
|
||||
.slice(cmdLine >= 0 ? cmdLine + 1 : 0)
|
||||
.join('\n')
|
||||
.trim();
|
||||
commandDone = true;
|
||||
onCommandDoneRef.current(exitCode, output);
|
||||
}
|
||||
@@ -300,7 +310,6 @@ export const TerminalView = ({
|
||||
isMounted,
|
||||
wsPath,
|
||||
sessionId,
|
||||
sandboxed,
|
||||
cwd,
|
||||
command,
|
||||
fontSize,
|
||||
|
||||
@@ -4,22 +4,17 @@ import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import type { TerminalConnectionState } from './Terminal';
|
||||
import { TerminalView } from './Terminal';
|
||||
import { useTerminalMode } from './useTerminalMode';
|
||||
|
||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||
|
||||
export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { dashboardId, cwd, root } = useWorkspace();
|
||||
const { mode } = useTerminalMode(panelId);
|
||||
const hostRoot = root === '~' || root === 'officer.dev';
|
||||
const sandboxed = !hostRoot && (cwd !== '~' || mode === 'sandboxed');
|
||||
const { dashboardId, cwd } = useWorkspace();
|
||||
const stateKey = (() => {
|
||||
const hostSuffix = mode === 'host' ? 'host-' : '';
|
||||
const wsMatch = dashboardId?.match(/^ws-layout-(.+)$/);
|
||||
if (wsMatch) return `ws-${hostSuffix}terminals-${wsMatch[1]}`;
|
||||
if (wsMatch) return `ws-terminals-${wsMatch[1]}`;
|
||||
const projMatch = dashboardId?.match(/^proj-layout-(.+)$/);
|
||||
if (projMatch) return `proj-${hostSuffix}terminals-${projMatch[1]}`;
|
||||
return `ws-${hostSuffix}terminals-default`;
|
||||
if (projMatch) return `proj-terminals-${projMatch[1]}`;
|
||||
return 'ws-terminals-default';
|
||||
})();
|
||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(
|
||||
stateKey,
|
||||
@@ -55,7 +50,6 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
<TerminalView
|
||||
className="h-full w-full p-2"
|
||||
sessionId={sessionId}
|
||||
sandboxed={sandboxed}
|
||||
cwd={cwd}
|
||||
onConnectionChange={onConnectionChange}
|
||||
/>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
type TerminalMode = 'sandboxed' | 'host';
|
||||
|
||||
export const useTerminalMode = (panelId: string) => {
|
||||
const [mode, setMode] = useGlobal<TerminalMode>(`terminal-mode-${panelId}`, 'sandboxed');
|
||||
|
||||
const toggle = () => setMode((prev) => (prev === 'sandboxed' ? 'host' : 'sandboxed'));
|
||||
|
||||
return { mode, setMode, toggle };
|
||||
};
|
||||
@@ -220,7 +220,6 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
images?: { filename: string; dataUrl: string }[],
|
||||
cwdParam?: { root?: string; path: string },
|
||||
groupSlug?: string | null,
|
||||
sandboxed?: boolean,
|
||||
thinking?: string | null,
|
||||
displayText?: string,
|
||||
) {
|
||||
@@ -257,7 +256,6 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
|
||||
...(cwdParam?.root ? { cwdRoot: cwdParam.root } : {}),
|
||||
...(sandboxed !== undefined ? { sandboxed } : {}),
|
||||
...(groupSlug !== undefined ? { groupSlug } : {}),
|
||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||
...(imageData?.length ? { images: imageData } : {}),
|
||||
|
||||
@@ -7,12 +7,11 @@ const QUERY_KEY = ['DOCK'];
|
||||
|
||||
type DockItemLike = {
|
||||
to: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
export function useDock<T extends DockItemLike>(allDockItems: T[], defaultPaths?: string[]) {
|
||||
const client = useClient();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: dockPaths = null } = useQuery<string[] | null>({
|
||||
@@ -27,15 +26,10 @@ export function useDock<T extends DockItemLike>(allDockItems: T[], defaultPaths?
|
||||
|
||||
const items = useMemo(() => {
|
||||
const byPath = new Map(allDockItems.map((item) => [item.to, item]));
|
||||
return activePaths
|
||||
.map((path) => byPath.get(path))
|
||||
.filter((item): item is T => !!item && (!item.role || item.role === user?.role));
|
||||
}, [activePaths, allDockItems, user?.role]);
|
||||
return activePaths.map((path) => byPath.get(path)).filter((item): item is T => !!item);
|
||||
}, [activePaths, allDockItems]);
|
||||
|
||||
const allItems = useMemo(
|
||||
() => allDockItems.filter((item) => !item.role || item.role === user?.role),
|
||||
[allDockItems, user?.role],
|
||||
);
|
||||
const allItems = allDockItems;
|
||||
|
||||
const setItems = useCallback(
|
||||
(paths: string[]) => {
|
||||
|
||||
@@ -72,17 +72,12 @@ export function useVisibleModels() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Models visible to the current user: system policy (members) or all (admins), minus per-user hidden. */
|
||||
/** Models visible to the owner: every model the server knows about, minus the ones they hid. */
|
||||
export function useUserVisibleModels() {
|
||||
const allModels = useModels();
|
||||
const policyModels = useVisibleModels();
|
||||
const { user } = useAuth();
|
||||
const base = useModels();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const isAdmin = user?.role !== 'Member';
|
||||
const base = isAdmin ? allModels : policyModels;
|
||||
const hidden = settings.chat.hiddenModels;
|
||||
|
||||
if (!hidden || hidden.length === 0) return base;
|
||||
|
||||
const hiddenSet = new Set(hidden);
|
||||
|
||||
Reference in New Issue
Block a user