add stop control to prompt input
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user