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
2 changes: 1 addition & 1 deletion shared/types/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface SlashCommand {
/** Placeholder hint for arguments (e.g., "<query>"), shown grayed after the command name. */
argumentHint?: string;
/** Engine-specific source type — used for execution routing. */
source: "claude" | "acp" | "codex-skill" | "codex-app";
source: "claude" | "acp" | "codex-skill" | "codex-app" | "local";
/** For Codex skills: auto-fill text after the prefix. */
defaultPrompt?: string;
/** For Codex apps: the app slug for $app-slug prefix. */
Expand Down
15 changes: 13 additions & 2 deletions src/components/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export function AppLayout() {
[handleSend],
);

const handleSidebarNewChat = useCallback(
const handleOpenNewChat = useCallback(
async (projectId: string) => {
const project = projectManager.projects.find((item) => item.id === projectId);
if (project) {
Expand All @@ -169,6 +169,16 @@ export function AppLayout() {
[handleNewChat, projectManager.projects, setJiraBoardProjectForSpace],
);

const handleComposerClear = useCallback(
async () => {
const projectId = activeProjectId ?? activeSpaceProject?.id;
if (!projectId) return;
setGrabbedElements([]);
await handleOpenNewChat(projectId);
},
[activeProjectId, activeSpaceProject, handleOpenNewChat, setGrabbedElements],
);

const handleSidebarSelectSession = useCallback(
(sessionId: string) => {
const session = manager.sessions.find((item) => item.id === sessionId);
Expand Down Expand Up @@ -384,7 +394,7 @@ Link: ${issue.url}`;
activeSessionId={manager.activeSessionId}
jiraBoardProjectId={jiraBoardProjectId}
jiraBoardEnabled={jiraBoardEnabled}
onNewChat={handleSidebarNewChat}
onNewChat={handleOpenNewChat}
onToggleProjectJiraBoard={handleToggleProjectJiraBoard}
onSelectSession={handleSidebarSelectSession}
onDeleteSession={manager.deleteSession}
Expand Down Expand Up @@ -532,6 +542,7 @@ Link: ${issue.url}`;
pendingPermission={manager.pendingPermission}
onRespondPermission={manager.respondPermission}
onSend={wrappedHandleSend}
onClear={handleComposerClear}
onStop={handleStop}
isProcessing={manager.isProcessing}
queuedCount={manager.queuedCount}
Expand Down
49 changes: 49 additions & 0 deletions src/components/InputBar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { SlashCommand } from "@/types";
import {
LOCAL_CLEAR_COMMAND,
getAvailableSlashCommands,
getSlashCommandReplacement,
isClearCommandText,
} from "./InputBar";

describe("InputBar slash command helpers", () => {
it("always includes the local clear command first", () => {
const commands: SlashCommand[] = [
{ name: "compact", description: "Compact context", source: "claude" },
];

expect(getAvailableSlashCommands(commands)).toEqual([
LOCAL_CLEAR_COMMAND,
commands[0],
]);
});

it("deduplicates engine-provided clear commands in favor of the local one", () => {
const commands: SlashCommand[] = [
{ name: "clear", description: "Engine clear", source: "claude" },
{ name: "help", description: "Help", source: "claude" },
];

expect(getAvailableSlashCommands(commands)).toEqual([
LOCAL_CLEAR_COMMAND,
commands[1],
]);
});

it("detects the exact /clear command text", () => {
expect(isClearCommandText("/clear")).toBe(true);
expect(isClearCommandText(" /clear ")).toBe(true);
expect(isClearCommandText("/clear now")).toBe(false);
expect(isClearCommandText("/compact")).toBe(false);
});

it("builds replacement text for local and engine commands", () => {
expect(getSlashCommandReplacement(LOCAL_CLEAR_COMMAND)).toBe("/clear");
expect(getSlashCommandReplacement({ name: "compact", description: "", source: "claude" })).toBe("/compact ");
expect(getSlashCommandReplacement({ name: "open", description: "", source: "codex-app", appSlug: "jira" })).toBe("$jira ");
expect(
getSlashCommandReplacement({ name: "fix", description: "", source: "codex-skill", defaultPrompt: "bug" }),
).toBe("$fix bug");
});
});
98 changes: 67 additions & 31 deletions src/components/InputBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
useRef,
useEffect,
useCallback,
useMemo,
memo,
type KeyboardEvent,
} from "react";
Expand Down Expand Up @@ -535,6 +536,7 @@ const FOLDER_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24

interface InputBarProps {
onSend: (text: string, images?: ImageAttachment[], displayText?: string) => void;
onClear?: () => void | Promise<void>;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require onClear prop for InputBar

/workspace/harnss/AGENTS.md explicitly forbids false optionals, but this change makes onClear optional even though the only runtime path (AppLayoutBottomComposerInputBar) provides it. Keeping it optional weakens the contract and allows future call sites to render a visible /clear command that silently does nothing except clear the composer, which is a maintainability and behavior regression the type system could prevent.

Useful? React with 👍 / 👎.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot fix this please

onStop: () => void;
isProcessing: boolean;
model: string;
Expand Down Expand Up @@ -580,6 +582,39 @@ interface InputBarProps {
isIslandLayout?: boolean;
}

export const LOCAL_CLEAR_COMMAND: SlashCommand = {
name: "clear",
description: "Open a new chat without sending anything to the agent",
argumentHint: "",
source: "local",
};

export function getAvailableSlashCommands(slashCommands?: SlashCommand[]): SlashCommand[] {
const commands = slashCommands?.filter((cmd) => cmd.name !== LOCAL_CLEAR_COMMAND.name) ?? [];
return [LOCAL_CLEAR_COMMAND, ...commands];
}

export function isClearCommandText(text: string): boolean {
return text.trim() === `/${LOCAL_CLEAR_COMMAND.name}`;
}

export function getSlashCommandReplacement(cmd: SlashCommand): string {
switch (cmd.source) {
case "claude":
case "acp":
return `/${cmd.name} `;
case "codex-skill":
return cmd.defaultPrompt
? `$${cmd.name} ${cmd.defaultPrompt}`
: `$${cmd.name} `;
case "codex-app":
return `$${cmd.appSlug ?? cmd.name} `;
case "local":
// Local commands execute directly, so keep the exact command text with no trailing space.
return `/${cmd.name}`;
}
}

// Simple fuzzy match: all query chars must appear in order
function fuzzyMatch(query: string, target: string): { match: boolean; score: number } {
const q = query.toLowerCase();
Expand Down Expand Up @@ -688,6 +723,7 @@ function extractEditableContent(el: HTMLElement): { text: string; mentionPaths:

export const InputBar = memo(function InputBar({
onSend,
onClear,
onStop,
isProcessing,
model,
Expand Down Expand Up @@ -758,6 +794,10 @@ export const InputBar = memo(function InputBar({
const isACPAgent = selectedAgent != null && selectedAgent.engine === "acp";
const isCodexAgent = selectedAgent != null && selectedAgent.engine === "codex";
const showACPConfigOptions = isACPAgent && (acpConfigOptions?.length ?? 0) > 0;
const availableSlashCommands = useMemo(
() => getAvailableSlashCommands(slashCommands),
[slashCommands],
);
const isAwaitingAcpOptions = isACPAgent && !!acpConfigOptionsLoading;
const modelsLoading = modelList.length === 0;
const modelsLoadingText = isCodexAgent
Expand Down Expand Up @@ -885,10 +925,10 @@ export const InputBar = memo(function InputBar({

// Slash command filtered results
const cmdResults = (() => {
if (!showCommands || !slashCommands?.length) return [];
if (!showCommands || availableSlashCommands.length === 0) return [];
const q = commandQuery.toLowerCase();
if (!q) return slashCommands.slice(0, 15);
return slashCommands
if (!q) return availableSlashCommands.slice(0, 15);
return availableSlashCommands
.filter(cmd => cmd.name.toLowerCase().includes(q) || cmd.description.toLowerCase().includes(q))
.slice(0, 15);
})();
Expand All @@ -915,6 +955,15 @@ export const InputBar = memo(function InputBar({
mentionStartOffset.current = 0;
}, []);

const clearComposer = useCallback((el: HTMLDivElement) => {
el.innerHTML = "";
hasContentRef.current = false;
setHasContent(false);
setAttachments([]);
closeMentions();
setShowCommands(false);
}, [closeMentions]);

const addImageFiles = useCallback(async (files: FileList | globalThis.File[]) => {
const validFiles = Array.from(files).filter(isAcceptedImage);
if (validFiles.length === 0) return;
Expand All @@ -941,24 +990,7 @@ export const InputBar = memo(function InputBar({
const el = editableRef.current;
if (!el) return;

// Build the replacement text based on source engine
let replacement: string;
switch (cmd.source) {
case "claude":
case "acp":
replacement = `/${cmd.name} `;
break;
case "codex-skill":
replacement = cmd.defaultPrompt
? `$${cmd.name} ${cmd.defaultPrompt}`
: `$${cmd.name} `;
break;
case "codex-app":
replacement = `$${cmd.appSlug ?? cmd.name} `;
break;
}

el.textContent = replacement;
el.textContent = getSlashCommandReplacement(cmd);

// Move cursor to end
const range = document.createRange();
Expand Down Expand Up @@ -1035,6 +1067,15 @@ export const InputBar = memo(function InputBar({
const grabbedElementDisplayTokens: string[] = [];
let hasContext = false;

if (isClearCommandText(trimmed)) {
try {
await onClear?.();
} finally {
clearComposer(el);
Comment on lines +1070 to +1074

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent duplicate /clear submits while new chat is pending

This branch awaits onClear but does not set any in-flight guard before waiting, so when new-chat creation is slow users can press Enter (or click send) multiple times and trigger concurrent /clear executions. Because the composer is only cleared in finally, repeated submits can create multiple empty sessions from one intent; set a busy flag or clear/disable immediately before awaiting to make submission idempotent.

Useful? React with 👍 / 👎.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot fix this please

}
return;
}

// File mentions → <file>/<folder> context blocks
if (mentionPaths.length > 0 && projectPath) {
setIsSending(true);
Expand Down Expand Up @@ -1104,13 +1145,8 @@ export const InputBar = memo(function InputBar({
onSend(trimmed, currentImages);
}

// Clear input
el.innerHTML = "";
hasContentRef.current = false;
setHasContent(false);
setAttachments([]);
closeMentions();
}, [attachments, isAwaitingAcpOptions, isSending, projectPath, onSend, closeMentions, grabbedElements]);
clearComposer(el);
}, [attachments, isAwaitingAcpOptions, isSending, projectPath, onSend, onClear, clearComposer, grabbedElements]);

const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
// Slash command picker keyboard navigation
Expand Down Expand Up @@ -1245,14 +1281,14 @@ export const InputBar = memo(function InputBar({
// Slash command detection — "/" at position 0 with no spaces (still typing the command name)
const fullText = (el.textContent ?? "").trimStart();
const slashMatch = fullText.match(/^\/(\S*)$/);
if (slashMatch && slashCommands?.length) {
if (slashMatch && availableSlashCommands.length > 0) {
setShowCommands(true);
setCommandQuery(slashMatch[1]);
setCommandIndex(0);
} else if (showCommands) {
setShowCommands(false);
}
}, [showMentions, showCommands, closeMentions, projectPath, slashCommands]);
}, [showMentions, showCommands, closeMentions, projectPath, availableSlashCommands]);

const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -1433,7 +1469,7 @@ export const InputBar = memo(function InputBar({
? "Loading agent options..."
: isProcessing
? `${selectedAgent?.name ?? "Claude"} is responding... (messages will be queued)`
: slashCommands?.length
: availableSlashCommands.length > 0
? "Ask anything, @ to tag files, / for commands"
: "Ask anything, @ to tag files"}
</div>
Expand Down
Loading