Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1230186
Merge pull request #3 from shellular-org/dev
biraj21 Jun 16, 2026
9c5953f
Merge pull request #6 from shellular-org/dev
biraj21 Jun 18, 2026
1a47c04
Merge pull request #9 from shellular-org/dev
biraj21 Jun 24, 2026
cab21e1
Biraj/cli version and update (#14) (#15)
biraj21 Jul 1, 2026
6fe2ced
Merge pull request #19 from shellular-org/dev
biraj21 Jul 3, 2026
e474f6a
v0.0.31: Merge pull request #22 from shellular-org/dev
biraj21 Jul 4, 2026
8b07e83
Merge pull request #24 from shellular-org/dev
biraj21 Jul 6, 2026
898570c
v0.0.33: Merge pull request #30 from shellular-org/dev
biraj21 Jul 7, 2026
48bacf5
v0.0.34: Merge pull request #33 from shellular-org/dev
biraj21 Jul 12, 2026
89bfd43
v0.0.35: Merge pull request #34 from shellular-org/dev
biraj21 Jul 12, 2026
cda09be
v0.0.36: Merge pull request #38 from shellular-org/dev
biraj21 Jul 18, 2026
f73c833
v0.0.37: Merge pull request #43 from shellular-org/dev
biraj21 Jul 21, 2026
f119e7f
Merge pull request #48 from shellular-org/dev
biraj21 Jul 31, 2026
cea2b0d
v0.0.39: Merge pull request #51 from shellular-org/dev
biraj21 Jul 31, 2026
42d3004
v0.0.40 (#53)
biraj21 Aug 10, 2026
f9653be
v0.0.41: Merge pull request #58 from shellular-org/dev
biraj21 Aug 14, 2026
f996b5c
v0.0.42: Merge pull request #61 from shellular-org/dev
biraj21 Aug 18, 2026
9ad9912
feat(chat): derive a tested row model for every work-log entry
jankarres Aug 19, 2026
b66eff2
feat(chat): render every work event as one row with its result
jankarres Aug 19, 2026
70aff44
fix(chat): replace the raw-command status line with a turn header
jankarres Aug 19, 2026
88de02c
feat(chat): say what a settled turn did and which files it changed
jankarres Aug 19, 2026
6096c75
fix(chat): never truncate a command you are about to approve
jankarres Aug 19, 2026
7b28be1
feat(chat): keep the reader's place when a turn runs on without them
jankarres Aug 19, 2026
bc8948f
fix(chat): make the composer settings readable and the bar lighter
jankarres Aug 19, 2026
6d6041c
fix(chat): fit the path budget to the column it actually renders in
jankarres Aug 19, 2026
864e6a8
fix(chat): put the jump control on the rule and stop the composer jum…
jankarres Aug 20, 2026
a176d3e
fix(chat): revive the away rule, and centre the send glyph optically
jankarres Aug 20, 2026
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
30 changes: 30 additions & 0 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,20 @@ export type TerminalSettings = {
letterSpacing: number;
};

export type ChatSettings = {
/**
* Show the whole work trail of a turn, windowed, rather than only the
* latest action. The old latest-only view is a window of one.
*/
showEveryStep: boolean;
};

export type AppSettings = {
theme: string;
server: ServerSettings;
editor: EditorSettings;
terminal: TerminalSettings;
chat: ChatSettings;
showHiddenFiles: boolean;
hapticFeedback: boolean;
};
Expand Down Expand Up @@ -103,11 +112,16 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
letterSpacing: 1,
};

export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
showEveryStep: true,
};

const DEFAULT_SETTINGS: AppSettings = {
theme: "dark",
server: DEFAULT_SERVER_SETTINGS,
editor: DEFAULT_EDITOR_SETTINGS,
terminal: DEFAULT_TERMINAL_SETTINGS,
chat: DEFAULT_CHAT_SETTINGS,
showHiddenFiles: false,
hapticFeedback: true,
};
Expand Down Expand Up @@ -209,6 +223,17 @@ function normalizeTerminalSettings(
};
}

function normalizeChatSettings(
settings?: Partial<ChatSettings> | null,
): ChatSettings {
return {
showEveryStep:
typeof settings?.showEveryStep === "boolean"
? settings.showEveryStep
: DEFAULT_CHAT_SETTINGS.showEveryStep,
};
}

