Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gently/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
except ImportError:
_VISUALIZATION_AVAILABLE = False

__version__ = "0.22.0.dev0"
__version__ = "0.22.0"
__all__ = [
# Main entry point
"Gently",
Expand Down
7 changes: 7 additions & 0 deletions gently/harness/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,13 @@ async def stream_response(
pending_choice_result = None

try:
# Open the stream envelope immediately — before the (potentially
# slow) context build + Claude API call with extended thinking — so
# every client shows the "Working…" trust signal the instant the
# turn starts, not only once the first token arrives. Pairs with the
# stream_end emitted on StopAsyncIteration below.
await send_fn({"type": "stream_start"})

while True:
try:
if pending_choice_result is not None:
Expand Down
37 changes: 32 additions & 5 deletions gently/ui/web/routes/agent_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ def _record_display(msg):
"role": "user",
"text": msg.get("text", ""),
"author": msg.get("author"),
"author_id": msg.get("author_id"),
}
)
elif t == "autonomous_start":
Expand Down Expand Up @@ -338,16 +339,20 @@ async def agent_websocket(websocket: WebSocket):
can_control = role in CONTROL_ROLES

# Assign a stable id for control arbitration. The label shown to other
# clients is the username when authenticated, else a generic window id.
# clients is the username when authenticated, else "Anonymous". The UI
# renders "You" for the viewer's own messages by matching client_id, so
# anonymous participants don't need disambiguating numbers.
_client_counter["n"] += 1
client_id = f"agent_client_{_client_counter['n']}"
client_label = username or f"window {_client_counter['n']}"
client_label = username or "Anonymous"

# Send connection metadata (version, tokens, embryo count, commands)
# Send connection metadata (version, tokens, embryo count, commands).
# you_id lets the client label its own messages "You".
meta = bridge.get_connect_metadata()
_connected_msg = {
"type": "connected",
**meta,
"you_id": client_id,
"timestamp": datetime.now().isoformat(),
}
await websocket.send_json(_connected_msg)
Expand Down Expand Up @@ -840,8 +845,17 @@ async def _run_resolution_bootstrap():
active_task.cancel()

# Echo the user's message to ALL clients (so observers see
# what was asked), then stream the reply to everyone.
await _broadcast({"type": "user_message", "text": text, "author": client_label})
# what was asked), then stream the reply to everyone. author
# is the display name (username or "Anonymous"); author_id
# lets each client render its own messages as "You".
await _broadcast(
{
"type": "user_message",
"text": text,
"author": client_label,
"author_id": client_id,
}
)
active_task = asyncio.create_task(
bridge.stream_response(text, _broadcast, choice_future_factory)
)
Expand All @@ -865,6 +879,10 @@ async def _run_resolution_bootstrap():
if active_task and not active_task.done():
active_task.cancel()
active_task = None
# A cancelled stream emits no stream_end of its own, so
# tell every client the turn is over — otherwise their
# "Working…" indicator spins forever after Stop.
await _broadcast({"type": "stream_end"})

elif msg_type == "command":
command = data.get("command", "").strip()
Expand Down Expand Up @@ -975,6 +993,15 @@ async def _run_resolution_bootstrap():
wizard_task.cancel()
if active_task and not active_task.done():
active_task.cancel()
# This connection was mid-stream — persist whatever the agent
# generated so far, otherwise a reload loses the in-progress
# reply (it's only committed on stream_end). Guarded to the
# owning connection so an observer's disconnect can't split a
# still-streaming reply into two history entries.
try:
_flush_agent_buf()
except Exception:
logger.debug("Could not flush agent buffer on disconnect", exc_info=True)
if bootstrap_task is not None and not bootstrap_task.done():
bootstrap_task.cancel()
# Release control arbitration for this client; hand the wheel
Expand Down
6 changes: 6 additions & 0 deletions gently/ui/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,12 @@ async def on_start(self):
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Match uvicorn's own bind (it sets SO_REUSEADDR): without this, the
# pre-flight check is stricter than the real server and falsely reports
# "port in use" for ~60s after a clean shutdown, while sockets from
# closed browser WebSockets linger in TIME_WAIT. uvicorn would rebind
# fine — so this check must too, and only trip on a live LISTEN holder.
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((self.host, self.port))
except OSError:
Expand Down
132 changes: 73 additions & 59 deletions gently/ui/web/static/css/agent-chat.css
Original file line number Diff line number Diff line change
@@ -1,40 +1,55 @@
/* Floating agent-chat window — the web-side control surface.
Restrained, professional styling for a lab instrument. */

