add stop control to prompt input

This commit is contained in:
Shantur Rathore
2025-12-06 23:05:38 +00:00
parent 1fbf51b7ae
commit de432106e5
3 changed files with 116 additions and 30 deletions

View File

@@ -22,6 +22,8 @@ interface PromptInputProps {
onRunShell?: (command: string) => Promise<void> onRunShell?: (command: string) => Promise<void>
disabled?: boolean disabled?: boolean
escapeInDebounce?: boolean escapeInDebounce?: boolean
isSessionBusy?: boolean
onAbortSession?: () => Promise<void>
} }
export default function PromptInput(props: PromptInputProps) { export default function PromptInput(props: PromptInputProps) {
@@ -599,8 +601,14 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef?.focus() textareaRef?.focus()
} }
} }
function handleAbort() {
if (!props.onAbortSession || !props.isSessionBusy) return
void props.onAbortSession()
}
function handleInput(e: Event) { function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement const target = e.target as HTMLTextAreaElement
const value = target.value const value = target.value
setPrompt(value) setPrompt(value)
@@ -818,14 +826,17 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef?.focus() textareaRef?.focus()
} }
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
const canSend = () => { const canSend = () => {
if (props.disabled) return false if (props.disabled) return false
const hasText = prompt().trim().length > 0 const hasText = prompt().trim().length > 0
if (mode() === "shell") return hasText if (mode() === "shell") return hasText
return hasText || attachments().length > 0 return hasText || attachments().length > 0
} }
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" }) const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
const shouldShowOverlay = () => prompt().length === 0 const shouldShowOverlay = () => prompt().length === 0
const instance = () => getActiveInstance() const instance = () => getActiveInstance()
@@ -1010,22 +1021,37 @@ export default function PromptInput(props: PromptInputProps) {
</div> </div>
</div> </div>
<button <div class="prompt-input-actions">
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`} <button
onClick={handleSend} type="button"
disabled={!canSend()} class="stop-button"
aria-label="Send message" onClick={handleAbort}
> disabled={!canStop()}
<Show aria-label="Stop session"
when={mode() === "shell"} title="Stop session"
fallback={<span class="send-icon"></span>}
> >
<svg class="shell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="stop-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8l5 4-5 4" /> <rect x="4" y="4" width="12" height="12" rx="2" />
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h6" />
</svg> </svg>
</Show> </button>
</button> <button
type="button"
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
onClick={handleSend}
disabled={!canSend()}
aria-label="Send message"
>
<Show
when={mode() === "shell"}
fallback={<span class="send-icon"></span>}
>
<svg class="shell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8l5 4-5 4" />
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h6" />
</svg>
</Show>
</button>
</div>
</div> </div>
</div> </div>
) )

View File

@@ -6,7 +6,8 @@ import MessageSection from "../message-section"
import { messageStoreBus } from "../../stores/message-v2/bus" import { messageStoreBus } from "../../stores/message-v2/bus"
import PromptInput from "../prompt-input" import PromptInput from "../prompt-input"
import { instances } from "../../stores/instances" 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 { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
@@ -31,17 +32,22 @@ export const SessionView: Component<SessionViewProps> = (props) => {
const session = () => props.activeSessions.get(props.sessionId) const session = () => props.activeSessions.get(props.sessionId)
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId)) const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) 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 let scrollToBottomHandle: (() => void) | undefined
createEffect(() => { createEffect(() => {
const currentSession = session() const currentSession = session()
if (currentSession) { if (currentSession) {
loadMessages(props.instanceId, currentSession.id).catch((error) => log.error("Failed to load messages", error)) loadMessages(props.instanceId, currentSession.id).catch((error) => log.error("Failed to load messages", error))
} }
}) })
async function handleSendMessage(prompt: string, attachments: Attachment[]) { async function handleSendMessage(prompt: string, attachments: Attachment[]) {
if (scrollToBottomHandle) { if (scrollToBottomHandle) {
scrollToBottomHandle() scrollToBottomHandle()
} }
@@ -51,8 +57,26 @@ export const SessionView: Component<SessionViewProps> = (props) => {
async function handleRunShell(command: string) { async function handleRunShell(command: string) {
await runShellCommand(props.instanceId, props.sessionId, command) 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 { function getUserMessageText(messageId: string): string | null {
const normalizedMessage = messageStore().getMessage(messageId) const normalizedMessage = messageStore().getMessage(messageId)
if (normalizedMessage && normalizedMessage.role === "user") { if (normalizedMessage && normalizedMessage.role === "user") {
const parts = normalizedMessage.partIds const parts = normalizedMessage.partIds
@@ -169,6 +193,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
onSend={handleSendMessage} onSend={handleSendMessage}
onRunShell={handleRunShell} onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce} escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
onAbortSession={handleAbortSession}
/> />
</div> </div>
) )

View File

@@ -6,7 +6,15 @@
} }
.prompt-input-wrapper { .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 { .prompt-input-field {
@@ -14,14 +22,15 @@
width: 100%; width: 100%;
} }
.prompt-input { .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; @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; font-family: inherit;
background-color: var(--surface-base); background-color: var(--surface-base);
color: inherit; color: inherit;
border-color: var(--border-base); border-color: var(--border-base);
line-height: var(--line-height-normal); line-height: var(--line-height-normal);
} }
.prompt-input-overlay { .prompt-input-overlay {
position: absolute; position: absolute;
@@ -84,6 +93,31 @@
color: var(--text-muted); 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 { .send-button {
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0; @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); background-color: var(--accent-primary);