Refine assistant transcript behavior
This commit is contained in:
1 parent
c59e2cb9e5
commit
70c66d8cd0
8 files changed
+242
-52
No files matched your search
@@ -197,7 +197,11 @@ final class AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func sendAgentDraft(_ conversation: ConversationModel, context: SessionContext = .none) {
|
||||
func sendAgentDraft(
|
||||
_ conversation: ConversationModel,
|
||||
context: SessionContext = .none,
|
||||
includesTerminalSuggestion: Bool = false
|
||||
) {
|
||||
let draft = conversation.draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !draft.isEmpty else { return }
|
||||
// The *returned* text, not `draft`: attachments are folded in there, and
|
||||
@@ -205,13 +209,17 @@ final class AppModel {
|
||||
// not match the one the conversation recorded.
|
||||
let prompt = conversation.appendUserTurn(draft)
|
||||
conversation.draft = ""
|
||||
startAgentRun(conversation, prompt: prompt, context: context)
|
||||
startAgentRun(
|
||||
conversation, prompt: prompt, context: context,
|
||||
includesTerminalSuggestion: includesTerminalSuggestion
|
||||
)
|
||||
}
|
||||
|
||||
func startAgentRun(
|
||||
_ conversation: ConversationModel,
|
||||
prompt: String? = nil,
|
||||
context: SessionContext = .none
|
||||
context: SessionContext = .none,
|
||||
includesTerminalSuggestion: Bool = false
|
||||
) {
|
||||
let userPrompt = prompt
|
||||
?? conversation.messages.last(where: { $0.role == .user })?.text
|
||||
@@ -288,7 +296,8 @@ final class AppModel {
|
||||
providers.append(AgentStaticToolProvider(AgentBuiltInTools.make(
|
||||
hosts: agentHosts, connections: agentConnections,
|
||||
authentication: agentAuthentication,
|
||||
background: agentBackgroundCommands, search: searchPlan.local
|
||||
background: agentBackgroundCommands, search: searchPlan.local,
|
||||
includesTerminalSuggestion: includesTerminalSuggestion
|
||||
)))
|
||||
providers.append(AgentStaticToolProvider(AgentScratchTools.make(
|
||||
workspace: scratch, connections: agentConnections,
|
||||
|
||||
@@ -13,13 +13,16 @@ nonisolated enum AgentBuiltInTools: AgentToolSchemaBuilding {
|
||||
authentication suppliedAuthentication: AgentSSHAuthenticationRegistry? = nil,
|
||||
background: AgentBackgroundCommands = AgentBackgroundCommands(),
|
||||
fetcher: RestrictedHTTPFetcher = RestrictedHTTPFetcher(),
|
||||
search: WebSearchClient? = nil
|
||||
search: WebSearchClient? = nil,
|
||||
includesTerminalSuggestion: Bool = false
|
||||
) -> [AnyAgentTool] {
|
||||
let authentication = suppliedAuthentication
|
||||
?? AgentSSHAuthenticationRegistry(resolver: hosts)
|
||||
let terminalTools = includesTerminalSuggestion ? [terminalSuggest()] : []
|
||||
return [
|
||||
listHosts(hosts), sshExec(authentication, connections, background),
|
||||
sshWait(background), terminalSuggest(),
|
||||
sshWait(background),
|
||||
] + terminalTools + [
|
||||
sftpList(authentication, connections), sftpStat(authentication, connections),
|
||||
sftpRead(authentication, connections), sftpWrite(authentication, connections),
|
||||
sftpMkdir(authentication, connections), sftpMove(authentication, connections),
|
||||
|
||||
@@ -49,6 +49,7 @@ struct RiskBadge: View {
|
||||
/// ```
|
||||
struct CommandCardView: View {
|
||||
let command: SuggestedCommand
|
||||
var showsTerminalActions = false
|
||||
|
||||
@Environment(WindowModel.self) private var window
|
||||
@State private var isConfirming = false
|
||||
@@ -91,10 +92,12 @@ struct CommandCardView: View {
|
||||
}
|
||||
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Button("Insert") { insert(run: false) }
|
||||
Button(runTitle) { run() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(assessment.risk == .destructive ? .red : .accentColor)
|
||||
if showsTerminalActions {
|
||||
Button("Insert") { insert(run: false) }
|
||||
Button(runTitle) { run() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(assessment.risk == .destructive ? .red : .accentColor)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
copyToPasteboard(command.command)
|
||||
|
||||
@@ -5,11 +5,26 @@ import SwiftUI
|
||||
/// Shared by the session Assistant sidebar and full Assistant tab so the two can never drift; only
|
||||
/// the padding and the provider header differ.
|
||||
struct ConversationView: View {
|
||||
private enum ScrollTarget: Hashable { case bottom }
|
||||
|
||||
private struct TranscriptScrollMetrics: Equatable {
|
||||
let isAtBottom: Bool
|
||||
let contentHeight: CGFloat
|
||||
}
|
||||
|
||||
let conversation: ConversationModel
|
||||
var isCompact: Bool = false
|
||||
/// Forwarded to the composer. Only the sidebar has a focused pane to
|
||||
/// describe, so only the sidebar passes one.
|
||||
var sessionContext: SessionContext = .none
|
||||
/// A conversation can be shown on both surfaces. Terminal actions belong
|
||||
/// to the Workspace sidebar displaying it, not to the conversation itself.
|
||||
var showsTerminalActions = false
|
||||
|
||||
@State private var isAtTranscriptBottom = true
|
||||
@State private var isFollowingTranscriptBottom = true
|
||||
@State private var isUserScrollingTranscript = false
|
||||
@State private var isTranscriptScrollPending = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -44,7 +59,8 @@ struct ConversationView: View {
|
||||
ComposerView(
|
||||
conversation: conversation,
|
||||
maxContentWidth: isCompact ? nil : Metrics.Chat.contentMaxWidth,
|
||||
sessionContext: sessionContext
|
||||
sessionContext: sessionContext,
|
||||
includesTerminalSuggestion: showsTerminalActions
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +86,9 @@ struct ConversationView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.id(ScrollTarget.bottom)
|
||||
}
|
||||
}
|
||||
// Horizontal padding belongs to the column, not to each turn:
|
||||
@@ -82,23 +101,85 @@ struct ConversationView: View {
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
// Keyed on the last row's revision, not on a count. Streaming text
|
||||
// grows one message without adding a row, so a count never changed
|
||||
// and the transcript stopped following the reply it was showing.
|
||||
.onChange(of: conversation.timeline.last?.revision) {
|
||||
guard let last = conversation.timeline.last else { return }
|
||||
withAnimation(Motion.tabSwitch) { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
.onScrollGeometryChange(for: TranscriptScrollMetrics.self) { geometry in
|
||||
TranscriptScrollMetrics(
|
||||
isAtBottom: geometry.visibleRect.maxY
|
||||
>= geometry.contentSize.height - Metrics.Space.l,
|
||||
contentHeight: geometry.contentSize.height
|
||||
)
|
||||
} action: { oldMetrics, newMetrics in
|
||||
isAtTranscriptBottom = newMetrics.isAtBottom
|
||||
if isUserScrollingTranscript {
|
||||
isFollowingTranscriptBottom = newMetrics.isAtBottom
|
||||
}
|
||||
|
||||
// A token only needs a scroll when it actually wraps or adds a
|
||||
// block. Watching text revisions scheduled work for every token
|
||||
// even though most of them did not move the bottom at all.
|
||||
if oldMetrics.contentHeight != newMetrics.contentHeight {
|
||||
requestScrollToBottom()
|
||||
}
|
||||
}
|
||||
.onScrollPhaseChange { _, phase in
|
||||
switch phase {
|
||||
case .tracking, .interacting, .decelerating:
|
||||
isUserScrollingTranscript = true
|
||||
if !isAtTranscriptBottom {
|
||||
isFollowingTranscriptBottom = false
|
||||
}
|
||||
case .idle:
|
||||
if isUserScrollingTranscript {
|
||||
isFollowingTranscriptBottom = isAtTranscriptBottom
|
||||
}
|
||||
isUserScrollingTranscript = false
|
||||
case .animating:
|
||||
// Programmatic scrolling is not an instruction to stop
|
||||
// following. Only direct user interaction changes it.
|
||||
break
|
||||
}
|
||||
}
|
||||
// Sending a new prompt is an explicit return to the live edge,
|
||||
// even if the reader had previously scrolled up through history.
|
||||
.onChange(of: conversation.messages.last(where: { $0.role == .user })?.id) {
|
||||
requestScrollToBottom(force: true)
|
||||
}
|
||||
.task(id: isTranscriptScrollPending) {
|
||||
// One view-owned debounce coalesces rapid layout changes.
|
||||
// Unlike an unstructured Task per token, it is cancelled with
|
||||
// the Sidebar and can never retain a stale ScrollViewProxy.
|
||||
guard isTranscriptScrollPending else { return }
|
||||
do {
|
||||
try await Task.sleep(for: .milliseconds(80))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
guard isFollowingTranscriptBottom else {
|
||||
isTranscriptScrollPending = false
|
||||
return
|
||||
}
|
||||
proxy.scrollTo(ScrollTarget.bottom, anchor: .bottom)
|
||||
isTranscriptScrollPending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestScrollToBottom(force: Bool = false) {
|
||||
guard force || isFollowingTranscriptBottom else { return }
|
||||
if force { isFollowingTranscriptBottom = true }
|
||||
isTranscriptScrollPending = true
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func row(for item: TimelineItem) -> some View {
|
||||
switch item {
|
||||
case .message(let message) where message.role == .user:
|
||||
UserMessageView(message: message, isCompact: isCompact)
|
||||
case .message(let message):
|
||||
AssistantMessageView(message: message, isCompact: isCompact)
|
||||
AssistantMessageView(
|
||||
message: message, isCompact: isCompact,
|
||||
showsTerminalActions: showsTerminalActions
|
||||
)
|
||||
case .tool(let card):
|
||||
AgentToolRowView(card: card)
|
||||
}
|
||||
@@ -148,6 +229,8 @@ private struct AgentToolRowView: View {
|
||||
.font(MarkdownTypography.body)
|
||||
.lineLimit(isExpanded ? nil : 1)
|
||||
.truncationMode(.tail)
|
||||
.foregroundStyle(card.state == .running ? .secondary : .primary)
|
||||
.modifier(ScanningTextMask(isActive: card.state == .running))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Spacer(minLength: Metrics.Space.s)
|
||||
if card.state == .reviewing {
|
||||
|
||||
@@ -127,6 +127,8 @@ struct ComposerView: View {
|
||||
/// Defaulting to `.none` is what makes a composer outside a Workspace — the
|
||||
/// Assistant tab — session-free by construction rather than by remembering.
|
||||
var sessionContext: SessionContext = .none
|
||||
/// Only the Workspace sidebar gives its agent a command-card tool.
|
||||
var includesTerminalSuggestion = false
|
||||
var onSend: (() -> Void)?
|
||||
|
||||
@Environment(AppModel.self) private var app
|
||||
@@ -523,7 +525,10 @@ struct ComposerView: View {
|
||||
if let onSend {
|
||||
onSend()
|
||||
} else {
|
||||
app.sendAgentDraft(conversation, context: sessionContext)
|
||||
app.sendAgentDraft(
|
||||
conversation, context: sessionContext,
|
||||
includesTerminalSuggestion: includesTerminalSuggestion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ struct UserMessageView: View {
|
||||
struct AssistantMessageView: View {
|
||||
let message: ChatMessage
|
||||
var isCompact = false
|
||||
var showsTerminalActions = false
|
||||
|
||||
@State private var isHovering = false
|
||||
|
||||
@@ -78,7 +79,9 @@ struct AssistantMessageView: View {
|
||||
}
|
||||
|
||||
ForEach(message.commands) { command in
|
||||
CommandCardView(command: command)
|
||||
CommandCardView(
|
||||
command: command, showsTerminalActions: showsTerminalActions
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -89,46 +92,97 @@ struct AssistantMessageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact waveform rather than a generic spinner. Four offset bars make
|
||||
/// progress feel alive without implying a measurable percentage, while the
|
||||
/// label says plainly why the conversation is waiting.
|
||||
/// A quiet, text-only activity indicator. The wording changes occasionally so
|
||||
/// a long wait does not feel frozen, while the transparent scan supplies the
|
||||
/// continuous motion shared by running tool titles.
|
||||
private struct WorkingIndicator: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var statusIndex = 0
|
||||
|
||||
private static let statuses = [
|
||||
"Working…",
|
||||
"Thinking…",
|
||||
"Reasoning…",
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Metrics.Space.m) {
|
||||
if reduceMotion {
|
||||
bars(phase: 0)
|
||||
} else {
|
||||
TimelineView(.animation) { timeline in
|
||||
bars(phase: timeline.date.timeIntervalSinceReferenceDate * 5)
|
||||
Text(Self.statuses[statusIndex])
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.modifier(ScanningTextMask(isActive: true))
|
||||
.padding(.vertical, Metrics.Space.xs)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text(Self.statuses[statusIndex]))
|
||||
.task(id: reduceMotion) {
|
||||
if reduceMotion {
|
||||
statusIndex = 0
|
||||
return
|
||||
}
|
||||
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(2.8))
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
// Pick any other phrase, so a timer tick always produces a
|
||||
// visible change instead of randomly choosing the same item.
|
||||
let offset = Int.random(in: 1..<Self.statuses.count)
|
||||
statusIndex = (statusIndex + offset) % Self.statuses.count
|
||||
}
|
||||
}
|
||||
|
||||
Text("Working…")
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, Metrics.Space.l)
|
||||
.padding(.vertical, Metrics.Space.m)
|
||||
.background(
|
||||
.quaternary.opacity(0.36),
|
||||
in: .capsule
|
||||
)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text("Working"))
|
||||
}
|
||||
}
|
||||
|
||||
private func bars(phase: TimeInterval) -> some View {
|
||||
HStack(alignment: .center, spacing: 3) {
|
||||
ForEach(0..<4, id: \.self) { index in
|
||||
let pulse = (sin(phase - Double(index) * 0.85) + 1) / 2
|
||||
Capsule()
|
||||
.fill(Color.accentColor.opacity(0.48 + pulse * 0.5))
|
||||
.frame(width: 4, height: 6 + pulse * 12)
|
||||
/// Cuts a soft transparent notch out of active text and moves it from leading
|
||||
/// to trailing, matching the familiar "Thinking" text treatment. The text is
|
||||
/// the only moving surface; surrounding controls stay still.
|
||||
struct ScanningTextMask: ViewModifier {
|
||||
let isActive: Bool
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if isActive, !reduceMotion {
|
||||
content.mask {
|
||||
GeometryReader { geometry in
|
||||
TimelineView(.animation) { timeline in
|
||||
let duration = 1.55
|
||||
let progress = timeline.date.timeIntervalSinceReferenceDate
|
||||
.truncatingRemainder(dividingBy: duration) / duration
|
||||
// The reference treatment loses roughly one third of
|
||||
// the word at a time: a fully transparent center with
|
||||
// long, soft shoulders rather than a narrow glint.
|
||||
let bandWidth = min(max(geometry.size.width * 0.32, 40), 96)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle().fill(.white)
|
||||
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.clear,
|
||||
.white.opacity(0.34),
|
||||
.white.opacity(0.82),
|
||||
.white,
|
||||
.white.opacity(0.82),
|
||||
.white.opacity(0.34),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: bandWidth)
|
||||
.offset(
|
||||
x: -bandWidth
|
||||
+ (geometry.size.width + bandWidth) * progress
|
||||
)
|
||||
.blendMode(.destinationOut)
|
||||
}
|
||||
.compositingGroup()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
.frame(width: 25, height: 20)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,10 +79,12 @@ struct AssistantPanelView: View {
|
||||
conversation: window.sidebar.assistantDraft,
|
||||
prompt: "Ask anything…",
|
||||
sessionContext: window.sidebar.sessionContext,
|
||||
includesTerminalSuggestion: true,
|
||||
onSend: {
|
||||
if let conversation = window.sendAssistantSidebarDraft() {
|
||||
app.startAgentRun(
|
||||
conversation, context: window.sidebar.sessionContext
|
||||
conversation, context: window.sidebar.sessionContext,
|
||||
includesTerminalSuggestion: true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -157,7 +159,8 @@ struct AssistantPanelView: View {
|
||||
Divider()
|
||||
ConversationView(
|
||||
conversation: conversation, isCompact: true,
|
||||
sessionContext: window.sidebar.sessionContext
|
||||
sessionContext: window.sidebar.sessionContext,
|
||||
showsTerminalActions: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite
|
||||
@MainActor
|
||||
struct AgentBuiltInToolAvailabilityTests {
|
||||
@Test
|
||||
func terminalSuggestionIsRegisteredOnlyWhenTheSurfaceOptsIn() {
|
||||
let hosts = AgentHostResolver(store: HostLibraryModel())
|
||||
let connections = AgentConnectionPool(
|
||||
configuration: { _ in throw AgentToolError.invalidHost },
|
||||
routeFingerprint: { _ in "" },
|
||||
openExec: { _ in throw AgentToolError.invalidHost },
|
||||
openSFTP: { _ in throw AgentToolError.invalidHost },
|
||||
sweepInterval: nil
|
||||
)
|
||||
|
||||
let mainAssistant = AgentBuiltInTools.make(
|
||||
hosts: hosts, connections: connections
|
||||
)
|
||||
let workspaceSidebar = AgentBuiltInTools.make(
|
||||
hosts: hosts, connections: connections,
|
||||
includesTerminalSuggestion: true
|
||||
)
|
||||
|
||||
#expect(!mainAssistant.contains { $0.descriptor.name == "terminal_suggest" })
|
||||
#expect(workspaceSidebar.contains { $0.descriptor.name == "terminal_suggest" })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user