/* ── Header toggle (replaces the floating FAB) ─────────────── */
.header-agent-toggle {
display: inline-flex; align-items: center; gap: 7px;
padding: 5px 10px; border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-hover); color: var(--text);
font: 500 12.5px/1 'Inter Tight', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
cursor: pointer; position: relative;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.header-agent-toggle:hover { border-color: var(--accent); }
.header-agent-toggle[aria-pressed="true"] {
border-color: var(--accent); color: var(--accent);
background: rgba(96, 165, 250, 0.12);
}
.header-agent-toggle svg { display: block; }
.header-agent-label { letter-spacing: 0.01em; }
.header-agent-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--text-muted); flex: 0 0 auto;
/* ── Collapsed rail (always-present agent affordance) ──────────
When the docked panel is collapsed it shrinks to this thin vertical rail:
a spark glyph (click to open), a vertical "Agent" label, plus the connection
dot + unseen-activity badge so a tucked-away agent can still signal it woke
or has something pending. Shown only via the body.chat-docked rules below. */
.agent-rail {
display: none; /* shown only when collapsed (see :not(.open) rule) */
flex-direction: column; align-items: center;
width: 100%; height: 100%; padding: 8px 0 0;
border: none; background: transparent;
cursor: pointer;
}
.header-agent-dot.ok { background: var(--accent-green); }
.header-agent-badge {
position: absolute; top: -6px; right: -6px;
/* Icon-only activity-bar button: contained rounded hover target (VS Code/Google
style), with the connection state + activity count attached to the icon. */
.agent-rail-icon {
position: relative;
display: flex; align-items: center; justify-content: center;
width: 40px; height: 40px; border-radius: 10px;
color: var(--text-muted);
transition: background 0.15s ease, color 0.15s ease;
}
.agent-rail:hover .agent-rail-icon { background: var(--bg-hover); color: var(--text); }
.agent-rail-spark { display: flex; }
/* Presence dot in the icon's corner (avatar-with-status pattern). */
.agent-rail-dot {
position: absolute; right: 5px; bottom: 5px;
width: 8px; height: 8px; border-radius: 50%;
background: var(--text-muted);
border: 2px solid var(--bg-card); box-sizing: content-box;
}
.agent-rail-dot.ok { background: var(--accent-green); }
/* Unseen-activity count badge in the icon's top-right corner. */
.agent-rail-badge {
position: absolute; top: -4px; right: -4px;
min-width: 16px; height: 16px; padding: 0 4px;
border-radius: 999px; background: var(--accent-purple); color: #fff;
font-size: 10px; font-weight: 700; line-height: 16px; text-align: center;
border: 2px solid var(--bg-card); box-sizing: content-box;
}
.header-agent-badge.hidden { display: none; }
.agent-rail-badge.hidden { display: none; }

/* ── Docked agent panel ────────────────────────────────────
Default = overlay slide-over, absolutely positioned inside .app-shell (which
sits below the global header/navbar). Pin (body.chat-docked) turns it into a
real column that pushes .app-main. */
The panel is always docked (body.chat-docked, set on load): a real column
that pushes .app-main, never a float over content. Collapsing it (header
Agent toggle / Ctrl+J / ×) drops it to width 0 to reclaim space.

The base .agent-chat rules below (absolute, translateX slide) are the
pre-JS / no-chat-docked fallback so the panel stays parked off-screen until
restorePrefs() docks it; body.chat-docked overrides them into the column. */
.agent-chat {
position: absolute;
top: 0; right: 0; bottom: 0;
Expand All @@ -54,7 +69,7 @@
}
.agent-chat.open { transform: translateX(0); }

/* Pinned: a real pushing column — no float shadow, just a seam. */
/* Docked: a real pushing column — no float shadow, just a seam. */
body.chat-docked .agent-chat {
position: relative;
transform: none;
Expand All @@ -64,9 +79,12 @@ body.chat-docked .agent-chat {
transition: none;
z-index: auto;
}
/* Collapsed: shrink to the rail. Hide every child, then re-show only the rail. */
body.chat-docked .agent-chat:not(.open) {
width: 0; border-left: none; overflow: hidden;
width: 48px; flex: 0 0 48px; overflow: hidden;
}
body.chat-docked .agent-chat:not(.open) > * { display: none; }
body.chat-docked .agent-chat:not(.open) > .agent-rail { display: flex; }

@media (prefers-reduced-motion: reduce) {
.agent-chat { transition: opacity 0.12s ease; }
Expand All @@ -85,15 +103,6 @@ body.chat-docked .agent-chat:not(.open) {

.agent-control-banner.hidden { display: none; }

/* Pin button in the panel header. */
.agent-chat-pin {
background: none; border: none; color: var(--text-muted);
cursor: pointer; padding: 2px; display: flex; align-items: center;
border-radius: 5px;
}
.agent-chat-pin:hover { color: var(--text); background: var(--bg-hover); }
.agent-chat-pin[aria-pressed="true"] { color: var(--accent); }

/* ── Header ─────────────────────────────────────────────── */
.agent-chat-header {
display: flex;
Expand Down Expand Up @@ -132,10 +141,12 @@ body.chat-docked .agent-chat:not(.open) {
.agent-chat-conn.ac-conn-bad { color: var(--accent-orange, #fb923c); border-color: rgba(251, 146, 60, 0.35); }
.agent-chat-close {
background: none; border: none;
color: var(--text-muted); font-size: 20px; line-height: 1;
cursor: pointer; padding: 0 2px;
color: var(--text-muted); line-height: 1;
cursor: pointer; padding: 2px; border-radius: 5px;
display: inline-flex; align-items: center;
}
.agent-chat-close:hover { color: var(--text); }
.agent-chat-close svg { display: block; }
.agent-chat-close:hover { color: var(--text); background: var(--bg-hover); }

/* ── Control banner ─────────────────────────────────────── */
.agent-control-banner {
Expand Down Expand Up @@ -172,6 +183,8 @@ body.chat-docked .agent-chat:not(.open) {
color: var(--accent-purple);
margin-bottom: 4px;
}
/* Sender name on user bubbles: muted so it doesn't compete with the message. */
.ac-role-user { color: var(--text-muted); }
.ac-turn-agent .ac-content { color: var(--text); }
.ac-turn-agent .ac-content code {
font-family: 'JetBrains Mono', ui-monospace, monospace;
Expand All @@ -180,7 +193,9 @@ body.chat-docked .agent-chat:not(.open) {
padding: 1px 5px; border-radius: 4px;
}

/* User: right-aligned, subtle accent block (not a loud bubble) */
/* User: right-aligned, subtle accent block (not a loud bubble). Your own
messages use the blue accent; other participants get a neutral bubble so a
shared chat is easy to read at a glance. */
.ac-turn-user { align-items: flex-end; }
.ac-turn-user .ac-content {
background: rgba(96, 165, 250, 0.12);
Expand All @@ -191,6 +206,10 @@ body.chat-docked .agent-chat:not(.open) {
max-width: 88%;
white-space: pre-wrap; word-wrap: break-word;
}
.ac-turn-user.ac-from-other .ac-content {
background: rgba(127, 127, 127, 0.12);
border-color: rgba(127, 127, 127, 0.24);
}

/* ── Autonomous (wake) turns ────────────────────────────── */
.ac-autonomous-banner {
Expand Down Expand Up @@ -391,28 +410,23 @@ body.chat-docked .agent-chat:not(.open) {
.agent-chat-input textarea::placeholder { color: var(--text-muted); }
.agent-chat-input textarea:focus { outline: none; border-color: var(--accent); }
.agent-chat-input textarea:disabled { opacity: 0.55; }
/* Circular icon button (ChatGPT/Claude style): an up-arrow to send, which
morphs into a stop square (.is-stop) while a cancellable turn is running. */
.agent-chat-send {
flex: 0 0 auto; align-self: flex-end;
padding: 9px 16px; border-radius: 9px;
width: 36px; height: 36px; padding: 0; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
border: none; background: var(--accent); color: #fff;
font-weight: 600; font-size: 13px; cursor: pointer;
transition: background 0.12s ease;
cursor: pointer; transition: background 0.12s ease, opacity 0.12s ease;
}
.agent-chat-send:hover:not(:disabled) { background: var(--accent-hover); }
.agent-chat-send:disabled { opacity: 0.5; cursor: default; }
/* Send now queues while busy (it no longer doubles as Stop), so just dim it. */
.agent-chat-send.ac-busy { opacity: 0.6; }

/* Explicit Stop (separate from Send), shown only during a cancellable turn. */
.ac-stop {
flex: 0 0 auto; align-self: flex-end;
padding: 9px 12px; border-radius: 9px;
border: 1px solid var(--color-danger, #f87171);
background: transparent; color: var(--color-danger, #f87171);
font-weight: 600; font-size: 13px; cursor: pointer;
}
.ac-stop:hover { background: rgba(248, 113, 113, 0.12); }
.ac-stop.hidden { display: none; }
.agent-chat-send:disabled { opacity: 0.4; cursor: default; }
.agent-chat-send svg { display: block; }
/* Toggle which glyph shows; same filled circle in both states (the icon is the
signal), matching how Claude/ChatGPT morph the composer button. */
.agent-chat-send .ac-icon-stop { display: none; }
.agent-chat-send.is-stop .ac-icon-send { display: none; }
.agent-chat-send.is-stop .ac-icon-stop { display: block; }

/* ── Queued-message panel (type-while-busy) ─────────────── */
.ac-queue {
Expand Down
Loading
Loading