Language-aware house style and a resizable chat split
House style now follows the app language: Chinese UI forces the new bundled humanizer-zh skill into every run, English forces humanizer - one variant per run, picked by preference with cross-language fallback so an older catalogue never silently loses house style. Both variants are protected from grant/revoke, list_skills labels each with its language, the bundle guard requires both, and startup sync imports the new skill into existing orgs on next launch. Covered by a unit test that flips app_state.language and asserts which humanizer compiles. The chat split is now adjustable: drag the conversation list's right edge (200-480px, persisted in localStorage) and the composer textarea grew a native vertical resize grip capped at 40vh.
This commit is contained in:
1 parent
c027508b97
commit
eaaf36f859
16 files changed
+931
-45
No files matched your search
@@ -29,7 +29,8 @@ export function listSkillsTool(deps: AgentToolDeps, ctx: AgentToolContext): stri
|
||||
const lines = skills.map((skill) => {
|
||||
const summary = skillSummary(skill.manifest_json);
|
||||
if (forced.has(skill.slug)) {
|
||||
return `- ${skill.name} (${skill.slug})${summary}: house style — every agent carries it automatically`;
|
||||
const language = skill.slug === "humanizer-zh" ? "Chinese" : "English";
|
||||
return `- ${skill.name} (${skill.slug})${summary}: house style — carried automatically by every agent when the app language is ${language}`;
|
||||
}
|
||||
const holders = bindings
|
||||
.filter((binding) => binding.skill_id === skill.id)
|
||||
|
||||
@@ -59,11 +59,14 @@ export function stageBuiltinSkillsLibrary(sourceRoot: string, libraryRoot: strin
|
||||
}
|
||||
staged.push(...stageCategorySkills(sourceDir, libraryRoot, seen));
|
||||
}
|
||||
// A bundle without the forced house-style skill is a broken install, not a
|
||||
// small catalogue. Failing here keeps the import (and the mirror-retire
|
||||
// pass) from wiping a healthy org catalogue against an empty directory.
|
||||
if (!seen.has("humanizer")) {
|
||||
throw new Error(`Built-in skills bundle at ${sourceRoot} is missing the humanizer skill - refusing to sync a broken bundle.`);
|
||||
// A bundle without the forced house-style skills (one per app language) is
|
||||
// a broken install, not a small catalogue. Failing here keeps the import
|
||||
// (and the mirror-retire pass) from wiping a healthy org catalogue against
|
||||
// an empty directory.
|
||||
for (const requiredSlug of ["humanizer", "humanizer-zh"]) {
|
||||
if (!seen.has(requiredSlug)) {
|
||||
throw new Error(`Built-in skills bundle at ${sourceRoot} is missing the ${requiredSlug} skill - refusing to sync a broken bundle.`);
|
||||
}
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,24 @@ function slugify(value: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* House-style skills every agent carries on every run, CEO included. They are
|
||||
* a resolution rule, not a binding row, so no creation path — hire tool,
|
||||
* onboarding, tests, raw SQL — can produce an agent without them.
|
||||
* House-style humanizer skills, one per app language. Every agent carries
|
||||
* the variant matching the current UI language on every run, CEO included.
|
||||
* They are a resolution rule, not a binding row, so no creation path — hire
|
||||
* tool, onboarding, tests, raw SQL — can produce an agent without one.
|
||||
* Both variants are protected from grant/revoke.
|
||||
*/
|
||||
export const FORCED_SKILL_SLUGS = ["humanizer"] as const;
|
||||
export const HUMANIZER_SLUG_EN = "humanizer";
|
||||
export const HUMANIZER_SLUG_ZH = "humanizer-zh";
|
||||
export const FORCED_SKILL_SLUGS = [HUMANIZER_SLUG_EN, HUMANIZER_SLUG_ZH] as const;
|
||||
|
||||
/**
|
||||
* Preference order for the forced skill: the language-matched variant first,
|
||||
* the other variant as a fallback so a catalogue that predates the Chinese
|
||||
* skill never silently loses house style.
|
||||
*/
|
||||
export function forcedSkillSlugCandidates(language: string): string[] {
|
||||
return language === "zh" ? [HUMANIZER_SLUG_ZH, HUMANIZER_SLUG_EN] : [HUMANIZER_SLUG_EN, HUMANIZER_SLUG_ZH];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiled skill prompts stop packing past this size so one over-granted
|
||||
@@ -182,22 +195,50 @@ export class SkillService {
|
||||
|
||||
/**
|
||||
* True when a run for this agent would compile at least one skill: every
|
||||
* agent carries the forced house-style skills once the catalogue holds
|
||||
* them, plus whatever bindings the CEO granted.
|
||||
* agent carries the forced house-style skill once the catalogue holds a
|
||||
* language variant, plus whatever bindings the CEO granted.
|
||||
*/
|
||||
hasRuntimeSkills(orgId: string, agentId: string): boolean {
|
||||
const forced = this.db.prepare(`
|
||||
select sd.id from skill_definitions sd
|
||||
join skill_revisions sr on sr.skill_id = sd.id
|
||||
where sd.org_id = ? and sd.status = 'active' and sd.slug in (${forcedSlugPlaceholders()})
|
||||
limit 1
|
||||
`).get(orgId, ...FORCED_SKILL_SLUGS);
|
||||
if (forced) return true;
|
||||
if (this.forcedSkillRows(orgId).length > 0) return true;
|
||||
return Boolean(this.db.prepare(
|
||||
"select id from skill_bindings where org_id = ? and agent_id = ? and enabled = 1 limit 1",
|
||||
).get(orgId, agentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* The single forced house-style skill for the current app language:
|
||||
* queries both humanizer variants and keeps the best match by preference
|
||||
* order (language-matched first, other variant as fallback).
|
||||
*/
|
||||
private forcedSkillRows(orgId: string): RuntimeSkillRow[] {
|
||||
const candidates = forcedSkillSlugCandidates(this.appLanguage());
|
||||
const rows = this.db.prepare(`
|
||||
select
|
||||
sd.id as skill_id,
|
||||
sd.slug as slug,
|
||||
sr.id as revision_id,
|
||||
sr.version_semver,
|
||||
sr.manifest_json,
|
||||
sa.object_key
|
||||
from skill_definitions sd
|
||||
join skill_revisions sr on sr.id = (
|
||||
select id from skill_revisions where skill_id = sd.id order by published_at desc, id desc limit 1
|
||||
)
|
||||
join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown')
|
||||
where sd.org_id = ? and sd.status = 'active' and sd.slug in (${candidates.map(() => "?").join(", ")})
|
||||
`).all(orgId, ...candidates) as unknown as Array<RuntimeSkillRow & { slug: string }>;
|
||||
for (const slug of candidates) {
|
||||
const match = rows.find((row) => row.slug === slug);
|
||||
if (match) return [match];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private appLanguage(): string {
|
||||
const row = this.db.prepare("select language from app_state where id = 1").get() as { language: string } | undefined;
|
||||
return row?.language === "zh" ? "zh" : "en";
|
||||
}
|
||||
|
||||
/**
|
||||
* Freezes the exact skill set this run carries: forced house-style skills
|
||||
* first (identical for everyone, CEO included), then the agent's enabled
|
||||
@@ -212,21 +253,7 @@ export class SkillService {
|
||||
const existingSnapshot = this.db.prepare("select id from skill_runtime_snapshots where agent_id = ? and run_id = ?")
|
||||
.get(input.agentId, input.runId) as { id: string } | undefined;
|
||||
if (existingSnapshot) return existingSnapshot.id;
|
||||
const forcedRows = this.db.prepare(`
|
||||
select
|
||||
sd.id as skill_id,
|
||||
sr.id as revision_id,
|
||||
sr.version_semver,
|
||||
sr.manifest_json,
|
||||
sa.object_key
|
||||
from skill_definitions sd
|
||||
join skill_revisions sr on sr.id = (
|
||||
select id from skill_revisions where skill_id = sd.id order by published_at desc, id desc limit 1
|
||||
)
|
||||
join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown')
|
||||
where sd.org_id = ? and sd.status = 'active' and sd.slug in (${forcedSlugPlaceholders()})
|
||||
order by sd.slug asc
|
||||
`).all(input.orgId, ...FORCED_SKILL_SLUGS) as unknown as RuntimeSkillRow[];
|
||||
const forcedRows = this.forcedSkillRows(input.orgId);
|
||||
const boundRows = this.db.prepare(`
|
||||
select
|
||||
sb.skill_id as skill_id,
|
||||
@@ -372,10 +399,6 @@ export class SkillService {
|
||||
}
|
||||
}
|
||||
|
||||
function forcedSlugPlaceholders(): string {
|
||||
return FORCED_SKILL_SLUGS.map(() => "?").join(", ");
|
||||
}
|
||||
|
||||
function artifactType(relativePath: string, manifest: SkillManifest): string {
|
||||
if (relativePath === "skill.manifest.json") return "manifest";
|
||||
if (relativePath === manifest.entrypoint) return "entrypoint";
|
||||
|
||||
@@ -126,6 +126,7 @@ export const zh: Record<string, string> = {
|
||||
"Edit profile": "编辑个人资料",
|
||||
"Former member": "已离职成员",
|
||||
"Jump to latest": "跳到最新消息",
|
||||
"Resize conversation list": "调整会话列表宽度",
|
||||
"Upload photo": "上传照片",
|
||||
"Pause agents": "暂停智能体",
|
||||
"Agents paused": "已暂停 · 点击恢复",
|
||||
|
||||
@@ -55,7 +55,7 @@ export function ChatComposer({
|
||||
agents={agents}
|
||||
employees={employees}
|
||||
textareaRef={ref}
|
||||
style={{ minHeight: 38 }}
|
||||
style={{ minHeight: 38, maxHeight: "40vh", resize: "vertical" }}
|
||||
/>
|
||||
<div className="row" style={{ paddingTop: 6, borderTop: "1px solid var(--border-soft)", gap: 4 }}>
|
||||
<IconButton icon="attach" label={t("Attach file")} onClick={onAttach} disabled={disabled} />
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useRef, useState, type PointerEvent } from "react";
|
||||
import { Avatar, Icon, IconButton } from "../../shared/ui";
|
||||
import type { ChatPresenceRecord } from "../../../../shared/domain";
|
||||
import { formatRelative } from "../types";
|
||||
import type { ConversationItem } from "./chat-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
const PANE_WIDTH_KEY = "agentcompany:chat-pane-width";
|
||||
const PANE_MIN_W = 200;
|
||||
const PANE_MAX_W = 480;
|
||||
|
||||
function storedPaneWidth(): number {
|
||||
const raw = Number(window.localStorage.getItem(PANE_WIDTH_KEY));
|
||||
if (!Number.isFinite(raw) || raw === 0) return 240;
|
||||
return Math.min(PANE_MAX_W, Math.max(PANE_MIN_W, raw));
|
||||
}
|
||||
|
||||
function presenceColor(status: string | undefined): string | null {
|
||||
switch (status) {
|
||||
case "active":
|
||||
@@ -38,8 +49,29 @@ export function ChatSidebar({
|
||||
onCreateSpace: () => void;
|
||||
}) {
|
||||
const presenceByPrincipal = new Map(presence.map((entry) => [entry.principalId, entry.status]));
|
||||
|
||||
// The list/conversation split is adjustable: drag the right edge, width
|
||||
// persists across sessions.
|
||||
const [paneWidth, setPaneWidth] = useState(storedPaneWidth);
|
||||
const dragStart = useRef<{ pointerX: number; width: number } | null>(null);
|
||||
const onHandleDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
dragStart.current = { pointerX: event.clientX, width: paneWidth };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const onHandleMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragStart.current) return;
|
||||
const next = Math.min(PANE_MAX_W, Math.max(PANE_MIN_W, dragStart.current.width + event.clientX - dragStart.current.pointerX));
|
||||
setPaneWidth(next);
|
||||
};
|
||||
const onHandleUp = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragStart.current) return;
|
||||
dragStart.current = null;
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
window.localStorage.setItem(PANE_WIDTH_KEY, String(paneWidth));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="secondary-pane">
|
||||
<aside className="secondary-pane" style={{ width: paneWidth, position: "relative" }}>
|
||||
<div className="row" style={{ padding: "4px 8px 12px" }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{t("Messages")}</div>
|
||||
<IconButton icon="plus" label={t("New space")} onClick={onCreateSpace} />
|
||||
@@ -57,6 +89,15 @@ export function ChatSidebar({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="pane-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("Resize conversation list")}
|
||||
onPointerDown={onHandleDown}
|
||||
onPointerMove={onHandleMove}
|
||||
onPointerUp={onHandleUp}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -313,3 +313,15 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
|
||||
color: #231600;
|
||||
}
|
||||
.button.autopause-paused:hover { background: var(--amber, #f59e0b); filter: brightness(1.06); }
|
||||
|
||||
/* Drag handle on the chat conversation list's right edge */
|
||||
.pane-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
.pane-resize-handle:hover { background: color-mix(in srgb, var(--accent) 35%, transparent); }
|
||||
Reference in new issue
Block a user