Compare commits

...

2 Commits

Author SHA1 Message Date
13038989b8 Fix spurious container snapshot on every start for projects with env vars
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 4m7s
Build App / build-linux (push) Successful in 4m58s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Migrate env-var-based checks in container_needs_recreation() to label-based
checks. When a container snapshot is committed, Docker merges the image's env
vars with the container's, causing get_env() to return stale values and
triggering an infinite snapshot→recreate loop. Labels are immutable after
creation and immune to this merging behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:17:00 -07:00
b55de8d75e Fix Jump to Current button not appearing on scroll-up
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 2m35s
Build App / build-linux (push) Successful in 5m9s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 12s
The onScroll RAF optimization (only fire when atBottom changes) prevented
the button from showing because xterm's onScroll may not fire from wheel
events. Fix by setting isAtBottom(false) directly in the wheel handler
and removing the RAF guard to always schedule state updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 10:28:28 -07:00
2 changed files with 34 additions and 37 deletions

View File

@@ -704,6 +704,13 @@ pub async fn create_container(
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string()); labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers)); labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string()); labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
labels.insert("triple-c.instructions-fingerprint".to_string(),
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
labels.insert("triple-c.git-user-name".to_string(), project.git_user_name.clone().unwrap_or_default());
labels.insert("triple-c.git-user-email".to_string(), project.git_user_email.clone().unwrap_or_default());
labels.insert("triple-c.git-token-hash".to_string(),
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
let host_config = HostConfig { let host_config = HostConfig {
mounts: Some(mounts), mounts: Some(mounts),
@@ -1000,41 +1007,32 @@ pub async fn container_needs_recreation(
return Ok(true); return Ok(true);
} }
// ── Git environment variables ──────────────────────────────────────── // ── Git settings (label-based to avoid stale snapshot env vars) ─────
let env_vars = info let expected_git_name = project.git_user_name.clone().unwrap_or_default();
.config let container_git_name = get_label("triple-c.git-user-name").unwrap_or_default();
.as_ref() if container_git_name != expected_git_name {
.and_then(|c| c.env.as_ref()); log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, expected_git_name);
let get_env = |name: &str| -> Option<String> {
env_vars.and_then(|vars| {
vars.iter()
.find(|v| v.starts_with(&format!("{}=", name)))
.map(|v| v[name.len() + 1..].to_string())
})
};
let container_git_name = get_env("GIT_USER_NAME");
let container_git_email = get_env("GIT_USER_EMAIL");
let container_git_token = get_env("GIT_TOKEN");
if container_git_name.as_deref() != project.git_user_name.as_deref() {
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, project.git_user_name);
return Ok(true); return Ok(true);
} }
if container_git_email.as_deref() != project.git_user_email.as_deref() {
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, project.git_user_email); let expected_git_email = project.git_user_email.clone().unwrap_or_default();
let container_git_email = get_label("triple-c.git-user-email").unwrap_or_default();
if container_git_email != expected_git_email {
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, expected_git_email);
return Ok(true); return Ok(true);
} }
if container_git_token.as_deref() != project.git_token.as_deref() {
let expected_git_token_hash = project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default();
let container_git_token_hash = get_label("triple-c.git-token-hash").unwrap_or_default();
if container_git_token_hash != expected_git_token_hash {
log::info!("GIT_TOKEN mismatch"); log::info!("GIT_TOKEN mismatch");
return Ok(true); return Ok(true);
} }
// ── Custom environment variables ────────────────────────────────────── // ── Custom environment variables (label-based fingerprint) ──────────
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars); let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let expected_fingerprint = compute_env_fingerprint(&merged_env); let expected_fingerprint = compute_env_fingerprint(&merged_env);
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default(); let container_fingerprint = get_label("triple-c.custom-env-fingerprint").unwrap_or_default();
if container_fingerprint != expected_fingerprint { if container_fingerprint != expected_fingerprint {
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint); log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
return Ok(true); return Ok(true);
@@ -1048,15 +1046,16 @@ pub async fn container_needs_recreation(
return Ok(true); return Ok(true);
} }
// ── Claude instructions ─────────────────────────────────────────────── // ── Claude instructions (label-based fingerprint) ─────────────────────
let expected_instructions = build_claude_instructions( let expected_instructions = build_claude_instructions(
global_claude_instructions, global_claude_instructions,
project.claude_instructions.as_deref(), project.claude_instructions.as_deref(),
&project.port_mappings, &project.port_mappings,
project.mission_control_enabled, project.mission_control_enabled,
); );
let container_instructions = get_env("CLAUDE_INSTRUCTIONS"); let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default();
if container_instructions.as_deref() != expected_instructions.as_deref() { let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default();
if container_instructions_fp != expected_instructions_fp {
log::info!("CLAUDE_INSTRUCTIONS mismatch"); log::info!("CLAUDE_INSTRUCTIONS mismatch");
return Ok(true); return Ok(true);
} }

View File

@@ -144,6 +144,8 @@ export default function TerminalView({ sessionId, active }: Props) {
if (e.deltaY < 0) { if (e.deltaY < 0) {
autoFollowRef.current = false; autoFollowRef.current = false;
setIsAutoFollow(false); setIsAutoFollow(false);
isAtBottomRef.current = false;
setIsAtBottom(false);
} }
}; };
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true }); containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
@@ -154,7 +156,6 @@ export default function TerminalView({ sessionId, active }: Props) {
const scrollDisposable = term.onScroll(() => { const scrollDisposable = term.onScroll(() => {
const buf = term.buffer.active; const buf = term.buffer.active;
const atBottom = buf.viewportY >= buf.baseY; const atBottom = buf.viewportY >= buf.baseY;
const prevAtBottom = isAtBottomRef.current;
isAtBottomRef.current = atBottom; isAtBottomRef.current = atBottom;
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered) // Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
@@ -164,15 +165,12 @@ export default function TerminalView({ sessionId, active }: Props) {
setIsAutoFollow(true); setIsAutoFollow(true);
} }
// Only update React state when value changes
if (atBottom !== prevAtBottom) {
if (scrollStateRafId === null) { if (scrollStateRafId === null) {
scrollStateRafId = requestAnimationFrame(() => { scrollStateRafId = requestAnimationFrame(() => {
scrollStateRafId = null; scrollStateRafId = null;
setIsAtBottom(isAtBottomRef.current); setIsAtBottom(isAtBottomRef.current);
}); });
} }
}
}); });
// Track text selection to show copy hint in status bar // Track text selection to show copy hint in status bar