mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61037082d1 | ||
|
|
45ea2e1d32 | ||
|
|
5ae574374f |
+5
-1
@@ -4,7 +4,11 @@
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg.
|
||||
- ### Thanks
|
||||
- @irisitymichaelgrundberg for closing sessions whose agent exited cleanly (#486), built carefully around every way a pane exit can lie (a SIGKILL with no status, a single misread), with the `.claude-images` guard split into its own commit as asked.
|
||||
- @opticon454 for the live-refreshing case picker and Manage search (#483), and for the uv/uvx, libsecret and pnpm additions to the Docker images (#487, #485).
|
||||
|
||||
**Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg.
|
||||
|
||||
**Search in the phone Select Case sheet (#488).** The bottom sheet gains a "Search cases" field that filters by name (every word must match, any order, ignoring case), Enter picks the case when exactly one row is left, and Escape clears then closes. Also fixes a dead band under Create New Case and a list shorter than the sheet could show.
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
⭐ <strong>Like Codeman? <a href="https://github.com/Ark0N/Codeman">Give it a star on GitHub!</a></strong> It takes one click and helps more people find the project. ⭐
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
⭐ <strong>喜欢 Codeman?<a href="https://github.com/Ark0N/Codeman">在 GitHub 上给它点个 Star 吧!</a></strong>只需轻点一下,就能帮助更多人发现这个项目。⭐
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
||||
</p>
|
||||
|
||||
+117
-16
@@ -332,6 +332,39 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
}
|
||||
|
||||
/* Edge fade for the phone's header tab strip (used in the block below).
|
||||
Registered so the keyframes can interpolate them as lengths; @property is
|
||||
only valid at the top level, hence out here. */
|
||||
@property --tab-strip-fade-start {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@property --tab-strip-fade-end {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
/* Driven by the strip's own scroll position, not by time: at the start only the
|
||||
end edge fades, at the end only the start edge, and in between both. */
|
||||
@keyframes tab-strip-edge-fade {
|
||||
0% {
|
||||
--tab-strip-fade-start: 0px;
|
||||
--tab-strip-fade-end: 28px;
|
||||
}
|
||||
10%,
|
||||
90% {
|
||||
--tab-strip-fade-start: 28px;
|
||||
--tab-strip-fade-end: 28px;
|
||||
}
|
||||
100% {
|
||||
--tab-strip-fade-start: 28px;
|
||||
--tab-strip-fade-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Phone Breakpoint (<600px)
|
||||
============================================================================ */
|
||||
@@ -652,7 +685,7 @@ html.mobile-init .file-browser-panel {
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
max-height: 36px;
|
||||
gap: 2px;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -660,6 +693,36 @@ html.mobile-init .file-browser-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Fade the strip's edges while there is more to scroll to, so the tab that
|
||||
does not fit dissolves into the edge instead of being cut mid-word against
|
||||
the connection dot. Scroll-driven, no JS: the timeline is the strip's own
|
||||
inline scroll (keyframes + registered properties above this block). A strip
|
||||
that does not overflow has an INACTIVE timeline, so the animation applies
|
||||
nothing and both widths stay at their registered 0px, which is no mask at
|
||||
all. Browsers without scroll timelines skip the block and keep the hard
|
||||
edge. Header only: in sidebar layout the same list scrolls vertically. */
|
||||
@supports (animation-timeline: scroll()) {
|
||||
.header .session-tabs {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
#000 var(--tab-strip-fade-start),
|
||||
#000 calc(100% - var(--tab-strip-fade-end)),
|
||||
transparent
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
#000 var(--tab-strip-fade-start),
|
||||
#000 calc(100% - var(--tab-strip-fade-end)),
|
||||
transparent
|
||||
);
|
||||
/* The shorthand resets animation-timeline, so the timeline comes after. */
|
||||
animation: tab-strip-edge-fade linear both;
|
||||
animation-timeline: scroll(self inline);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smaller tabs for mobile */
|
||||
.session-tab {
|
||||
flex-shrink: 0;
|
||||
@@ -671,10 +734,46 @@ html.mobile-init .file-browser-panel {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Smaller status indicator on mobile */
|
||||
/* Every tab in the header strip is a chip, not only the active one. Left
|
||||
transparent, the strip read as a row of disabled labels: grey 11px text
|
||||
floating in unmarked gaps, with nothing saying "tap me". Fill and border
|
||||
come from the skin's control tokens, so the four light skins (which repaint
|
||||
the header with --glass-bg) get a matching chip with no override block, and
|
||||
the active tab's !important fill and border in styles.css still win.
|
||||
`:where(.header)` keeps this at (0,1,0): the per-colour left border
|
||||
(`.session-tab[data-color="red"]`, (0,2,0)) must still outrank the
|
||||
border-color here, and in sidebar layout the list leaves the header, so
|
||||
its rows are untouched. */
|
||||
:where(.header) .session-tab {
|
||||
border-radius: 8px;
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:where(.header) .session-tab .tab-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Only the active tab shows its action icons on a phone (see below), so on
|
||||
every other tab the container is empty but still a flex item, and its gap
|
||||
made the chip visibly wider on the right than on the left. */
|
||||
:where(.header) .session-tab:not(.active) .tab-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The boxed digit is the Alt+1..9 shortcut hint. A phone has no Alt key, so
|
||||
here it was only a second grey box inside every tab, and 20px of the name's
|
||||
width. Every header tab is therefore numberless on a phone, which is the
|
||||
case the active-tab reserve below is already sized for. */
|
||||
:where(.header) .session-tab .tab-number {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Status dot: 6px so an idle green reads at arm's length (4px was a speck). */
|
||||
.session-tab .tab-status {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
/* The working dot is the one glance-state a phone needs: keep idle tiny, but
|
||||
@@ -710,9 +809,12 @@ html.mobile-init .file-browser-panel {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Truncate tab names more aggressively on mobile */
|
||||
/* Truncate tab names on mobile. 80px, not the old 50px: session names share
|
||||
a `w1-` style prefix, and at 50px "w1-ingest-pipeline" became "w1-inge…"
|
||||
and a clipped tab just "w1-", which says nothing about which session it is.
|
||||
The 20px the hidden tab number gave back pays for most of the difference. */
|
||||
.session-tab .tab-name {
|
||||
max-width: 50px;
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -726,20 +828,19 @@ html.mobile-init .file-browser-panel {
|
||||
difference instead, which costs a little strip space on exactly one tab
|
||||
and keeps tap-to-switch the majority of it.
|
||||
|
||||
⚠️ The floor is set by the 10th tab onward, NOT by the numbered tabs you
|
||||
are looking at. `.tab-number` is rendered only for `_tabIdx < 9` (app.js),
|
||||
so tab 10 loses 16px + a 4px gap off its left and its centre sits 10px
|
||||
further right. The centre clears the icons when
|
||||
⚠️ The floor is set by a NUMBERLESS tab. `.tab-number` is rendered only
|
||||
for `_tabIdx < 9` (app.js), and the header hides it on phones altogether
|
||||
(above), so every phone tab is that case now; a numbered one would sit 10px
|
||||
further left and hide the problem. The centre clears the icons when
|
||||
|
||||
reserved > icons + rightEdge - leftRunUp - gap
|
||||
= 50 + 9 - 17 - 4 = 38px
|
||||
= 50 + 9 - 19 - 4 = 36px
|
||||
|
||||
with icons = gear 32 + close 20 - close's -2px margin, leftRunUp = border 1
|
||||
+ padding 8 + status dot 4 + gap 4, and rightEdge = padding 8 + border 1.
|
||||
Hit testing snaps to whole pixels, so 39px still lands on the gear: the
|
||||
practical floor is 40px and 44px keeps 4px of headroom. A NUMBERED tab
|
||||
clears it at 20px, so reasoning from the tabs on screen is exactly what
|
||||
would put the centre back on the gear. Pinned by
|
||||
+ padding 8 + status dot 6 + gap 4, and rightEdge = padding 8 + border 1.
|
||||
Hit testing snaps to whole pixels, so a centre half a pixel short still
|
||||
lands on the gear: the practical floor was measured at 40px (with the
|
||||
older 4px dot) and 44px keeps headroom. Pinned by
|
||||
test/mobile-tab-tap-zones.test.ts. */
|
||||
.session-tab.active .tab-name {
|
||||
min-width: 44px;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview The phone header's tab strip must read as live tabs.
|
||||
*
|
||||
* It used to render every inactive tab transparent: grey 11px text floating in
|
||||
* unmarked gaps, a boxed Alt+N digit in each (a phone has no Alt key), names cut
|
||||
* to 50px so a shared `w1-` prefix was most of what showed, and the tab that did
|
||||
* not fit chopped mid-word against the connection dot. On a phone it looked like
|
||||
* a row of disabled labels.
|
||||
*
|
||||
* The fix is four small rules in the phone block of mobile.css, and each has a
|
||||
* way to be silently undone, which is what this file fences:
|
||||
*
|
||||
* - The chip rule is written `:where(.header) .session-tab` so it stays at
|
||||
* (0,1,0). Written `.header .session-tab` it would be (0,2,0), tie with the
|
||||
* per-colour `.session-tab[data-color="red"]` left border in styles.css, and
|
||||
* win on source order (mobile.css loads later): every colour-tagged tab would
|
||||
* lose its identity stripe.
|
||||
* - The edge fade is scroll-DRIVEN (no JS). Its two widths must be registered
|
||||
* with @property to interpolate, and @property is only valid at the top
|
||||
* level: nested inside the phone @media it is dropped, the keyframes stop
|
||||
* interpolating, and the fade snaps between states instead of following the
|
||||
* scroll position.
|
||||
* - `animation` is a shorthand that resets `animation-timeline`, so the
|
||||
* timeline must be declared AFTER it or the fade silently becomes a 0s time
|
||||
* animation.
|
||||
*
|
||||
* Parsed with postcss because the declarations live in nested at-rules. The
|
||||
* rendered result (chips on dark and light skins, the fade at both scroll ends)
|
||||
* was checked in a browser; this is the cheap regression fence. Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import postcss, { type AtRule, type Declaration, type Rule } from 'postcss';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const CSS = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile.css'), 'utf8');
|
||||
const ROOT = postcss.parse(CSS);
|
||||
const PHONE_QUERY = '(max-width: 599px)';
|
||||
|
||||
/** Declarations of the rule matching `selector` inside the phone block (later rules win). */
|
||||
function phoneDeclarations(selector: string): Record<string, string> {
|
||||
const found: Record<string, string> = {};
|
||||
ROOT.walkAtRules('media', (atRule) => {
|
||||
if (atRule.params !== PHONE_QUERY) return;
|
||||
atRule.walkRules((rule: Rule) => {
|
||||
if (!rule.selectors.map((s) => s.trim()).includes(selector)) return;
|
||||
rule.walkDecls((decl: Declaration) => {
|
||||
found[decl.prop] = decl.value.trim();
|
||||
});
|
||||
});
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The `@property` rule for `name`, wherever it sits. */
|
||||
function propertyRule(name: string): AtRule | undefined {
|
||||
let hit: AtRule | undefined;
|
||||
ROOT.walkAtRules('property', (atRule) => {
|
||||
if (atRule.params.trim() === name) hit = atRule;
|
||||
});
|
||||
return hit;
|
||||
}
|
||||
|
||||
function declsOf(node: AtRule | Rule): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
node.each((child) => {
|
||||
if (child.type === 'decl') out[child.prop] = child.value.trim();
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('phone header tab strip', () => {
|
||||
describe('chips', () => {
|
||||
const chip = phoneDeclarations(':where(.header) .session-tab');
|
||||
|
||||
it('gives every header tab a fill and border from the skin control tokens', () => {
|
||||
// Tokens, not literals: the four light skins repaint the header with
|
||||
// --glass-bg and define their own --control-* values.
|
||||
expect(chip.background).toMatch(/^var\(--control-bg/);
|
||||
expect(chip['border-color']).toMatch(/^var\(--control-border/);
|
||||
expect(chip.color).toBe('var(--text)');
|
||||
});
|
||||
|
||||
it('keeps the chip selector at (0,1,0) so per-colour borders still win', () => {
|
||||
// The lookup above only matches the exact `:where(.header)` spelling, so a
|
||||
// rewrite to `.header .session-tab` leaves it empty and fails here.
|
||||
expect(Object.keys(chip).length).toBeGreaterThan(0);
|
||||
expect(phoneDeclarations('.header .session-tab')).toEqual({});
|
||||
});
|
||||
|
||||
it('hides the Alt+N digit, which a phone has no key for', () => {
|
||||
expect(phoneDeclarations(':where(.header) .session-tab .tab-number').display).toBe('none');
|
||||
});
|
||||
|
||||
it('drops the empty action container on inactive tabs only', () => {
|
||||
// The active tab's gear and close live in .tab-actions, so the rule must
|
||||
// stay scoped to :not(.active).
|
||||
expect(phoneDeclarations(':where(.header) .session-tab:not(.active) .tab-actions').display).toBe('none');
|
||||
expect(phoneDeclarations(':where(.header) .session-tab .tab-actions')).toEqual({});
|
||||
});
|
||||
|
||||
it('leaves enough name to get past a shared w1- prefix', () => {
|
||||
const maxWidth = Number.parseFloat(phoneDeclarations('.session-tab .tab-name')['max-width'] ?? '');
|
||||
expect(maxWidth).toBeGreaterThanOrEqual(72);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll-driven edge fade', () => {
|
||||
it('registers both fade widths at the top level, as lengths starting at 0px', () => {
|
||||
for (const name of ['--tab-strip-fade-start', '--tab-strip-fade-end']) {
|
||||
const rule = propertyRule(name);
|
||||
expect(rule, `${name} is not registered`).toBeDefined();
|
||||
// Nested in @media it is invalid and silently ignored.
|
||||
expect(rule!.parent?.type, `${name} must be top level`).toBe('root');
|
||||
const d = declsOf(rule!);
|
||||
expect(d.syntax).toBe("'<length>'");
|
||||
expect(d['initial-value']).toBe('0px');
|
||||
}
|
||||
});
|
||||
|
||||
it('fades only the far edge at the start and only the near edge at the end', () => {
|
||||
let frames: Record<string, Record<string, string>> = {};
|
||||
ROOT.walkAtRules('keyframes', (atRule) => {
|
||||
if (atRule.params.trim() !== 'tab-strip-edge-fade') return;
|
||||
frames = {};
|
||||
atRule.each((node) => {
|
||||
if (node.type !== 'rule') return;
|
||||
for (const sel of node.selectors) frames[sel.trim()] = declsOf(node);
|
||||
});
|
||||
});
|
||||
expect(frames['0%']?.['--tab-strip-fade-start']).toBe('0px');
|
||||
expect(Number.parseFloat(frames['0%']?.['--tab-strip-fade-end'] ?? '0')).toBeGreaterThan(0);
|
||||
expect(frames['100%']?.['--tab-strip-fade-end']).toBe('0px');
|
||||
expect(Number.parseFloat(frames['100%']?.['--tab-strip-fade-start'] ?? '0')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('masks the header strip behind a scroll-timeline feature check, timeline after the shorthand', () => {
|
||||
let strip: Rule | undefined;
|
||||
ROOT.walkAtRules('media', (media) => {
|
||||
if (media.params !== PHONE_QUERY) return;
|
||||
media.walkAtRules('supports', (supports) => {
|
||||
if (!/animation-timeline:\s*scroll\(\)/.test(supports.params)) return;
|
||||
supports.walkRules((rule) => {
|
||||
if (rule.selectors.map((s) => s.trim()).includes('.header .session-tabs')) strip = rule;
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(strip, 'no @supports-gated .header .session-tabs rule in the phone block').toBeDefined();
|
||||
|
||||
const props: string[] = [];
|
||||
const d: Record<string, string> = {};
|
||||
strip!.each((node) => {
|
||||
if (node.type !== 'decl') return;
|
||||
props.push(node.prop);
|
||||
d[node.prop] = node.value.replace(/\s+/g, ' ').trim();
|
||||
});
|
||||
for (const prop of ['mask-image', '-webkit-mask-image']) {
|
||||
expect(d[prop]).toContain('var(--tab-strip-fade-start)');
|
||||
expect(d[prop]).toContain('var(--tab-strip-fade-end)');
|
||||
}
|
||||
expect(d.animation).toContain('tab-strip-edge-fade');
|
||||
expect(d['animation-timeline']).toBe('scroll(self inline)');
|
||||
expect(props.indexOf('animation-timeline')).toBeGreaterThan(props.indexOf('animation'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -88,9 +88,10 @@ describe('Tab Navigation', () => {
|
||||
if (tabNameExists) {
|
||||
const maxWidth = await getCSSProperty(page, SELECTORS.TAB_NAME, 'max-width');
|
||||
const maxWidthPx = parseFloat(maxWidth);
|
||||
// Should be 50px on mobile
|
||||
expect(maxWidthPx).toBeLessThanOrEqual(60);
|
||||
expect(maxWidthPx).toBeGreaterThan(0);
|
||||
// 80px on phones: wide enough to get past a shared `w1-` prefix,
|
||||
// still short enough that several tabs fit the strip.
|
||||
expect(maxWidthPx).toBeLessThanOrEqual(96);
|
||||
expect(maxWidthPx).toBeGreaterThanOrEqual(72);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user