function normalizeSettings(
settings: Partial<AppSettings> | null | undefined,
): AppSettings {
Expand All @@ -217,6 +242,7 @@ function normalizeSettings(
server: normalizeServerSettings(settings?.server),
editor: normalizeEditorSettings(settings?.editor),
terminal: normalizeTerminalSettings(settings?.terminal),
chat: normalizeChatSettings(settings?.chat),
showHiddenFiles:
typeof settings?.showHiddenFiles === "boolean"
? settings.showHiddenFiles
Expand Down Expand Up @@ -264,6 +290,10 @@ export async function saveSettings(
...current.terminal,
...settings.terminal,
},
chat: {
...current.chat,
...settings.chat,
},
});
await file.write(SETTINGS_PATH, JSON.stringify(next));
window.dispatchEvent(
Expand Down
41 changes: 37 additions & 4 deletions src/pages/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,29 @@ type ChatComposerProps = {
onStop: () => void;
};

// Four filled squares in a row read as a toolbar, which is the wrong weight
// for a surface that sits under the transcript on every screen. The controls
// are 40px of ink; the `after` pseudo-element extends the hit area to 44px
// without changing the layout, so the row can be lighter without being harder
// to hit.
const COMPOSER_CONTROL =
"haptic-trigger relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-[10px] border-0 bg-transparent p-0 text-[1.15rem] text-secondary-text transition-[background,opacity] duration-150 after:absolute after:-inset-0.5 after:content-[''] active:bg-surface-soft disabled:cursor-default disabled:opacity-35 [-webkit-tap-highlight-color:transparent]";

/**
* Send keeps its fill: it is the primary action on the surface. Stop takes the
* same slot while a turn runs and keeps the same weight, tinted rather than
* filled solid — a saturated block of red vibrates against a dark canvas.
*
* Both are 36px of fill inside a 44px target, which the `after` pseudo-element
* supplies without changing the layout.
*/
const COMPOSER_STOP =
"haptic-trigger relative flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-[color-mix(in_srgb,var(--danger)_20%,transparent)] p-0 text-[1.05rem] text-danger transition-opacity duration-150 after:absolute after:-inset-1 after:content-[''] [-webkit-tap-highlight-color:transparent]";

/** The glyph inside carries an optical nudge; see the render site. */
const COMPOSER_SEND =
"haptic-trigger relative flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-button-background p-0 text-[1.05rem] text-button-text transition-opacity duration-150 after:absolute after:-inset-1 after:content-[''] disabled:opacity-35 [-webkit-tap-highlight-color:transparent]";

export function ChatComposer({
inputBarRef,
inputRef,
Expand Down Expand Up @@ -270,7 +293,7 @@ export function ChatComposer({
<div className="flex min-w-0 flex-1 items-center gap-0.5">
<button
type="button"
className="haptic-trigger flex h-[34px] w-[34px] shrink-0 cursor-pointer items-center justify-center rounded-[9px] border-0 bg-transparent p-0 text-[1.15rem] text-secondary-text transition-[background,opacity] duration-150 active:bg-surface-soft disabled:cursor-default disabled:opacity-35 [-webkit-tap-highlight-color:transparent]"
className={COMPOSER_CONTROL}
onClick={() => fileInputRef.current?.click()}
disabled={!agentAvailable || !isConnected}
aria-label="Attach image"
Expand All @@ -285,7 +308,7 @@ export function ChatComposer({
{isEditingQueuedPrompt ? null : isStreaming && !canSendPrompt ? (
<button
type="button"
className="haptic-trigger flex h-[34px] w-[34px] shrink-0 cursor-pointer items-center justify-center rounded-[9px] border-0 bg-transparent p-0 text-[1.15rem] text-danger transition-opacity duration-150 [-webkit-tap-highlight-color:transparent]"
className={COMPOSER_STOP}
onClick={onStop}
aria-label="Stop generation"
>
Expand All @@ -298,7 +321,7 @@ export function ChatComposer({
// *why* (e.g. an image is still uploading) via a toast instead of
// silently doing nothing. The dimmed look is a class, not the
// native `disabled` attribute, which would swallow the tap.
className={`haptic-trigger flex h-[34px] w-[34px] shrink-0 cursor-pointer items-center justify-center rounded-[9px] border-0 bg-transparent p-0 text-[1.15rem] text-[var(--active-text-color)] transition-opacity duration-150 disabled:opacity-35 [-webkit-tap-highlight-color:transparent] ${canSendPrompt && isConnected && agentAvailable ? "" : "opacity-35"}`}
className={`${COMPOSER_SEND} ${canSendPrompt && isConnected && agentAvailable ? "" : "opacity-35"}`}
onClick={() => {
if (canSendPrompt && isConnected && agentAvailable) {
onSend();
Expand All @@ -309,7 +332,17 @@ export function ChatComposer({
aria-disabled={!canSendPrompt || !isConnected || !agentAvailable}
aria-label={isStreaming ? "Queue prompt" : "Send"}
>
<span className="icon-send" aria-hidden="true" />
<span
className="icon-send"
// An optical nudge, not a geometric one. The glyph's bounding box
// is a centred square, but the paper plane's mass sits toward the
// upper right: traced on a canvas, the ink centroid is 7.3% right
// and 8.1% above the box centre, which is why a geometrically
// centred glyph reads as low and left. This puts the centroid on
// the centre, in em so it follows the font size.
style={{ transform: "translate(-0.073em, 0.081em)" }}
aria-hidden="true"
/>
</button>
)}
</div>
Expand Down
224 changes: 224 additions & 0 deletions src/pages/chat/chat-bubble/components/ActivityRow.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
.activity-row {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
box-sizing: border-box;
color: var(--secondary-text);

.chat-part-card-title {
display: flex;
align-items: flex-start;
gap: 7px;
width: 100%;
min-width: 0;
// 48px, not the 31px used elsewhere: below that the row is not a reliable
// touch target on a phone. Set as min-height, never height, so a
// user-forced line height (WCAG SC 1.4.12) cannot clip it.
min-height: 48px;
padding: 6px 2px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;

>em {
margin-left: 2px;
font-style: normal;
font-size: 10px;
opacity: 0.5;
flex-shrink: 0;
}
}

>.chat-disclosure-root {
width: 100%;
min-width: 0;
}

.chat-disclosure-panel {
width: 100%;
min-width: 0;

>.chat-disclosure-panel-inner {
margin: 1px 0 5px 22px;
border-radius: 7px;
background: var(--surface-soft);
}
}

&--failed {
color: var(--error);

// The container colour alone does not reach the verb, which sets its own.
// The glyph already carries the state as a shape, per WCAG SC 1.4.1; this
// is so a failed row is findable while scrolling past twenty-five of them.
.activity-row-label strong {
color: var(--error);
}
}

&--running .activity-row-icon {
color: var(--accent);
}
}

.activity-row-icon {
width: 15px;
margin-top: 2px;
color: var(--secondary-text);
font-size: 13px;
text-align: center;
opacity: 0.72;
flex-shrink: 0;
}

.activity-row-label {
display: flex;
align-items: baseline;
// The object drops to its own full-width line when the verb, glyph and
// status leave it too little room. At 390px the shared line offers roughly
// 25 to 30 characters, so this is the normal case, not the exception.
flex-wrap: wrap;
gap: 6px;
min-width: 0;
flex: 1;

strong {
color: var(--primary-text);
font-weight: 600;
flex-shrink: 0;
}
}

.activity-row-object {
min-width: 0;
color: var(--secondary-text);
// A description is a sentence and carries meaning at both ends, so it wraps
// rather than being cut. Measured: median 28 characters, p90 41.
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;

&--mono {
font-family: 'JetBrainsMono Nerd Font', ui-monospace, Menlo, monospace;
font-size: 12px;
white-space: nowrap;
-webkit-line-clamp: 1;
text-overflow: ellipsis;
}
}

// The unshortened value, kept in the accessibility tree only. Clipped rather
// than `display: none`, which would remove it from that tree as well.
.activity-row-object-full {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

.activity-row-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 13px;
margin-top: 2px;
font-size: 12px;
flex-shrink: 0;

.icon-check {
opacity: 0.7;
}
}

.activity-row-detail {
// Pulled up under the row it belongs to: the tappable head is 48px tall for
// the touch target, which otherwise leaves the result floating midway
// between its own row and the next one.
margin: -9px 0 5px 22px;
color: var(--secondary-text);
font-size: 12px;
font-variant-numeric: tabular-nums;
opacity: 0.7;

&--mono {
font-family: 'JetBrainsMono Nerd Font', ui-monospace, Menlo, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}

.activity-row-peek {
position: relative;
margin: -7px 0 3px 22px;
padding: 8px 9px 10px;
max-height: 66px;
overflow: hidden;
border-radius: 7px;
background: var(--surface-soft);
color: var(--secondary-text);
font-family: 'JetBrainsMono Nerd Font', ui-monospace, Menlo, monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre;
// Mask the overflow instead of adding a scroll region, which would be a
// touch trap inside a scrolling list. The mask sits over the cut end, so a
// block clipped at the top fades upward instead.
mask-image: linear-gradient(to bottom, #000 46px, transparent 66px);
-webkit-mask-image: linear-gradient(to bottom, #000 46px, transparent 66px);

&--clipped-top {
mask-image: linear-gradient(to top, #000 46px, transparent 66px);
-webkit-mask-image: linear-gradient(to top, #000 46px, transparent 66px);
}

&--failed {
background: color-mix(in srgb, var(--error) 9%, transparent);
color: var(--error);
}
}

.activity-row-more {
margin: 2px 0 6px 22px;
color: var(--secondary-text);
font-size: 11px;
opacity: 0.6;
}

.activity-row-chips {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin: -7px 0 7px 22px;
}

.activity-row-chip {
max-width: 100%;
padding: 2px 6px;
border: 1px solid var(--card-border);
border-radius: 6px;
color: var(--secondary-text);
font-family: 'JetBrainsMono Nerd Font', ui-monospace, Menlo, monospace;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

&--more {
border-style: dashed;
opacity: 0.7;
}
}
Loading