mirror of
https://github.com/block/goose.git
synced 2026-04-28 03:29:36 +00:00
feat: goose2 context window usage in chat input (#8613)
Some checks are pending
Canary / Prepare Version (push) Waiting to run
Canary / build-cli (push) Blocked by required conditions
Canary / Upload Install Script (push) Blocked by required conditions
Canary / bundle-desktop (push) Blocked by required conditions
Canary / bundle-desktop-intel (push) Blocked by required conditions
Canary / bundle-desktop-linux (push) Blocked by required conditions
Canary / bundle-desktop-windows (push) Blocked by required conditions
Canary / Release (push) Blocked by required conditions
Unused Dependencies / machete (push) Waiting to run
CI / changes (push) Waiting to run
CI / Check Rust Code Format (push) Blocked by required conditions
CI / Build and Test Rust Project (push) Blocked by required conditions
CI / Build Rust Project on Windows (push) Waiting to run
CI / Lint Rust Code (push) Blocked by required conditions
CI / Check Generated Schemas are Up-to-Date (push) Blocked by required conditions
CI / Test and Lint Electron Desktop App (push) Blocked by required conditions
Goose 2 CI / Lint & Format (push) Waiting to run
Goose 2 CI / Unit Tests (push) Waiting to run
Goose 2 CI / Desktop Build & E2E (push) Waiting to run
Goose 2 CI / Rust Lint (push) Waiting to run
Live Provider Tests / check-fork (push) Waiting to run
Live Provider Tests / changes (push) Blocked by required conditions
Live Provider Tests / Build Binary (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (Code Execution) (push) Blocked by required conditions
Live Provider Tests / Compaction Tests (push) Blocked by required conditions
Live Provider Tests / goose server HTTP integration tests (push) Blocked by required conditions
Publish Docker Image / docker (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Some checks are pending
Canary / Prepare Version (push) Waiting to run
Canary / build-cli (push) Blocked by required conditions
Canary / Upload Install Script (push) Blocked by required conditions
Canary / bundle-desktop (push) Blocked by required conditions
Canary / bundle-desktop-intel (push) Blocked by required conditions
Canary / bundle-desktop-linux (push) Blocked by required conditions
Canary / bundle-desktop-windows (push) Blocked by required conditions
Canary / Release (push) Blocked by required conditions
Unused Dependencies / machete (push) Waiting to run
CI / changes (push) Waiting to run
CI / Check Rust Code Format (push) Blocked by required conditions
CI / Build and Test Rust Project (push) Blocked by required conditions
CI / Build Rust Project on Windows (push) Waiting to run
CI / Lint Rust Code (push) Blocked by required conditions
CI / Check Generated Schemas are Up-to-Date (push) Blocked by required conditions
CI / Test and Lint Electron Desktop App (push) Blocked by required conditions
Goose 2 CI / Lint & Format (push) Waiting to run
Goose 2 CI / Unit Tests (push) Waiting to run
Goose 2 CI / Desktop Build & E2E (push) Waiting to run
Goose 2 CI / Rust Lint (push) Waiting to run
Live Provider Tests / check-fork (push) Waiting to run
Live Provider Tests / changes (push) Blocked by required conditions
Live Provider Tests / Build Binary (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (Code Execution) (push) Blocked by required conditions
Live Provider Tests / Compaction Tests (push) Blocked by required conditions
Live Provider Tests / goose server HTTP integration tests (push) Blocked by required conditions
Publish Docker Image / docker (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
parent
765213561e
commit
a7d78ee59e
22 changed files with 1035 additions and 73 deletions
|
|
@ -40,7 +40,7 @@ use sacp::schema::{
|
|||
SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse,
|
||||
SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents,
|
||||
ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate,
|
||||
ToolCallUpdateFields, ToolKind,
|
||||
ToolCallUpdateFields, ToolKind, Usage, UsageUpdate,
|
||||
};
|
||||
use sacp::util::MatchDispatchFrom;
|
||||
use sacp::{
|
||||
|
|
@ -603,6 +603,22 @@ fn build_config_options(
|
|||
]
|
||||
}
|
||||
|
||||
fn to_nonnegative_u64(value: Option<i32>) -> Option<u64> {
|
||||
value.and_then(|v| u64::try_from(v).ok())
|
||||
}
|
||||
|
||||
fn build_prompt_usage(session: &Session) -> Option<Usage> {
|
||||
let total = to_nonnegative_u64(session.total_tokens)?;
|
||||
let input = to_nonnegative_u64(session.input_tokens).unwrap_or(0);
|
||||
let output = to_nonnegative_u64(session.output_tokens).unwrap_or(0);
|
||||
Some(Usage::new(total, input, output))
|
||||
}
|
||||
|
||||
fn build_usage_update(session: &Session, context_limit: usize) -> UsageUpdate {
|
||||
let used = session.total_tokens.unwrap_or(0).max(0) as u64;
|
||||
UsageUpdate::new(used, context_limit as u64)
|
||||
}
|
||||
|
||||
impl GooseAcpAgent {
|
||||
pub fn permission_manager(&self) -> Arc<PermissionManager> {
|
||||
Arc::clone(&self.permission_manager)
|
||||
|
|
@ -1383,27 +1399,38 @@ impl GooseAcpAgent {
|
|||
// Resolve provider + model from config so we can include the current
|
||||
// model in the response without waiting for the full agent setup.
|
||||
let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await;
|
||||
let initial_usage_update = resolved
|
||||
.as_ref()
|
||||
.ok()
|
||||
.map(|(_, mc)| build_usage_update(&goose_session, mc.context_limit()));
|
||||
let (model_state, config_options) =
|
||||
build_eager_config(&resolved, &mode_state, &goose_session).await;
|
||||
let session_id = SessionId::new(thread_id.clone());
|
||||
|
||||
self.spawn_agent_setup(
|
||||
cx,
|
||||
agent_tx,
|
||||
AgentSetupRequest {
|
||||
session_id: SessionId::new(thread_id.clone()),
|
||||
session_id: session_id.clone(),
|
||||
goose_session,
|
||||
mcp_servers: args.mcp_servers,
|
||||
resolved_provider: resolved.ok(),
|
||||
resolved_provider: resolved.as_ref().ok().cloned(),
|
||||
},
|
||||
);
|
||||
|
||||
let mut response = NewSessionResponse::new(SessionId::new(thread_id)).modes(mode_state);
|
||||
let mut response = NewSessionResponse::new(session_id.clone()).modes(mode_state);
|
||||
if let Some(ms) = model_state {
|
||||
response = response.models(ms);
|
||||
}
|
||||
if let Some(co) = config_options {
|
||||
response = response.config_options(co);
|
||||
}
|
||||
if let Some(usage_update) = initial_usage_update {
|
||||
cx.send_notification(SessionNotification::new(
|
||||
session_id,
|
||||
SessionUpdate::UsageUpdate(usage_update),
|
||||
))?;
|
||||
}
|
||||
debug!(
|
||||
target: "perf",
|
||||
sid = %sid,
|
||||
|
|
@ -1748,6 +1775,16 @@ impl GooseAcpAgent {
|
|||
let mode_state = build_mode_state(loaded_mode)?;
|
||||
|
||||
let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await;
|
||||
let initial_usage_update = resolved
|
||||
.as_ref()
|
||||
.ok()
|
||||
.map(|(_, mc)| build_usage_update(&goose_session, mc.context_limit()))
|
||||
.or_else(|| {
|
||||
goose_session
|
||||
.model_config
|
||||
.as_ref()
|
||||
.map(|mc| build_usage_update(&goose_session, mc.context_limit()))
|
||||
});
|
||||
let (model_state, config_options) =
|
||||
build_eager_config(&resolved, &mode_state, &goose_session).await;
|
||||
|
||||
|
|
@ -1769,6 +1806,12 @@ impl GooseAcpAgent {
|
|||
if let Some(co) = config_options {
|
||||
response = response.config_options(co);
|
||||
}
|
||||
if let Some(usage_update) = initial_usage_update {
|
||||
cx.send_notification(SessionNotification::new(
|
||||
args.session_id.clone(),
|
||||
SessionUpdate::UsageUpdate(usage_update),
|
||||
))?;
|
||||
}
|
||||
debug!(
|
||||
target: "perf",
|
||||
sid = %sid,
|
||||
|
|
@ -1882,10 +1925,30 @@ impl GooseAcpAgent {
|
|||
}
|
||||
}
|
||||
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = sessions.get_mut(&thread_id) {
|
||||
session.cancel_token = None;
|
||||
{
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = sessions.get_mut(&thread_id) {
|
||||
session.cancel_token = None;
|
||||
}
|
||||
}
|
||||
|
||||
let session = self
|
||||
.session_manager
|
||||
.get_session(&internal_session_id, false)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
sacp::Error::internal_error().data(format!("Failed to load session: {}", e))
|
||||
})?;
|
||||
let provider = agent.provider().await.map_err(|e| {
|
||||
sacp::Error::internal_error().data(format!("Failed to get provider: {}", e))
|
||||
})?;
|
||||
let usage_update =
|
||||
build_usage_update(&session, provider.get_model_config().context_limit());
|
||||
cx.send_notification(SessionNotification::new(
|
||||
args.session_id.clone(),
|
||||
SessionUpdate::UsageUpdate(usage_update),
|
||||
))?;
|
||||
|
||||
debug!(
|
||||
target: "perf",
|
||||
sid = %sid,
|
||||
|
|
@ -1894,11 +1957,17 @@ impl GooseAcpAgent {
|
|||
cancelled = was_cancelled,
|
||||
"perf: prompt done"
|
||||
);
|
||||
Ok(PromptResponse::new(if was_cancelled {
|
||||
let stop_reason = if was_cancelled {
|
||||
StopReason::Cancelled
|
||||
} else {
|
||||
StopReason::EndTurn
|
||||
}))
|
||||
};
|
||||
|
||||
let mut response = PromptResponse::new(stop_reason);
|
||||
if let Some(usage) = build_prompt_usage(&session) {
|
||||
response = response.usage(usage);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn on_cancel(&self, args: CancelNotification) -> Result<(), sacp::Error> {
|
||||
|
|
@ -3486,6 +3555,80 @@ print(\"hello, world\")
|
|||
.map(|locs| locs.into_iter().map(|loc| (loc.path, loc.line)).collect())
|
||||
}
|
||||
|
||||
fn make_session_with_usage(
|
||||
total_tokens: Option<i32>,
|
||||
input_tokens: Option<i32>,
|
||||
output_tokens: Option<i32>,
|
||||
accumulated_total_tokens: Option<i32>,
|
||||
accumulated_input_tokens: Option<i32>,
|
||||
accumulated_output_tokens: Option<i32>,
|
||||
) -> Session {
|
||||
Session {
|
||||
id: "session-1".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
name: "ACP Session".to_string(),
|
||||
user_set_name: false,
|
||||
session_type: SessionType::Acp,
|
||||
created_at: Default::default(),
|
||||
updated_at: Default::default(),
|
||||
extension_data: goose::session::ExtensionData::default(),
|
||||
total_tokens,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
accumulated_total_tokens,
|
||||
accumulated_input_tokens,
|
||||
accumulated_output_tokens,
|
||||
schedule_id: None,
|
||||
recipe: None,
|
||||
user_recipe_values: None,
|
||||
conversation: None,
|
||||
message_count: 0,
|
||||
provider_name: None,
|
||||
model_config: None,
|
||||
goose_mode: GooseMode::default(),
|
||||
thread_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_prompt_usage_uses_current_turn_tokens() {
|
||||
let session = make_session_with_usage(
|
||||
Some(120),
|
||||
Some(80),
|
||||
Some(40),
|
||||
Some(360),
|
||||
Some(210),
|
||||
Some(150),
|
||||
);
|
||||
let usage = build_prompt_usage(&session).expect("usage should be present");
|
||||
assert_eq!(usage.total_tokens, 120);
|
||||
assert_eq!(usage.input_tokens, 80);
|
||||
assert_eq!(usage.output_tokens, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_prompt_usage_falls_back_to_current_tokens() {
|
||||
let session = make_session_with_usage(Some(120), Some(80), Some(40), None, None, None);
|
||||
let usage = build_prompt_usage(&session).expect("usage should be present");
|
||||
assert_eq!(usage.total_tokens, 120);
|
||||
assert_eq!(usage.input_tokens, 80);
|
||||
assert_eq!(usage.output_tokens, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_prompt_usage_requires_total_tokens() {
|
||||
let session = make_session_with_usage(None, Some(80), Some(40), None, None, None);
|
||||
assert!(build_prompt_usage(&session).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_update_clamps_negative_used_to_zero() {
|
||||
let session = make_session_with_usage(Some(-7), Some(0), Some(0), None, None, None);
|
||||
let usage = build_usage_update(&session, 258_000);
|
||||
assert_eq!(usage.used, 0);
|
||||
assert_eq!(usage.size, 258_000);
|
||||
}
|
||||
|
||||
#[test_case(
|
||||
GooseMode::Auto
|
||||
=> Ok(SessionModeState::new(
|
||||
|
|
|
|||
|
|
@ -88,10 +88,24 @@ dev:
|
|||
# Override with e.g. RUST_LOG=info just dev to disable.
|
||||
export RUST_LOG="${RUST_LOG:-perf=debug,info}"
|
||||
PROJECT_DIR=$(pwd)
|
||||
GOOSE_BIN="${PROJECT_DIR}/../../target/debug/goose"
|
||||
export GOOSE_BIN
|
||||
REPO_ROOT=$(cd ../.. && pwd)
|
||||
LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose"
|
||||
LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose"
|
||||
if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then
|
||||
export GOOSE_BIN="${LOCAL_GOOSE_DEBUG}"
|
||||
elif [[ -x "${LOCAL_GOOSE_RELEASE}" ]]; then
|
||||
export GOOSE_BIN="${LOCAL_GOOSE_RELEASE}"
|
||||
else
|
||||
unset GOOSE_BIN
|
||||
fi
|
||||
EXTRA_CONFIG_ARGS=(--config "{\"build\":{\"devUrl\":\"http://localhost:${VITE_PORT}\",\"beforeDevCommand\":{\"script\":\"cd ${PROJECT_DIR} && exec pnpm exec vite --port ${VITE_PORT} --strictPort\",\"cwd\":\".\",\"wait\":false}}}")
|
||||
|
||||
if [[ -n "${GOOSE_BIN:-}" ]]; then
|
||||
echo "Using local goose binary: ${GOOSE_BIN}"
|
||||
else
|
||||
echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH"
|
||||
fi
|
||||
|
||||
# In worktrees, generate a labeled icon so you can tell instances apart
|
||||
if git rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
GIT_DIR=$(git rev-parse --git-dir)
|
||||
|
|
@ -117,14 +131,29 @@ dev-debug:
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VITE_PORT={{ vite_port }}
|
||||
export VITE_PORT
|
||||
# Enable perf logs in the child `goose serve` process by default.
|
||||
# Override with e.g. RUST_LOG=info just dev-debug to disable.
|
||||
export RUST_LOG="${RUST_LOG:-perf=debug,info}"
|
||||
PROJECT_DIR=$(pwd)
|
||||
GOOSE_BIN="${PROJECT_DIR}/../../target/debug/goose"
|
||||
export GOOSE_BIN
|
||||
REPO_ROOT=$(cd ../.. && pwd)
|
||||
LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose"
|
||||
LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose"
|
||||
if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then
|
||||
export GOOSE_BIN="${LOCAL_GOOSE_DEBUG}"
|
||||
elif [[ -x "${LOCAL_GOOSE_RELEASE}" ]]; then
|
||||
export GOOSE_BIN="${LOCAL_GOOSE_RELEASE}"
|
||||
else
|
||||
unset GOOSE_BIN
|
||||
fi
|
||||
EXTRA_CONFIG_ARGS=(--config "{\"build\":{\"devUrl\":\"http://localhost:${VITE_PORT}\",\"beforeDevCommand\":{\"script\":\"exec ./node_modules/.bin/vite --port ${VITE_PORT} --strictPort\",\"cwd\":\"..\",\"wait\":false}}}")
|
||||
|
||||
if [[ -n "${GOOSE_BIN:-}" ]]; then
|
||||
echo "Using local goose binary: ${GOOSE_BIN}"
|
||||
else
|
||||
echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH"
|
||||
fi
|
||||
|
||||
# In worktrees, generate a labeled icon so you can tell instances apart
|
||||
if git rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
GIT_DIR=$(git rev-parse --git-dir)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,17 @@ const EXCEPTIONS = {
|
|||
"src/features/chat/ui/ChatView.tsx": {
|
||||
limit: 570,
|
||||
justification:
|
||||
"ACP prewarm guards, project-aware working dir selection, working context sync, and chat bootstrapping still live together here. Includes gated [perf:chatview] logging via perfLog (dev-only by default).",
|
||||
"ACP prewarm guards, project-aware working dir selection, working context sync, chat bootstrapping, context-ring compaction wiring, and gated [perf:chatview] logging via perfLog (dev-only by default).",
|
||||
},
|
||||
"src/features/chat/hooks/useChat.ts": {
|
||||
limit: 510,
|
||||
justification:
|
||||
"Session preparation, provider/model handoff, persona-aware sends, cancellation, and compaction replay still live in one chat lifecycle hook.",
|
||||
},
|
||||
"src/shared/api/acpNotificationHandler.ts": {
|
||||
limit: 550,
|
||||
justification:
|
||||
"ACP replay/live update handling, pending session buffering, model/config propagation, and streaming perf tracking still share one notification entrypoint.",
|
||||
},
|
||||
"src/features/chat/ui/__tests__/ContextPanel.test.tsx": {
|
||||
limit: 550,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Message } from "@/shared/types/messages";
|
||||
import { useChatStore } from "../../stores/chatStore";
|
||||
import { clearReplayBuffer, ensureReplayBuffer } from "../replayBuffer";
|
||||
|
||||
const mockAcpSendMessage = vi.fn();
|
||||
const mockAcpLoadSession = vi.fn();
|
||||
const mockGetGooseSessionId = vi.fn();
|
||||
|
||||
vi.mock("@/shared/api/acp", () => ({
|
||||
acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args),
|
||||
acpCancelSession: vi.fn(),
|
||||
acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args),
|
||||
acpPrepareSession: vi.fn(),
|
||||
acpSetModel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/api/acpSessionTracker", () => ({
|
||||
getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args),
|
||||
}));
|
||||
|
||||
import { useChat } from "../useChat";
|
||||
|
||||
function createDeferredPromise<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createTextMessage(
|
||||
id: string,
|
||||
role: Message["role"],
|
||||
text: string,
|
||||
): Message {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
created: 0,
|
||||
content: [{ type: "text", text }],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: role !== "system",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useChat compaction", () => {
|
||||
beforeEach(() => {
|
||||
mockAcpSendMessage.mockReset();
|
||||
mockAcpLoadSession.mockReset();
|
||||
mockGetGooseSessionId.mockReset();
|
||||
clearReplayBuffer("session-1");
|
||||
useChatStore.setState({
|
||||
messagesBySession: {},
|
||||
sessionStateById: {},
|
||||
activeSessionId: null,
|
||||
isConnected: true,
|
||||
loadingSessionIds: new Set<string>(),
|
||||
});
|
||||
mockAcpSendMessage.mockResolvedValue(undefined);
|
||||
mockAcpLoadSession.mockResolvedValue(undefined);
|
||||
mockGetGooseSessionId.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("reloads compacted history after sending the compact command", async () => {
|
||||
mockGetGooseSessionId.mockReturnValue("goose-session-1");
|
||||
mockAcpLoadSession.mockImplementation(async (sessionId: string) => {
|
||||
const buffer = ensureReplayBuffer(sessionId);
|
||||
buffer.push(createTextMessage("user-1", "user", "Before compact"));
|
||||
buffer.push(createTextMessage("compact-1", "user", "/compact/compact"));
|
||||
buffer.push(
|
||||
createTextMessage("assistant-1", "assistant", "After compact"),
|
||||
);
|
||||
});
|
||||
|
||||
useChatStore
|
||||
.getState()
|
||||
.setMessages("session-1", [
|
||||
createTextMessage("stale-1", "assistant", "Stale"),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useChat("session-1"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.compactConversation();
|
||||
});
|
||||
|
||||
expect(mockAcpSendMessage).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
"/compact",
|
||||
undefined,
|
||||
);
|
||||
expect(mockAcpLoadSession).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
"goose-session-1",
|
||||
undefined,
|
||||
);
|
||||
|
||||
const messages = useChatStore.getState().messagesBySession["session-1"];
|
||||
const runtime = useChatStore.getState().getSessionRuntime("session-1");
|
||||
|
||||
expect(messages).toEqual([
|
||||
createTextMessage("user-1", "user", "Before compact"),
|
||||
createTextMessage("assistant-1", "assistant", "After compact"),
|
||||
]);
|
||||
expect(runtime.chatState).toBe("idle");
|
||||
expect(runtime.error).toBeNull();
|
||||
expect(useChatStore.getState().loadingSessionIds.has("session-1")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks new sends while compaction is in flight", async () => {
|
||||
mockGetGooseSessionId.mockReturnValue("goose-session-1");
|
||||
const compactDeferred = createDeferredPromise();
|
||||
mockAcpSendMessage.mockImplementation(
|
||||
(_sessionId: string, prompt: string) =>
|
||||
prompt === "/compact" ? compactDeferred.promise : Promise.resolve(),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChat("session-1"));
|
||||
|
||||
let compactPromise!: Promise<void>;
|
||||
await act(async () => {
|
||||
compactPromise = result.current.compactConversation();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
useChatStore.getState().getSessionRuntime("session-1").chatState,
|
||||
).toBe("compacting");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Hello during compact");
|
||||
});
|
||||
|
||||
expect(mockAcpSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockAcpSendMessage).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
"/compact",
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
useChatStore.getState().messagesBySession["session-1"],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
useChatStore.getState().getSessionRuntime("session-1").chatState,
|
||||
).toBe("compacting");
|
||||
|
||||
compactDeferred.resolve();
|
||||
await act(async () => {
|
||||
await compactPromise;
|
||||
});
|
||||
|
||||
expect(
|
||||
useChatStore.getState().getSessionRuntime("session-1").chatState,
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("ignores a second compact request while the first one is still in flight", async () => {
|
||||
mockGetGooseSessionId.mockReturnValue("goose-session-1");
|
||||
const compactDeferred = createDeferredPromise();
|
||||
mockAcpSendMessage.mockImplementation(
|
||||
(_sessionId: string, prompt: string) =>
|
||||
prompt === "/compact" ? compactDeferred.promise : Promise.resolve(),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChat("session-1"));
|
||||
|
||||
let firstCompact!: Promise<void>;
|
||||
let secondCompact!: Promise<void>;
|
||||
await act(async () => {
|
||||
firstCompact = result.current.compactConversation();
|
||||
secondCompact = result.current.compactConversation();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockAcpSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockAcpSendMessage).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
"/compact",
|
||||
undefined,
|
||||
);
|
||||
expect(mockAcpLoadSession).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useChatStore.getState().getSessionRuntime("session-1").chatState,
|
||||
).toBe("compacting");
|
||||
|
||||
compactDeferred.resolve();
|
||||
await act(async () => {
|
||||
await Promise.all([firstCompact, secondCompact]);
|
||||
});
|
||||
|
||||
expect(mockAcpLoadSession).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
useChatStore.getState().getSessionRuntime("session-1").chatState,
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("surfaces an error when compacting before the session is prepared", async () => {
|
||||
const { result } = renderHook(() => useChat("session-1"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.compactConversation();
|
||||
});
|
||||
|
||||
expect(mockAcpSendMessage).not.toHaveBeenCalled();
|
||||
expect(mockAcpLoadSession).not.toHaveBeenCalled();
|
||||
|
||||
const messages = useChatStore.getState().messagesBySession["session-1"];
|
||||
const runtime = useChatStore.getState().getSessionRuntime("session-1");
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].content).toEqual([
|
||||
{
|
||||
type: "systemNotification",
|
||||
notificationType: "error",
|
||||
text: "Session not prepared. Send a message before compacting.",
|
||||
},
|
||||
]);
|
||||
expect(runtime.error).toBe(
|
||||
"Session not prepared. Send a message before compacting.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,19 +4,27 @@ import { useAgentStore } from "@/features/agents/stores/agentStore";
|
|||
import { useChatStore } from "../../stores/chatStore";
|
||||
import { useChatSessionStore } from "../../stores/chatSessionStore";
|
||||
import type { Message } from "@/shared/types/messages";
|
||||
import { clearReplayBuffer } from "../replayBuffer";
|
||||
|
||||
const mockAcpSendMessage = vi.fn();
|
||||
const mockAcpCancelSession = vi.fn();
|
||||
const mockAcpLoadSession = vi.fn();
|
||||
const mockAcpPrepareSession = vi.fn();
|
||||
const mockAcpSetModel = vi.fn();
|
||||
const mockGetGooseSessionId = vi.fn();
|
||||
|
||||
vi.mock("@/shared/api/acp", () => ({
|
||||
acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args),
|
||||
acpCancelSession: (...args: unknown[]) => mockAcpCancelSession(...args),
|
||||
acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args),
|
||||
acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args),
|
||||
acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/api/acpSessionTracker", () => ({
|
||||
getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args),
|
||||
}));
|
||||
|
||||
import { useChat } from "../useChat";
|
||||
|
||||
function addStreamingAssistantMessage(
|
||||
|
|
@ -53,7 +61,14 @@ function createDeferredPromise<T = void>() {
|
|||
|
||||
describe("useChat", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAcpSendMessage.mockReset();
|
||||
mockAcpCancelSession.mockReset();
|
||||
mockAcpLoadSession.mockReset();
|
||||
mockAcpPrepareSession.mockReset();
|
||||
mockAcpSetModel.mockReset();
|
||||
mockGetGooseSessionId.mockReset();
|
||||
clearReplayBuffer("session-1");
|
||||
clearReplayBuffer("session-2");
|
||||
useChatStore.setState({
|
||||
messagesBySession: {},
|
||||
sessionStateById: {},
|
||||
|
|
@ -96,9 +111,12 @@ describe("useChat", () => {
|
|||
personaEditorOpen: false,
|
||||
editingPersona: null,
|
||||
});
|
||||
mockAcpSendMessage.mockResolvedValue(undefined);
|
||||
mockAcpCancelSession.mockResolvedValue(true);
|
||||
mockAcpLoadSession.mockResolvedValue(undefined);
|
||||
mockAcpPrepareSession.mockResolvedValue(undefined);
|
||||
mockAcpSetModel.mockResolvedValue(undefined);
|
||||
mockGetGooseSessionId.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("cancels the active override persona instead of the hook default persona", async () => {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
import { useChatStore } from "../stores/chatStore";
|
||||
import { useChatSessionStore } from "../stores/chatSessionStore";
|
||||
import { clearReplayBuffer, getAndDeleteReplayBuffer } from "./replayBuffer";
|
||||
import {
|
||||
type ChatAttachmentDraft,
|
||||
type Message,
|
||||
createSystemNotificationMessage,
|
||||
createUserMessage,
|
||||
getTextContent,
|
||||
} from "@/shared/types/messages";
|
||||
import type { ChatState, TokenState } from "@/shared/types/chat";
|
||||
import {
|
||||
acpSendMessage,
|
||||
acpCancelSession,
|
||||
acpLoadSession,
|
||||
acpPrepareSession,
|
||||
acpSetModel,
|
||||
} from "@/shared/api/acp";
|
||||
import { getGooseSessionId } from "@/shared/api/acpSessionTracker";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import {
|
||||
getSessionTitleFromDraft,
|
||||
|
|
@ -26,6 +31,26 @@ import {
|
|||
buildMessageAttachments,
|
||||
} from "../lib/attachments";
|
||||
|
||||
// TODO: Remove this fallback once goose2 has first-class /-commands.
|
||||
const MANUAL_COMPACT_TRIGGER = "/compact";
|
||||
|
||||
function isManualCompactCommandMessage(message: Message): boolean {
|
||||
if (message.role !== "user") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedText = getTextContent(message).replace(/\s+/g, "");
|
||||
if (!normalizedText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedText.replaceAll(MANUAL_COMPACT_TRIGGER, "").length === 0;
|
||||
}
|
||||
|
||||
function removeManualCompactCommandMessages(messages: Message[]): Message[] {
|
||||
return messages.filter((message) => !isManualCompactCommandMessage(message));
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
// Tauri command rejections typically arrive as plain strings, so handle
|
||||
// that shape first before falling back to standard Error objects.
|
||||
|
|
@ -134,10 +159,14 @@ export function useChat(
|
|||
const tSendStart = performance.now();
|
||||
const images = buildAcpImages(attachments);
|
||||
const hasAttachments = (attachments?.length ?? 0) > 0;
|
||||
const currentChatState = useChatStore
|
||||
.getState()
|
||||
.getSessionRuntime(sessionId).chatState;
|
||||
if (
|
||||
(!text.trim() && !hasAttachments) ||
|
||||
chatState === "streaming" ||
|
||||
chatState === "thinking"
|
||||
currentChatState === "streaming" ||
|
||||
currentChatState === "thinking" ||
|
||||
currentChatState === "compacting"
|
||||
)
|
||||
return;
|
||||
perfLog(
|
||||
|
|
@ -318,7 +347,6 @@ export function useChat(
|
|||
},
|
||||
[
|
||||
sessionId,
|
||||
chatState,
|
||||
store,
|
||||
providerOverride,
|
||||
systemPromptOverride,
|
||||
|
|
@ -387,6 +415,78 @@ export function useChat(
|
|||
store.setPendingAssistantProvider(sessionId, null);
|
||||
}, [sessionId, store]);
|
||||
|
||||
const compactConversation = useCallback(async () => {
|
||||
const currentChatState = useChatStore
|
||||
.getState()
|
||||
.getSessionRuntime(sessionId).chatState;
|
||||
if (currentChatState !== "idle") {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectivePersonaInfo = resolvePersonaInfo();
|
||||
const gooseSessionId = getGooseSessionId(
|
||||
sessionId,
|
||||
effectivePersonaInfo?.id,
|
||||
);
|
||||
|
||||
if (!gooseSessionId) {
|
||||
const errorMessage =
|
||||
"Session not prepared. Send a message before compacting.";
|
||||
store.addMessage(
|
||||
sessionId,
|
||||
createSystemNotificationMessage(errorMessage, "error"),
|
||||
);
|
||||
store.setError(sessionId, errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
store.setActiveSession(sessionId);
|
||||
store.setChatState(sessionId, "compacting");
|
||||
store.setStreamingMessageId(sessionId, null);
|
||||
store.setError(sessionId, null);
|
||||
store.setSessionLoading(sessionId, true);
|
||||
clearReplayBuffer(sessionId);
|
||||
|
||||
try {
|
||||
const sendOptions = effectivePersonaInfo?.id
|
||||
? { personaId: effectivePersonaInfo.id }
|
||||
: undefined;
|
||||
await acpSendMessage(sessionId, MANUAL_COMPACT_TRIGGER, sendOptions);
|
||||
|
||||
// Command responses are streamed via prompt notifications, but the ACP
|
||||
// layer does not currently forward history replacement events. Drop those
|
||||
// transient chunks and refresh the session from replay instead.
|
||||
clearReplayBuffer(sessionId);
|
||||
const workingDir = await getWorkingDir?.();
|
||||
await acpLoadSession(sessionId, gooseSessionId, workingDir);
|
||||
|
||||
store.setSessionLoading(sessionId, false);
|
||||
|
||||
const buffer = getAndDeleteReplayBuffer(sessionId);
|
||||
if (buffer) {
|
||||
store.setMessages(
|
||||
sessionId,
|
||||
removeManualCompactCommandMessages(buffer),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
clearReplayBuffer(sessionId);
|
||||
store.setSessionLoading(sessionId, false);
|
||||
|
||||
const errorMessage = getErrorMessage(err);
|
||||
store.addMessage(
|
||||
sessionId,
|
||||
createSystemNotificationMessage(errorMessage, "error"),
|
||||
);
|
||||
store.setError(sessionId, errorMessage);
|
||||
} finally {
|
||||
store.setChatState(sessionId, "idle");
|
||||
store.setStreamingMessageId(sessionId, null);
|
||||
store.setPendingAssistantProvider(sessionId, null);
|
||||
store.setSessionLoading(sessionId, false);
|
||||
}
|
||||
}, [getWorkingDir, resolvePersonaInfo, sessionId, store]);
|
||||
|
||||
const stopStreaming = stopGeneration;
|
||||
|
||||
return {
|
||||
|
|
@ -400,6 +500,7 @@ export function useChat(
|
|||
stopStreaming,
|
||||
retryLastMessage,
|
||||
clearChat,
|
||||
compactConversation,
|
||||
isStreaming,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ interface ChatInputProps {
|
|||
}) => void;
|
||||
contextTokens?: number;
|
||||
contextLimit?: number;
|
||||
onCompactContext?: () => void | Promise<void>;
|
||||
canCompactContext?: boolean;
|
||||
isCompactingContext?: boolean;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
|
|
@ -94,6 +97,9 @@ export function ChatInput({
|
|||
onCreateProject,
|
||||
contextTokens = 0,
|
||||
contextLimit = 0,
|
||||
onCompactContext,
|
||||
canCompactContext = false,
|
||||
isCompactingContext = false,
|
||||
}: ChatInputProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const [text, setTextRaw] = useState(initialValue);
|
||||
|
|
@ -429,6 +435,9 @@ export function ChatInput({
|
|||
onCreateProject={onCreateProject}
|
||||
contextTokens={contextTokens}
|
||||
contextLimit={contextLimit}
|
||||
onCompactContext={onCompactContext}
|
||||
canCompactContext={canCompactContext}
|
||||
isCompactingContext={isCompactingContext}
|
||||
canSend={canSend}
|
||||
isStreaming={isStreaming}
|
||||
hasQueuedMessage={hasQueuedMessage}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Mic,
|
||||
ArrowUp,
|
||||
|
|
@ -24,6 +24,8 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Progress } from "@/shared/ui/progress";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip";
|
||||
import { AgentModelPicker } from "./AgentModelPicker";
|
||||
import type { ModelOption } from "../types";
|
||||
|
|
@ -77,6 +79,9 @@ interface ChatInputToolbarProps {
|
|||
contextTokens: number;
|
||||
contextLimit: number;
|
||||
// Actions
|
||||
canCompactContext?: boolean;
|
||||
isCompactingContext?: boolean;
|
||||
onCompactContext?: () => void | Promise<void>;
|
||||
canSend: boolean;
|
||||
isStreaming: boolean;
|
||||
hasQueuedMessage: boolean;
|
||||
|
|
@ -108,6 +113,9 @@ export function ChatInputToolbar({
|
|||
onCreateProject,
|
||||
contextTokens,
|
||||
contextLimit,
|
||||
canCompactContext = false,
|
||||
isCompactingContext = false,
|
||||
onCompactContext,
|
||||
canSend,
|
||||
isStreaming,
|
||||
hasQueuedMessage,
|
||||
|
|
@ -121,6 +129,7 @@ export function ChatInputToolbar({
|
|||
const { t } = useTranslation("chat");
|
||||
const { formatNumber } = useLocaleFormatting();
|
||||
const { readyAgentIds } = useAgentProviderStatus();
|
||||
const [isContextPopoverOpen, setIsContextPopoverOpen] = useState(false);
|
||||
|
||||
const agentProviders = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -156,6 +165,21 @@ export function ChatInputToolbar({
|
|||
const projectTitle = selectedProject?.workingDirs.length
|
||||
? selectedProject.workingDirs.join(", ")
|
||||
: undefined;
|
||||
const contextProgress =
|
||||
contextLimit > 0 ? Math.min(contextTokens / contextLimit, 1) : 0;
|
||||
const contextPercentDigits =
|
||||
contextProgress > 0 && contextProgress < 0.1 ? 1 : 0;
|
||||
const usedPercentLabel = formatNumber(contextProgress, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: contextPercentDigits,
|
||||
maximumFractionDigits: contextPercentDigits,
|
||||
});
|
||||
const formatCompactTokenCount = (value: number) =>
|
||||
formatNumber(value, {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: value < 10_000 ? 1 : 0,
|
||||
});
|
||||
|
||||
const handleProjectValueChange = (value: string) => {
|
||||
if (value === CREATE_PROJECT_VALUE) {
|
||||
|
|
@ -166,6 +190,15 @@ export function ChatInputToolbar({
|
|||
onProjectChange?.(value === NO_PROJECT_VALUE ? null : value);
|
||||
};
|
||||
|
||||
const handleCompactContext = () => {
|
||||
if (!canCompactContext || isCompactingContext || !onCompactContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsContextPopoverOpen(false);
|
||||
void onCompactContext();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Left side: pickers */}
|
||||
|
|
@ -247,19 +280,70 @@ export function ChatInputToolbar({
|
|||
)}
|
||||
|
||||
{contextLimit > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={t("toolbar.contextUsage")}
|
||||
title={t("toolbar.contextUsageTitle", {
|
||||
tokens: formatNumber(contextTokens),
|
||||
limit: formatNumber(contextLimit),
|
||||
})}
|
||||
<Popover
|
||||
open={isContextPopoverOpen}
|
||||
onOpenChange={setIsContextPopoverOpen}
|
||||
>
|
||||
<ContextRing tokens={contextTokens} limit={contextLimit} />
|
||||
</Button>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={isCompact ? "icon-sm" : "sm"}
|
||||
className={cn(
|
||||
"group rounded-full bg-transparent text-foreground/80 shadow-none hover:bg-transparent hover:text-foreground data-[state=open]:bg-transparent data-[state=open]:text-foreground",
|
||||
isCompact ? "px-0" : "px-2.5",
|
||||
)}
|
||||
aria-label={t("toolbar.contextUsage")}
|
||||
title={t("toolbar.contextUsageTitle", {
|
||||
tokens: formatNumber(contextTokens),
|
||||
limit: formatNumber(contextLimit),
|
||||
})}
|
||||
>
|
||||
<ContextRing
|
||||
tokens={contextTokens}
|
||||
limit={contextLimit}
|
||||
size={18}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-52 rounded-2xl p-1 text-left"
|
||||
>
|
||||
<div className="px-2 py-1.5 text-sm font-semibold text-foreground">
|
||||
{t("toolbar.contextWindow")}
|
||||
</div>
|
||||
<div className="space-y-2 px-2 pb-1.5">
|
||||
<Progress
|
||||
className="h-1.5 bg-muted"
|
||||
value={contextProgress * 100}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-foreground">
|
||||
<div className="truncate">
|
||||
{t("toolbar.contextTokensBreakdown", {
|
||||
tokens: formatCompactTokenCount(contextTokens),
|
||||
limit: formatCompactTokenCount(contextLimit),
|
||||
})}
|
||||
</div>
|
||||
<div className="shrink-0">{usedPercentLabel}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="w-full justify-center"
|
||||
onClick={handleCompactContext}
|
||||
disabled={!canCompactContext || isCompactingContext}
|
||||
>
|
||||
{isCompactingContext
|
||||
? t("toolbar.compacting")
|
||||
: t("toolbar.compactNow")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ export function ChatView({
|
|||
const { t } = useTranslation("chat");
|
||||
const activeSessionId = sessionId;
|
||||
const mountStart = useRef(performance.now());
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: log once on mount per session
|
||||
useEffect(() => {
|
||||
const ms = (performance.now() - mountStart.current).toFixed(1);
|
||||
perfLog(`[perf:chatview] ${sessionId.slice(0, 8)} mounted in ${ms}ms`);
|
||||
|
|
@ -363,6 +362,7 @@ export function ChatView({
|
|||
chatState,
|
||||
tokenState,
|
||||
sendMessage,
|
||||
compactConversation,
|
||||
stopStreaming,
|
||||
streamingMessageId,
|
||||
} = useChat(
|
||||
|
|
@ -453,6 +453,7 @@ export function ChatView({
|
|||
onInitialMessageConsumed,
|
||||
]);
|
||||
const isStreaming = chatState === "streaming";
|
||||
const isCompacting = chatState === "compacting";
|
||||
const showIndicator =
|
||||
chatState === "thinking" ||
|
||||
chatState === "streaming" ||
|
||||
|
|
@ -544,6 +545,13 @@ export function ChatView({
|
|||
}
|
||||
contextTokens={tokenState.accumulatedTotal}
|
||||
contextLimit={tokenState.contextLimit}
|
||||
onCompactContext={compactConversation}
|
||||
canCompactContext={
|
||||
chatState === "idle" &&
|
||||
tokenState.accumulatedTotal > 0 &&
|
||||
!projectMetadataPending
|
||||
}
|
||||
isCompactingContext={isCompacting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -22,14 +22,6 @@ export function ContextRing({
|
|||
const offset = circumference - progress * circumference;
|
||||
const percent = formatNumber(Math.round(progress * 100));
|
||||
|
||||
// Color based on usage
|
||||
const strokeColor =
|
||||
progress > 0.9
|
||||
? "var(--text-danger)"
|
||||
: progress > 0.7
|
||||
? "var(--text-warning)"
|
||||
: "var(--text-muted)";
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
|
|
@ -38,29 +30,26 @@ export function ContextRing({
|
|||
className="shrink-0"
|
||||
aria-label={t("context.ringAria", { percent })}
|
||||
>
|
||||
{/* Background track */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
className="text-muted-foreground/30"
|
||||
stroke="var(--color-border)"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
{/* Progress arc */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
stroke="var(--color-foreground)"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap={progress > 0 ? "round" : "butt"}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
className="transition-all duration-300"
|
||||
className="transition-all duration-300 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ const LOADING_SHIMMER_REPEAT_DELAY_S = 0.9;
|
|||
|
||||
const MESSAGE_KEY_BY_STATE: Record<
|
||||
Exclude<LoadingChatState, "idle">,
|
||||
"thinking" | "responding"
|
||||
"thinking" | "responding" | "compacting"
|
||||
> = {
|
||||
thinking: "thinking",
|
||||
streaming: "responding",
|
||||
waiting: "responding",
|
||||
compacting: "responding",
|
||||
compacting: "compacting",
|
||||
};
|
||||
|
||||
export function LoadingGoose({ chatState = "idle" }: LoadingGooseProps) {
|
||||
|
|
|
|||
|
|
@ -223,6 +223,49 @@ describe("ChatInput", () => {
|
|||
expect(screen.getByText("goose2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a context usage popover when token tracking is available", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ChatInput onSend={vi.fn()} contextTokens={1536} contextLimit={8192} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /context usage/i }));
|
||||
|
||||
expect(screen.getByText("Context window")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.5K / 8.2K tokens used")).toBeInTheDocument();
|
||||
expect(screen.getByText("19%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("runs compaction from the context usage popover", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCompactContext = vi.fn();
|
||||
|
||||
render(
|
||||
<ChatInput
|
||||
onSend={vi.fn()}
|
||||
contextTokens={1536}
|
||||
contextLimit={8192}
|
||||
canCompactContext
|
||||
onCompactContext={onCompactContext}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /context usage/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Compact" }));
|
||||
|
||||
expect(onCompactContext).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("hides the context usage control when the context limit is unavailable", () => {
|
||||
render(
|
||||
<ChatInput onSend={vi.fn()} contextTokens={1536} contextLimit={0} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /context usage/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows stop button when streaming", () => {
|
||||
render(<ChatInput onSend={vi.fn()} onStop={vi.fn()} isStreaming />);
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { LoadingGoose } from "../LoadingGoose";
|
||||
import chat from "@/shared/i18n/locales/en/chat.json";
|
||||
|
||||
const { thinking, responding } = chat.loading;
|
||||
const { thinking, responding, compacting } = chat.loading;
|
||||
|
||||
describe("LoadingGoose", () => {
|
||||
it("renders thinking copy for the thinking state", () => {
|
||||
|
|
@ -23,10 +23,13 @@ describe("LoadingGoose", () => {
|
|||
expect(
|
||||
screen.getByRole("status", { name: responding }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders compacting copy for the compacting state", () => {
|
||||
render(<LoadingGoose chatState="compacting" />);
|
||||
|
||||
rerender(<LoadingGoose chatState="compacting" />);
|
||||
expect(
|
||||
screen.getByRole("status", { name: responding }),
|
||||
screen.getByRole("status", { name: compacting }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
57
ui/goose2/src/shared/api/__tests__/acp.test.ts
Normal file
57
ui/goose2/src/shared/api/__tests__/acp.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockLoadSession = vi.fn();
|
||||
|
||||
vi.mock("../acpApi", () => ({
|
||||
listProviders: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
setModel: vi.fn(),
|
||||
listSessions: vi.fn(),
|
||||
loadSession: (...args: unknown[]) => mockLoadSession(...args),
|
||||
exportSession: vi.fn(),
|
||||
importSession: vi.fn(),
|
||||
forkSession: vi.fn(),
|
||||
cancelSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../acpNotificationHandler", () => ({
|
||||
setActiveMessageId: vi.fn(),
|
||||
clearActiveMessageId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../sessionSearch", () => ({
|
||||
searchSessionsViaExports: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("acpLoadSession", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("restores the prior session mapping when replay loading fails", async () => {
|
||||
mockLoadSession.mockRejectedValueOnce(new Error("load failed"));
|
||||
|
||||
const sessionTracker = await import("../acpSessionTracker");
|
||||
const { acpLoadSession } = await import("../acp");
|
||||
|
||||
sessionTracker.registerSession(
|
||||
"local-session",
|
||||
"goose-session-1",
|
||||
"goose",
|
||||
"/tmp/original",
|
||||
);
|
||||
|
||||
await expect(
|
||||
acpLoadSession("local-session", "goose-session-2", "/tmp/replay"),
|
||||
).rejects.toThrow("load failed");
|
||||
|
||||
expect(sessionTracker.getGooseSessionId("local-session")).toBe(
|
||||
"goose-session-1",
|
||||
);
|
||||
expect(sessionTracker.getLocalSessionId("goose-session-1")).toBe(
|
||||
"local-session",
|
||||
);
|
||||
expect(sessionTracker.getLocalSessionId("goose-session-2")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -146,17 +146,22 @@ export async function acpLoadSession(
|
|||
const effectiveWorkingDir = workingDir ?? "~/.goose/artifacts";
|
||||
const sid = sessionId.slice(0, 8);
|
||||
const t0 = performance.now();
|
||||
perfLog(`[perf:load] ${sid} acpLoadSession → client.loadSession`);
|
||||
await directAcp.loadSession(gooseSessionId, effectiveWorkingDir);
|
||||
perfLog(
|
||||
`[perf:load] ${sid} client.loadSession resolved in ${(performance.now() - t0).toFixed(1)}ms`,
|
||||
);
|
||||
sessionTracker.registerSession(
|
||||
const rollbackSessionRegistration = sessionTracker.registerSession(
|
||||
sessionId,
|
||||
gooseSessionId,
|
||||
"goose",
|
||||
effectiveWorkingDir,
|
||||
);
|
||||
try {
|
||||
perfLog(`[perf:load] ${sid} acpLoadSession → client.loadSession`);
|
||||
await directAcp.loadSession(gooseSessionId, effectiveWorkingDir);
|
||||
perfLog(
|
||||
`[perf:load] ${sid} client.loadSession resolved in ${(performance.now() - t0).toFixed(1)}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
rollbackSessionRegistration();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Export a session as JSON via the goose binary. */
|
||||
|
|
|
|||
|
|
@ -132,11 +132,23 @@ export async function cancelSession(sessionId: string): Promise<void> {
|
|||
|
||||
export async function newSession(
|
||||
workingDir: string,
|
||||
providerId?: string,
|
||||
): Promise<NewSessionResponse> {
|
||||
const tClient = performance.now();
|
||||
const client = await getClient();
|
||||
const request: Parameters<typeof client.newSession>[0] & {
|
||||
meta?: Record<string, string>;
|
||||
} = {
|
||||
cwd: workingDir,
|
||||
mcpServers: [],
|
||||
};
|
||||
|
||||
if (providerId) {
|
||||
request.meta = { provider: providerId };
|
||||
}
|
||||
|
||||
const tCall = performance.now();
|
||||
const response = await client.newSession({ cwd: workingDir, mcpServers: [] });
|
||||
const response = await client.newSession(request);
|
||||
const sid = response.sessionId.slice(0, 8);
|
||||
perfLog(
|
||||
`[perf:api] ${sid} newSession getClient=${(tCall - tClient).toFixed(1)}ms wire=${(performance.now() - tCall).toFixed(1)}ms`,
|
||||
|
|
|
|||
76
ui/goose2/src/shared/api/acpNotificationHandler.test.ts
Normal file
76
ui/goose2/src/shared/api/acpNotificationHandler.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { useChatStore } from "@/features/chat/stores/chatStore";
|
||||
import {
|
||||
clearReplayBuffer,
|
||||
getAndDeleteReplayBuffer,
|
||||
} from "@/features/chat/hooks/replayBuffer";
|
||||
import { registerSession } from "./acpSessionTracker";
|
||||
import { handleSessionNotification } from "./acpNotificationHandler";
|
||||
|
||||
describe("acpNotificationHandler", () => {
|
||||
beforeEach(() => {
|
||||
clearReplayBuffer("draft-session-1");
|
||||
clearReplayBuffer("draft-session-2");
|
||||
useChatStore.setState({
|
||||
messagesBySession: {},
|
||||
sessionStateById: {},
|
||||
queuedMessageBySession: {},
|
||||
draftsBySession: {},
|
||||
activeSessionId: null,
|
||||
isConnected: false,
|
||||
loadingSessionIds: new Set<string>(),
|
||||
scrollTargetMessageBySession: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("buffers usage updates until the local session mapping is registered", async () => {
|
||||
const notification = {
|
||||
sessionId: "goose-session-1",
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 512,
|
||||
size: 8192,
|
||||
},
|
||||
} as SessionNotification;
|
||||
|
||||
await handleSessionNotification(notification);
|
||||
|
||||
expect(
|
||||
useChatStore.getState().sessionStateById["draft-session-1"],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
useChatStore.getState().sessionStateById["goose-session-1"],
|
||||
).toBeUndefined();
|
||||
|
||||
registerSession("draft-session-1", "goose-session-1", "goose", "/tmp");
|
||||
|
||||
const runtime = useChatStore
|
||||
.getState()
|
||||
.getSessionRuntime("draft-session-1");
|
||||
expect(runtime.tokenState.accumulatedTotal).toBe(512);
|
||||
expect(runtime.tokenState.contextLimit).toBe(8192);
|
||||
});
|
||||
|
||||
it("does not buffer non-usage updates before the local session mapping exists", async () => {
|
||||
const notification = {
|
||||
sessionId: "goose-session-2",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "message-1",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "hello from replay",
|
||||
},
|
||||
},
|
||||
} as SessionNotification;
|
||||
|
||||
await handleSessionNotification(notification);
|
||||
registerSession("draft-session-2", "goose-session-2", "goose", "/tmp");
|
||||
|
||||
expect(getAndDeleteReplayBuffer("draft-session-2")).toBeUndefined();
|
||||
expect(
|
||||
useChatStore.getState().messagesBySession["draft-session-2"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -14,11 +14,55 @@ import type {
|
|||
ToolResponseContent,
|
||||
} from "@/shared/types/messages";
|
||||
import type { AcpNotificationHandler } from "./acpConnection";
|
||||
import { getLocalSessionId } from "./acpSessionTracker";
|
||||
import {
|
||||
getLocalSessionId,
|
||||
subscribeToSessionRegistration,
|
||||
} from "./acpSessionTracker";
|
||||
import { perfLog } from "@/shared/lib/perfLog";
|
||||
|
||||
// Pre-set message ID for the next live stream per goose session
|
||||
const presetMessageIds = new Map<string, string>();
|
||||
const pendingUsageUpdates = new Map<string, SessionUpdate[]>();
|
||||
|
||||
function shouldBufferPendingUpdate(update: SessionUpdate): boolean {
|
||||
return update.sessionUpdate === "usage_update";
|
||||
}
|
||||
|
||||
function queuePendingUsageUpdate(
|
||||
gooseSessionId: string,
|
||||
update: SessionUpdate,
|
||||
): void {
|
||||
const pending = pendingUsageUpdates.get(gooseSessionId);
|
||||
if (pending) {
|
||||
pending.push(update);
|
||||
return;
|
||||
}
|
||||
pendingUsageUpdates.set(gooseSessionId, [update]);
|
||||
}
|
||||
|
||||
function flushPendingUsageUpdates(
|
||||
localSessionId: string,
|
||||
gooseSessionId: string,
|
||||
): void {
|
||||
const pending = pendingUsageUpdates.get(gooseSessionId);
|
||||
if (!pending?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingUsageUpdates.delete(gooseSessionId);
|
||||
|
||||
for (const update of pending) {
|
||||
if (useChatStore.getState().loadingSessionIds.has(localSessionId)) {
|
||||
handleReplay(localSessionId, update);
|
||||
} else {
|
||||
handleLive(localSessionId, gooseSessionId, update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToSessionRegistration((localSessionId, gooseSessionId) => {
|
||||
flushPendingUsageUpdates(localSessionId, gooseSessionId);
|
||||
});
|
||||
|
||||
// Per-session perf counters for replay/live streaming.
|
||||
interface ReplayPerf {
|
||||
|
|
@ -67,22 +111,32 @@ export async function handleSessionNotification(
|
|||
notification: SessionNotification,
|
||||
): Promise<void> {
|
||||
const gooseSessionId = notification.sessionId;
|
||||
const sessionId = getLocalSessionId(gooseSessionId) ?? gooseSessionId;
|
||||
const { update } = notification;
|
||||
const isReplay = useChatStore.getState().loadingSessionIds.has(sessionId);
|
||||
const localSessionId = getLocalSessionId(gooseSessionId);
|
||||
|
||||
if (!localSessionId) {
|
||||
if (shouldBufferPendingUpdate(update)) {
|
||||
queuePendingUsageUpdate(gooseSessionId, update);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isReplay = useChatStore
|
||||
.getState()
|
||||
.loadingSessionIds.has(localSessionId);
|
||||
|
||||
if (isReplay) {
|
||||
const sid = sessionId.slice(0, 8);
|
||||
let perf = replayPerf.get(sessionId);
|
||||
const sid = localSessionId.slice(0, 8);
|
||||
let perf = replayPerf.get(localSessionId);
|
||||
const now = performance.now();
|
||||
if (!perf) {
|
||||
perf = { firstAt: now, lastAt: now, count: 0 };
|
||||
replayPerf.set(sessionId, perf);
|
||||
replayPerf.set(localSessionId, perf);
|
||||
perfLog(`[perf:replay] ${sid} first notification received`);
|
||||
}
|
||||
perf.lastAt = now;
|
||||
perf.count += 1;
|
||||
handleReplay(sessionId, update);
|
||||
handleReplay(localSessionId, update);
|
||||
} else {
|
||||
const perf = livePerf.get(gooseSessionId);
|
||||
if (perf && update.sessionUpdate === "agent_message_chunk") {
|
||||
|
|
@ -95,7 +149,7 @@ export async function handleSessionNotification(
|
|||
);
|
||||
}
|
||||
}
|
||||
handleLive(sessionId, gooseSessionId, update);
|
||||
handleLive(localSessionId, gooseSessionId, update);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,26 @@ interface PreparedSession {
|
|||
workingDir: string;
|
||||
}
|
||||
|
||||
type SessionRegistrationListener = (
|
||||
localSessionId: string,
|
||||
gooseSessionId: string,
|
||||
) => void;
|
||||
|
||||
const prepared = new Map<string, PreparedSession>();
|
||||
const gooseToLocal = new Map<string, string>();
|
||||
const registrationListeners = new Set<SessionRegistrationListener>();
|
||||
|
||||
function restoreGooseRegistration(
|
||||
gooseSessionId: string,
|
||||
localSessionId: string | undefined,
|
||||
): void {
|
||||
if (localSessionId === undefined) {
|
||||
gooseToLocal.delete(gooseSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
gooseToLocal.set(gooseSessionId, localSessionId);
|
||||
}
|
||||
|
||||
function makeKey(sessionId: string, personaId?: string): string {
|
||||
if (personaId && personaId.length > 0) {
|
||||
|
|
@ -17,6 +35,22 @@ function makeKey(sessionId: string, personaId?: string): string {
|
|||
return sessionId;
|
||||
}
|
||||
|
||||
function notifySessionRegistered(
|
||||
localSessionId: string,
|
||||
gooseSessionId: string,
|
||||
): void {
|
||||
for (const listener of registrationListeners) {
|
||||
listener(localSessionId, gooseSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeToSessionRegistration(
|
||||
listener: SessionRegistrationListener,
|
||||
): () => void {
|
||||
registrationListeners.add(listener);
|
||||
return () => registrationListeners.delete(listener);
|
||||
}
|
||||
|
||||
export async function prepareSession(
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
|
|
@ -67,7 +101,7 @@ export async function prepareSession(
|
|||
|
||||
if (!gooseSessionId) {
|
||||
const tNew = performance.now();
|
||||
const response = await acpApi.newSession(workingDir);
|
||||
const response = await acpApi.newSession(workingDir, providerId);
|
||||
gooseSessionId = response.sessionId;
|
||||
perfLog(
|
||||
`[perf:prepare] ${sid} tracker newSession done in ${(performance.now() - tNew).toFixed(1)}ms (goose_sid=${gooseSessionId.slice(0, 8)})`,
|
||||
|
|
@ -84,6 +118,7 @@ export async function prepareSession(
|
|||
prepared.set(key, { gooseSessionId, providerId, workingDir });
|
||||
prepared.set(sessionId, { gooseSessionId, providerId, workingDir });
|
||||
gooseToLocal.set(gooseSessionId, sessionId);
|
||||
notifySessionRegistered(sessionId, gooseSessionId);
|
||||
|
||||
return gooseSessionId;
|
||||
}
|
||||
|
|
@ -109,8 +144,54 @@ export function registerSession(
|
|||
gooseSessionId: string,
|
||||
providerId: string,
|
||||
workingDir: string,
|
||||
): void {
|
||||
): () => void {
|
||||
const previousEntry = prepared.get(sessionId);
|
||||
const previousGooseSessionLocal = gooseToLocal.get(gooseSessionId);
|
||||
const previousSessionGooseLocal = previousEntry
|
||||
? gooseToLocal.get(previousEntry.gooseSessionId)
|
||||
: undefined;
|
||||
const entry = { gooseSessionId, providerId, workingDir };
|
||||
|
||||
if (
|
||||
previousEntry &&
|
||||
previousEntry.gooseSessionId !== gooseSessionId &&
|
||||
gooseToLocal.get(previousEntry.gooseSessionId) === sessionId
|
||||
) {
|
||||
gooseToLocal.delete(previousEntry.gooseSessionId);
|
||||
}
|
||||
|
||||
prepared.set(sessionId, entry);
|
||||
gooseToLocal.set(gooseSessionId, sessionId);
|
||||
notifySessionRegistered(sessionId, gooseSessionId);
|
||||
|
||||
return () => {
|
||||
prepared.delete(sessionId);
|
||||
if (previousEntry) {
|
||||
prepared.set(sessionId, previousEntry);
|
||||
}
|
||||
|
||||
restoreGooseRegistration(gooseSessionId, previousGooseSessionLocal);
|
||||
if (previousEntry && previousEntry.gooseSessionId !== gooseSessionId) {
|
||||
restoreGooseRegistration(
|
||||
previousEntry.gooseSessionId,
|
||||
previousSessionGooseLocal,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function unregisterSession(
|
||||
sessionId: string,
|
||||
gooseSessionId?: string,
|
||||
): void {
|
||||
const entry = prepared.get(sessionId);
|
||||
prepared.delete(sessionId);
|
||||
|
||||
const resolvedGooseSessionId = gooseSessionId ?? entry?.gooseSessionId;
|
||||
if (
|
||||
resolvedGooseSessionId &&
|
||||
gooseToLocal.get(resolvedGooseSessionId) === sessionId
|
||||
) {
|
||||
gooseToLocal.delete(resolvedGooseSessionId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@
|
|||
"placeholder": "Message {{agent}}, @ to mention personas"
|
||||
},
|
||||
"loading": {
|
||||
"compacting": "Compacting conversation...",
|
||||
"thinking": "Thinking...",
|
||||
"responding": "Responding..."
|
||||
},
|
||||
|
|
@ -151,9 +152,14 @@
|
|||
"attachFolder": "Folder",
|
||||
"chooseAgentModel": "Choose agent and model",
|
||||
"chooseProject": "Choose a project",
|
||||
"compactNow": "Compact",
|
||||
"compacting": "Compacting...",
|
||||
"chooseProvider": "Choose a provider",
|
||||
"contextTokensBreakdown": "{{tokens}} / {{limit}} tokens used",
|
||||
"contextUsage": "Context usage",
|
||||
"contextUsageBreakdown": "{{used}} used ({{left}} left)",
|
||||
"contextUsageTitle": "{{tokens}} / {{limit}} tokens",
|
||||
"contextWindow": "Context window",
|
||||
"createProject": "Create project",
|
||||
"generalChatWithoutProject": "General chat without project context",
|
||||
"loading": "Loading...",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@
|
|||
"placeholder": "Enviar mensaje a {{agent}}, usa @ para mencionar personas"
|
||||
},
|
||||
"loading": {
|
||||
"compacting": "Compactando conversación...",
|
||||
"thinking": "Pensando...",
|
||||
"responding": "Respondiendo..."
|
||||
},
|
||||
|
|
@ -151,9 +152,14 @@
|
|||
"attachFolder": "Carpeta",
|
||||
"chooseAgentModel": "Elegir agente y modelo",
|
||||
"chooseProject": "Elegir un proyecto",
|
||||
"compactNow": "Compactar",
|
||||
"compacting": "Compactando...",
|
||||
"chooseProvider": "Elegir un proveedor",
|
||||
"contextTokensBreakdown": "{{tokens}} / {{limit}} tokens usados",
|
||||
"contextUsage": "Uso del contexto",
|
||||
"contextUsageBreakdown": "{{used}} en uso ({{left}} libres)",
|
||||
"contextUsageTitle": "{{tokens}} / {{limit}} tokens",
|
||||
"contextWindow": "Ventana de contexto",
|
||||
"createProject": "Crear proyecto",
|
||||
"generalChatWithoutProject": "Chat general sin contexto de proyecto",
|
||||
"loading": "Cargando...",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const buttonVariants = cva(
|
|||
"outline-flat":
|
||||
"border border-input bg-background shadow-none hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
"ghost-light":
|
||||
"font-normal hover:bg-accent hover:text-accent-foreground",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue