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:
brunorezio
2026-07-25 23:30:20 +01:00
co-authored by Claude Opus 5
parent 92de996412
commit 044aacf4d5
85 changed files with 2761 additions and 2121 deletions
-1
View File
@@ -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 />;
};
@@ -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.' : "Its running in the background."}
{created.action === 'queue'
? 'It will run when the current job finishes.'
: 'Its 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 } : {}),
+4 -10
View File
@@ -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[]) => {
+2 -7
View File
@@ -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);