diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index d7b8281d..f15ac22f 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -22,6 +22,8 @@ interface PromptInputProps { onRunShell?: (command: string) => Promise disabled?: boolean escapeInDebounce?: boolean + isSessionBusy?: boolean + onAbortSession?: () => Promise } export default function PromptInput(props: PromptInputProps) { @@ -599,8 +601,14 @@ export default function PromptInput(props: PromptInputProps) { textareaRef?.focus() } } - + + function handleAbort() { + if (!props.onAbortSession || !props.isSessionBusy) return + void props.onAbortSession() + } + function handleInput(e: Event) { + const target = e.target as HTMLTextAreaElement const value = target.value setPrompt(value) @@ -818,14 +826,17 @@ export default function PromptInput(props: PromptInputProps) { textareaRef?.focus() } + const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession) + const canSend = () => { if (props.disabled) return false const hasText = prompt().trim().length > 0 if (mode() === "shell") return hasText return hasText || attachments().length > 0 } - + const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" }) + const shouldShowOverlay = () => prompt().length === 0 const instance = () => getActiveInstance() @@ -1010,22 +1021,37 @@ export default function PromptInput(props: PromptInputProps) { - + + + ) diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 408e5c3e..643c7ce6 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -6,7 +6,8 @@ import MessageSection from "../message-section" import { messageStoreBus } from "../../stores/message-v2/bus" import PromptInput from "../prompt-input" import { instances } from "../../stores/instances" -import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions" +import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" +import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status" import { showAlertDialog } from "../../stores/alerts" import { getLogger } from "../../lib/logger" @@ -31,17 +32,22 @@ export const SessionView: Component = (props) => { const session = () => props.activeSessions.get(props.sessionId) const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId)) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) + const sessionBusy = createMemo(() => { + const currentSession = session() + if (!currentSession) return false + return getSessionBusyStatus(props.instanceId, currentSession.id) + }) let scrollToBottomHandle: (() => void) | undefined - createEffect(() => { - + createEffect(() => { const currentSession = session() if (currentSession) { loadMessages(props.instanceId, currentSession.id).catch((error) => log.error("Failed to load messages", error)) } }) - + async function handleSendMessage(prompt: string, attachments: Attachment[]) { + if (scrollToBottomHandle) { scrollToBottomHandle() } @@ -51,8 +57,26 @@ export const SessionView: Component = (props) => { async function handleRunShell(command: string) { await runShellCommand(props.instanceId, props.sessionId, command) } - + + async function handleAbortSession() { + const currentSession = session() + if (!currentSession) return + + try { + await abortSession(props.instanceId, currentSession.id) + log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id }) + } catch (error) { + log.error("Failed to abort session", error) + showAlertDialog("Failed to stop session", { + title: "Stop failed", + detail: error instanceof Error ? error.message : String(error), + variant: "error", + }) + } + } + function getUserMessageText(messageId: string): string | null { + const normalizedMessage = messageStore().getMessage(messageId) if (normalizedMessage && normalizedMessage.role === "user") { const parts = normalizedMessage.partIds @@ -169,6 +193,8 @@ export const SessionView: Component = (props) => { onSend={handleSendMessage} onRunShell={handleRunShell} escapeInDebounce={props.escapeInDebounce} + isSessionBusy={sessionBusy()} + onAbortSession={handleAbortSession} /> ) diff --git a/packages/ui/src/styles/messaging/prompt-input.css b/packages/ui/src/styles/messaging/prompt-input.css index 825d364d..9e5a972a 100644 --- a/packages/ui/src/styles/messaging/prompt-input.css +++ b/packages/ui/src/styles/messaging/prompt-input.css @@ -6,7 +6,15 @@ } .prompt-input-wrapper { - @apply flex items-end gap-2 p-3; + @apply flex items-stretch gap-2 p-2; +} + +.prompt-input-actions { + @apply flex flex-col items-center justify-between; + align-self: stretch; + height: 100%; + padding: 0.25rem 0; + gap: 0.5rem; } .prompt-input-field { @@ -14,14 +22,15 @@ width: 100%; } -.prompt-input { - @apply flex-1 w-full min-h-[56px] max-h-[96px] px-3 pt-2.5 pb-12 border rounded-md text-sm resize-none outline-none transition-colors; - font-family: inherit; - background-color: var(--surface-base); - color: inherit; - border-color: var(--border-base); - line-height: var(--line-height-normal); -} + .prompt-input { + @apply flex-1 w-full min-h-[56px] max-h-[96px] px-3 pt-2.5 pb-4 border rounded-md text-sm resize-none outline-none transition-colors; + font-family: inherit; + background-color: var(--surface-base); + color: inherit; + border-color: var(--border-base); + line-height: var(--line-height-normal); + } + .prompt-input-overlay { position: absolute; @@ -84,6 +93,31 @@ color: var(--text-muted); } +.stop-button { + @apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0; + background-color: rgba(239, 68, 68, 0.85); + color: var(--text-inverted); +} + +.stop-button:hover:not(:disabled) { + background-color: rgba(239, 68, 68, 0.9); + @apply opacity-95 scale-105; +} + +.stop-button:active:not(:disabled) { + background-color: rgba(239, 68, 68, 1); + @apply scale-95; +} + +.stop-button:disabled { + @apply opacity-60 cursor-not-allowed; +} + +.stop-icon { + width: 1rem; + height: 1rem; +} + .send-button { @apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0; background-color: var(--accent-primary);