Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/terminal/agent-session-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ function hasCliOption(args: string[], optionName: string): boolean {
return false;
}

function addCliOptionBeforeSubcommand(args: string[], optionName: string, subcommands: readonly string[]): void {
if (hasCliOption(args, optionName)) {
return;
}
const subcommandIndex = args.findIndex((arg) => subcommands.includes(arg));
args.splice(subcommandIndex === -1 ? 0 : subcommandIndex, 0, optionName);
}
Comment on lines +130 to +136

function getClineHookScriptPath(
hooksDir: string,
hookName: "Notification" | "TaskComplete" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse",
Expand Down Expand Up @@ -762,6 +770,8 @@ const codexAdapter: AgentSessionAdapter = {

const hooks = resolveHookContext(input);
if (hooks) {
// These hooks are generated by Kanban for this launch; bypassing hook trust prevents an interactive prompt.
addCliOptionBeforeSubcommand(codexArgs, "--dangerously-bypass-hook-trust", ["resume", "fork"]);
configureCodexHooks(codexArgs);
Object.assign(
Comment on lines 771 to 776
env,
Expand Down
4 changes: 4 additions & 0 deletions test/runtime/terminal/agent-session-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ describe("prepareAgentLaunch hook strategies", () => {
expect(launchCommand).toContain("hooks.PermissionRequest");
expect(getCodexConfigOverrideValues(launch.args, "features.hooks")).toEqual(["true"]);
expect(getCodexConfigOverrideValues(launch.args, "features.codex_hooks")).toEqual([]);
expect(launch.args).toContain("--dangerously-bypass-hook-trust");
const hookTrustState = getCodexConfigOverrideValues(launch.args, "hooks.state");
expect(hookTrustState).toHaveLength(1);
expect(hookTrustState[0]).toContain('"/<session-flags>/config.toml:user_prompt_submit:0:0"');
Expand Down Expand Up @@ -620,7 +621,10 @@ describe("prepareAgentLaunch hook strategies", () => {
});

const resumeIndex = launch.args.indexOf("resume");
const hookTrustBypassIndex = launch.args.indexOf("--dangerously-bypass-hook-trust");
expect(resumeIndex).toBeGreaterThan(0);
expect(hookTrustBypassIndex).toBeGreaterThan(-1);
expect(hookTrustBypassIndex).toBeLessThan(resumeIndex);
for (const key of [
"features.hooks",
"hooks.state",
Expand Down
24 changes: 12 additions & 12 deletions web-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { useTaskSessions } from "@/hooks/use-task-sessions";
import { useTaskStartActions } from "@/hooks/use-task-start-actions";
import { useTerminalPanels } from "@/hooks/use-terminal-panels";
import { useWorkspaceSync } from "@/hooks/use-workspace-sync";
import { useI18n } from "@/i18n/i18n-context";
import { LayoutCustomizationsProvider } from "@/resize/layout-customizations";
import { ResizableBottomPane } from "@/resize/resizable-bottom-pane";
import { useProjectNavigationLayout } from "@/resize/use-project-navigation-layout";
Expand All @@ -80,6 +81,7 @@ import { useTerminalThemeColors } from "@/terminal/theme-colors";
import type { BoardData } from "@/types";

export default function App(): ReactElement {
const { t } = useI18n();
const terminalThemeColors = useTerminalThemeColors();
const [board, setBoard] = useState<BoardData>(() => createInitialBoardData());
const [sessions, setSessions] = useState<Record<string, RuntimeTaskSessionSummary>>({});
Expand Down Expand Up @@ -460,12 +462,12 @@ export default function App(): ReactElement {
{
intent: "warning",
icon: "warning-sign",
message: "Workspace changed elsewhere. Synced latest state. Retry your last edit if needed.",
message: t("app.toast.workspaceConflict"),
timeout: 5000,
},
"workspace-state-conflict",
);
}, []);
}, [t]);

useWorkspacePersistence({
board,
Expand Down Expand Up @@ -494,8 +496,8 @@ export default function App(): ReactElement {
intent: "danger",
icon: "warning-sign",
message: removedPath
? `Project no longer exists and was removed: ${removedPath}`
: "Project no longer exists and was removed.",
? t("app.toast.projectRemovedWithPath", { path: removedPath })
: t("app.toast.projectRemoved"),
timeout: 6000,
},
`project-removed-${removedPath || "unknown"}`,
Expand All @@ -511,7 +513,7 @@ export default function App(): ReactElement {
notifyError(streamError, { key: `error:${streamError}` });
}
lastStreamErrorRef.current = streamError;
}, [isRuntimeDisconnected, streamError]);
}, [isRuntimeDisconnected, streamError, t]);

useEffect(() => {
resetTaskEditorState();
Expand Down Expand Up @@ -682,10 +684,10 @@ export default function App(): ReactElement {
return undefined;
}
if (!activeSelectedTaskWorkspaceInfo.exists) {
return selectedCard.column.id === "trash" ? "Task worktree deleted" : "Task worktree not created yet";
return selectedCard.column.id === "trash" ? t("app.worktree.deleted") : t("app.worktree.notCreated");
}
return undefined;
}, [selectedCard]);
}, [selectedCard, t]);

const sidebarLayout = useProjectNavigationLayout();
const handleToggleSidebar = useCallback(() => {
Expand Down Expand Up @@ -901,17 +903,15 @@ export default function App(): ReactElement {
<div className="flex flex-1 min-h-0 items-center justify-center bg-surface-0 p-6">
<div className="flex flex-col items-center justify-center gap-3 text-text-tertiary">
<FolderOpen size={48} strokeWidth={1} />
<h3 className="text-sm font-semibold text-text-primary">No projects yet</h3>
<p className="text-[13px] text-text-secondary">
Add a git repository to start using Kanban.
</p>
<h3 className="text-sm font-semibold text-text-primary">{t("app.noProjects.title")}</h3>
<p className="text-[13px] text-text-secondary">{t("app.noProjects.description")}</p>
<Button
variant="primary"
onClick={() => {
void handleAddProject();
}}
>
Add Project
{t("app.noProjects.addProject")}
</Button>
</div>
</div>
Expand Down
81 changes: 47 additions & 34 deletions web-ui/src/components/board-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/components/ui/cn";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip } from "@/components/ui/tooltip";
import { useI18n } from "@/i18n/i18n-context";
import type { TranslationKey, TranslationValues } from "@/i18n/translations";
import type { RuntimeTaskSessionSummary } from "@/runtime/types";
import { useTaskWorkspaceSnapshotValue } from "@/stores/workspace-metadata-store";
import type { BoardCard as BoardCardModel, BoardColumnId } from "@/types";
import { getTaskAutoReviewCancelButtonLabel } from "@/types";
import { formatPathForDisplay } from "@/utils/path-display";
import { useMeasure } from "@/utils/react-use";
import {
Expand Down Expand Up @@ -46,10 +47,8 @@ const SESSION_ACTIVITY_COLOR = {

const DESCRIPTION_COLLAPSE_LINES = 3;
const DESCRIPTION_EXPANDED_MAX_LINES = 10;
const DESCRIPTION_EXPAND_LABEL = "See more";
const DESCRIPTION_COLLAPSE_LABEL = "Less";
const DESCRIPTION_COLLAPSE_SUFFIX = `… ${DESCRIPTION_EXPAND_LABEL}`;
const DESCRIPTION_EXPANDED_SUFFIX = `… ${DESCRIPTION_COLLAPSE_LABEL}`;

type Translate = (key: TranslationKey, values?: TranslationValues) => string;

function reconstructTaskWorktreeDisplayPath(taskId: string, workspacePath: string | null | undefined): string | null {
if (!workspacePath) {
Expand Down Expand Up @@ -141,12 +140,15 @@ function isCardCreditLimitError(summary: RuntimeTaskSessionSummary | undefined):
return summary.latestHookActivity?.notificationType === "credit_limit";
}

function getCardSessionActivity(summary: RuntimeTaskSessionSummary | undefined): CardSessionActivity | null {
function getCardSessionActivity(
summary: RuntimeTaskSessionSummary | undefined,
t: Translate,
): CardSessionActivity | null {
if (!summary) {
return null;
}
if (isCardCreditLimitError(summary)) {
return { dotColor: SESSION_ACTIVITY_COLOR.warning, text: "Out of credits" };
return { dotColor: SESSION_ACTIVITY_COLOR.warning, text: t("task.outOfCredits") };
}
const hookActivity = summary.latestHookActivity;
const activityText = hookActivity?.activityText?.trim();
Expand Down Expand Up @@ -193,19 +195,19 @@ function getCardSessionActivity(summary: RuntimeTaskSessionSummary | undefined):
} else if (text.startsWith("Failed ")) {
dotColor = SESSION_ACTIVITY_COLOR.error;
} else if (text === "Agent active" || text === "Working on task" || text.startsWith("Resumed")) {
return { dotColor: SESSION_ACTIVITY_COLOR.thinking, text: "Thinking..." };
return { dotColor: SESSION_ACTIVITY_COLOR.thinking, text: t("task.thinking") };
}
return { dotColor, text };
}
if (summary.state === "failed") {
const failedText = finalMessage ?? activityText ?? "Task failed to start";
const failedText = finalMessage ?? activityText ?? t("task.failedToStart");
return { dotColor: SESSION_ACTIVITY_COLOR.error, text: failedText };
}
if (summary.state === "awaiting_review") {
return { dotColor: SESSION_ACTIVITY_COLOR.success, text: "Waiting for review" };
return { dotColor: SESSION_ACTIVITY_COLOR.success, text: t("task.waitingForReview") };
}
if (summary.state === "running") {
return { dotColor: SESSION_ACTIVITY_COLOR.thinking, text: "Thinking..." };
return { dotColor: SESSION_ACTIVITY_COLOR.thinking, text: t("task.thinking") };
}
return null;
}
Expand Down Expand Up @@ -259,6 +261,7 @@ export function BoardCard({
workspacePath?: string | null;
defaultClineModelId?: string | null;
}): React.ReactElement {
const { t } = useI18n();
const [isHovered, setIsHovered] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [draftTitle, setDraftTitle] = useState(card.title);
Expand All @@ -273,7 +276,7 @@ export function BoardCard({
const isTrashCard = columnId === "trash";
const isCardInteractive = !isTrashCard;
const descriptionWidth = descriptionRect.width > 0 ? descriptionRect.width : descriptionWidthFallback;
const rawSessionActivity = useMemo(() => getCardSessionActivity(sessionSummary), [sessionSummary]);
const rawSessionActivity = useMemo(() => getCardSessionActivity(sessionSummary, t), [sessionSummary, t]);
const lastSessionActivityRef = useRef<CardSessionActivity | null>(null);
const lastSessionActivityCardIdRef = useRef<string | null>(null);
if (lastSessionActivityCardIdRef.current !== card.id) {
Expand All @@ -292,6 +295,10 @@ export function BoardCard({
() => getTaskPromptDescription(card.prompt, displayTitle),
[card.prompt, displayTitle],
);
const descriptionExpandLabel = t("task.seeMore");
const descriptionCollapseLabel = t("task.less");
const descriptionCollapseSuffix = `… ${descriptionExpandLabel}`;
const descriptionExpandedSuffix = `… ${descriptionCollapseLabel}`;

useLayoutEffect(() => {
if (descriptionRect.width > 0 || !displayDescription) {
Expand Down Expand Up @@ -384,17 +391,17 @@ export function BoardCard({
collapsed: clampTextWithInlineSuffix(displayDescription, {
maxWidthPx: descriptionWidth,
maxLines: DESCRIPTION_COLLAPSE_LINES,
suffix: DESCRIPTION_COLLAPSE_SUFFIX,
suffix: descriptionCollapseSuffix,
measureText: measure,
}),
expanded: clampTextWithInlineSuffix(displayDescription, {
maxWidthPx: descriptionWidth,
maxLines: DESCRIPTION_EXPANDED_MAX_LINES,
suffix: DESCRIPTION_EXPANDED_SUFFIX,
suffix: descriptionExpandedSuffix,
measureText: measure,
}),
};
}, [descriptionFont, descriptionWidth, displayDescription]);
}, [descriptionCollapseSuffix, descriptionExpandedSuffix, descriptionFont, descriptionWidth, displayDescription]);

const isCreditLimit = isCardCreditLimitError(sessionSummary);
const renderStatusMarker = () => {
Expand All @@ -421,15 +428,21 @@ export function BoardCard({
? reviewWorkspaceSnapshot.changedFiles == null
? null
: {
filesLabel: `${reviewWorkspaceSnapshot.changedFiles} ${reviewWorkspaceSnapshot.changedFiles === 1 ? "file" : "files"}`,
filesLabel: `${reviewWorkspaceSnapshot.changedFiles} ${t(
reviewWorkspaceSnapshot.changedFiles === 1 ? "common.file" : "common.files",
)}`,
additions: reviewWorkspaceSnapshot.additions ?? 0,
deletions: reviewWorkspaceSnapshot.deletions ?? 0,
}
: null;
const showReviewGitActions = columnId === "review" && (reviewWorkspaceSnapshot?.changedFiles ?? 0) > 0;
const isAnyGitActionLoading = isCommitLoading || isOpenPrLoading;
const cancelAutomaticActionLabel =
!isTrashCard && card.autoReviewEnabled ? getTaskAutoReviewCancelButtonLabel(card.autoReviewMode) : null;
!isTrashCard && card.autoReviewEnabled
? card.autoReviewMode === "pr"
? t("task.cancelAutoPr")
: t("task.cancelAutoCommit")
: null;
const agentOverrideLabel = useMemo(
() => (card.agentId ? (getRuntimeAgentCatalogEntry(card.agentId)?.label ?? card.agentId) : null),
[card.agentId],
Expand All @@ -441,15 +454,15 @@ export function BoardCard({
const explicitReasoningLabel = card.clineSettings.reasoningEffort
? formatClineReasoningEffortLabel(card.clineSettings.reasoningEffort)
: !card.clineSettings.providerId && !card.clineSettings.modelId
? "Default"
? t("common.default")
: null;
if (card.clineSettings.providerId && !card.clineSettings.modelId) {
const providerLabel = `Provider: ${card.clineSettings.providerId}`;
const providerLabel = `${t("task.provider")}: ${card.clineSettings.providerId}`;
return explicitReasoningLabel ? `${providerLabel} (${explicitReasoningLabel})` : providerLabel;
}
const effectiveModelId = card.clineSettings.modelId ?? defaultClineModelId;
if (!effectiveModelId) {
return explicitReasoningLabel ? `Default model (${explicitReasoningLabel})` : null;
return explicitReasoningLabel ? `${t("task.defaultModel")} (${explicitReasoningLabel})` : null;
}
const modelName = resolveClineModelDisplayName(effectiveModelId);
if (explicitReasoningLabel) {
Expand All @@ -461,7 +474,7 @@ export function BoardCard({
reasoningEffort: inheritedReasoningEffort,
showReasoningEffort: Boolean(inheritedReasoningEffort),
});
}, [card.clineSettings, defaultClineModelId]);
}, [card.clineSettings, defaultClineModelId, t]);
const taskAgentSettingsLabel = useMemo(() => {
const parts = [agentOverrideLabel, modelOverrideLabel].filter((value): value is string => Boolean(value));
return parts.length > 0 ? parts.join(" · ") : null;
Expand Down Expand Up @@ -576,7 +589,7 @@ export function BoardCard({
</p>
<button
type="button"
aria-label="Edit task title"
aria-label={t("task.editTitle")}
onMouseDown={stopEvent}
onClick={(event) => {
stopEvent(event);
Expand Down Expand Up @@ -607,7 +620,7 @@ export function BoardCard({
icon={<Play size={14} />}
variant="ghost"
size="sm"
aria-label="Start task"
aria-label={t("task.startTask")}
onMouseDown={stopEvent}
onClick={(event) => {
stopEvent(event);
Expand All @@ -620,7 +633,7 @@ export function BoardCard({
variant="ghost"
size="sm"
disabled={isMoveToTrashLoading}
aria-label="Move task to done"
aria-label={t("task.moveToDone")}
onMouseDown={stopEvent}
onClick={(event) => {
stopEvent(event);
Expand All @@ -632,17 +645,17 @@ export function BoardCard({
side="bottom"
content={
<>
Restore session
{t("task.restoreSession")}
<br />
in new worktree
{t("task.restoreInNewWorktree")}
</>
}
>
<Button
icon={<RotateCcw size={12} />}
variant="ghost"
size="sm"
aria-label="Restore task from done"
aria-label={t("task.restoreFromDone")}
onMouseDown={stopEvent}
onClick={(event) => {
stopEvent(event);
Expand Down Expand Up @@ -677,16 +690,16 @@ export function BoardCard({
aria-expanded={isDescriptionExpanded}
aria-label={
isDescriptionExpanded
? "Collapse task description"
: "Expand task description"
? t("task.collapseDescription")
: t("task.expandDescription")
}
onMouseDown={stopEvent}
onClick={(event) => {
stopEvent(event);
setIsDescriptionExpanded(!isDescriptionExpanded);
}}
>
{isDescriptionExpanded ? DESCRIPTION_COLLAPSE_LABEL : DESCRIPTION_EXPAND_LABEL}
{isDescriptionExpanded ? descriptionCollapseLabel : descriptionExpandLabel}
</button>
</>
) : isDescriptionExpanded && descriptionDisplay.collapsed.isTruncated ? (
Expand All @@ -696,14 +709,14 @@ export function BoardCard({
type="button"
className="inline cursor-pointer rounded-sm text-text-tertiary hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent [font:inherit]"
aria-expanded={isDescriptionExpanded}
aria-label="Collapse task description"
aria-label={t("task.collapseDescription")}
onMouseDown={stopEvent}
onClick={(event) => {
stopEvent(event);
setIsDescriptionExpanded(false);
}}
>
{DESCRIPTION_COLLAPSE_LABEL}
{descriptionCollapseLabel}
</button>
</>
) : null}
Expand Down Expand Up @@ -811,7 +824,7 @@ export function BoardCard({
onCommit?.(card.id);
}}
>
Commit
{t("task.commit")}
</Button>
<Button
variant="primary"
Expand All @@ -825,7 +838,7 @@ export function BoardCard({
onOpenPr?.(card.id);
}}
>
Open PR
{t("task.openPr")}
</Button>
</div>
) : null}
Expand Down
Loading