Merge remote-tracking branch 'public-gh/master'
* public-gh/master: Default Gemini adapter to yolo mode and add API access prompt note fix: remove Cmd+1..9 company-switch shortcut fix(ui): prevent IME composition Enter from moving focus in new issue title fix(cli): add restart hint after allowed-hostname change docs: remove obsolete TODO for CONTRIBUTING.md fix: default dangerouslySkipPermissions to true for unattended agents fix: route heartbeat cost recording through costService Show issue creator in properties sidebar
This commit is contained in:
@@ -248,8 +248,6 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
|||||||
|
|
||||||
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
|
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
|
||||||
|
|
||||||
<!-- TODO: add CONTRIBUTING.md -->
|
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export async function addAllowedHostname(host: string, opts: { config?: string }
|
|||||||
p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`);
|
p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`);
|
||||||
} else {
|
} else {
|
||||||
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
|
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
|
||||||
|
p.log.message(
|
||||||
|
pc.dim("Restart the Paperclip server for this change to take effect."),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) {
|
if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) {
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ export interface AdapterExecutionResult {
|
|||||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
clearSession?: boolean;
|
clearSession?: boolean;
|
||||||
|
question?: {
|
||||||
|
prompt: string;
|
||||||
|
choices: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdapterSessionCodec {
|
export interface AdapterSessionCodec {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ Core fields:
|
|||||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||||
- promptTemplate (string, optional): run prompt template
|
- promptTemplate (string, optional): run prompt template
|
||||||
- model (string, optional): Gemini model id. Defaults to auto.
|
- model (string, optional): Gemini model id. Defaults to auto.
|
||||||
- approvalMode (string, optional): "default", "auto_edit", or "yolo" (default: "default")
|
|
||||||
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
|
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
|
||||||
- command (string, optional): defaults to "gemini"
|
- command (string, optional): defaults to "gemini"
|
||||||
- extraArgs (string[], optional): additional CLI args
|
- extraArgs (string[], optional): additional CLI args
|
||||||
|
|||||||
@@ -59,6 +59,20 @@ function renderPaperclipEnvNote(env: Record<string, string>): string {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderApiAccessNote(env: Record<string, string>): string {
|
||||||
|
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
|
||||||
|
return [
|
||||||
|
"Paperclip API access note:",
|
||||||
|
"Use run_shell_command with curl to make Paperclip API requests.",
|
||||||
|
"GET example:",
|
||||||
|
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`,
|
||||||
|
"POST/PATCH example:",
|
||||||
|
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/{id}/checkout\\"" })`,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||||
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||||
@@ -132,7 +146,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
);
|
);
|
||||||
const command = asString(config.command, "gemini");
|
const command = asString(config.command, "gemini");
|
||||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||||
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
|
|
||||||
const sandbox = asBoolean(config.sandbox, false);
|
const sandbox = asBoolean(config.sandbox, false);
|
||||||
|
|
||||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||||
@@ -250,7 +263,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
}
|
}
|
||||||
const commandNotes = (() => {
|
const commandNotes = (() => {
|
||||||
const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."];
|
const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."];
|
||||||
if (approvalMode !== "default") notes.push(`Added --approval-mode ${approvalMode} for unattended execution.`);
|
notes.push("Added --approval-mode yolo for unattended execution.");
|
||||||
if (!instructionsFilePath) return notes;
|
if (!instructionsFilePath) return notes;
|
||||||
if (instructionsPrefix.length > 0) {
|
if (instructionsPrefix.length > 0) {
|
||||||
notes.push(
|
notes.push(
|
||||||
@@ -275,13 +288,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
context,
|
context,
|
||||||
});
|
});
|
||||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||||
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
|
const apiAccessNote = renderApiAccessNote(env);
|
||||||
|
const prompt = `${instructionsPrefix}${paperclipEnvNote}${apiAccessNote}${renderedPrompt}`;
|
||||||
|
|
||||||
const buildArgs = (resumeSessionId: string | null) => {
|
const buildArgs = (resumeSessionId: string | null) => {
|
||||||
const args = ["--output-format", "stream-json"];
|
const args = ["--output-format", "stream-json"];
|
||||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||||
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
||||||
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
|
args.push("--approval-mode", "yolo");
|
||||||
if (sandbox) {
|
if (sandbox) {
|
||||||
args.push("--sandbox");
|
args.push("--sandbox");
|
||||||
} else {
|
} else {
|
||||||
@@ -398,6 +412,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
stderr: attempt.proc.stderr,
|
stderr: attempt.proc.stderr,
|
||||||
},
|
},
|
||||||
summary: attempt.parsed.summary,
|
summary: attempt.parsed.summary,
|
||||||
|
question: attempt.parsed.question,
|
||||||
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function parseGeminiJsonl(stdout: string) {
|
|||||||
let errorMessage: string | null = null;
|
let errorMessage: string | null = null;
|
||||||
let costUsd: number | null = null;
|
let costUsd: number | null = null;
|
||||||
let resultEvent: Record<string, unknown> | null = null;
|
let resultEvent: Record<string, unknown> | null = null;
|
||||||
|
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
|
||||||
const usage = {
|
const usage = {
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
cachedInputTokens: 0,
|
cachedInputTokens: 0,
|
||||||
@@ -98,6 +99,25 @@ export function parseGeminiJsonl(stdout: string) {
|
|||||||
|
|
||||||
if (type === "assistant") {
|
if (type === "assistant") {
|
||||||
messages.push(...collectMessageText(event.message));
|
messages.push(...collectMessageText(event.message));
|
||||||
|
const messageObj = parseObject(event.message);
|
||||||
|
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
|
||||||
|
for (const partRaw of content) {
|
||||||
|
const part = parseObject(partRaw);
|
||||||
|
if (asString(part.type, "").trim() === "question") {
|
||||||
|
question = {
|
||||||
|
prompt: asString(part.prompt, "").trim(),
|
||||||
|
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
|
||||||
|
const choice = parseObject(choiceRaw);
|
||||||
|
return {
|
||||||
|
key: asString(choice.key, "").trim(),
|
||||||
|
label: asString(choice.label, "").trim(),
|
||||||
|
description: asString(choice.description, "").trim() || undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
break; // only one question per message
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +174,7 @@ export function parseGeminiJsonl(stdout: string) {
|
|||||||
costUsd,
|
costUsd,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
resultEvent,
|
resultEvent,
|
||||||
|
question,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Object.keys(env).length > 0) ac.env = env;
|
if (Object.keys(env).length > 0) ac.env = env;
|
||||||
if (v.dangerouslyBypassSandbox) ac.approvalMode = "yolo";
|
|
||||||
ac.sandbox = !v.dangerouslyBypassSandbox;
|
ac.sandbox = !v.dangerouslyBypassSandbox;
|
||||||
|
|
||||||
if (v.command) ac.command = v.command;
|
if (v.command) ac.command = v.command;
|
||||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||||
return ac;
|
return ac;
|
||||||
|
|||||||
@@ -39,6 +39,37 @@ describe("gemini_local parser", () => {
|
|||||||
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
|
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
|
||||||
expect(parsed.errorMessage).toBe("model access denied");
|
expect(parsed.errorMessage).toBe("model access denied");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts structured questions", () => {
|
||||||
|
const stdout = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "output_text", text: "I have a question." },
|
||||||
|
{
|
||||||
|
type: "question",
|
||||||
|
prompt: "Which model?",
|
||||||
|
choices: [
|
||||||
|
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||||
|
{ key: "flash", label: "Gemini Flash" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const parsed = parseGeminiJsonl(stdout);
|
||||||
|
expect(parsed.summary).toBe("I have a question.");
|
||||||
|
expect(parsed.question).toEqual({
|
||||||
|
prompt: "Which model?",
|
||||||
|
choices: [
|
||||||
|
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||||
|
{ key: "flash", label: "Gemini Flash", description: undefined },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("gemini_local stale session detection", () => {
|
describe("gemini_local stale session detection", () => {
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ describe("gemini execute", () => {
|
|||||||
command: commandPath,
|
command: commandPath,
|
||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
model: "gemini-2.5-pro",
|
model: "gemini-2.5-pro",
|
||||||
yolo: true,
|
|
||||||
env: {
|
env: {
|
||||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||||
},
|
},
|
||||||
@@ -112,6 +111,51 @@ describe("gemini execute", () => {
|
|||||||
);
|
);
|
||||||
expect(invocationPrompt).toContain("Paperclip runtime note:");
|
expect(invocationPrompt).toContain("Paperclip runtime note:");
|
||||||
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
|
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
|
||||||
|
expect(invocationPrompt).toContain("Paperclip API access note:");
|
||||||
|
expect(invocationPrompt).toContain("run_shell_command");
|
||||||
|
expect(result.question).toBeNull();
|
||||||
|
} finally {
|
||||||
|
if (previousHome === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
} else {
|
||||||
|
process.env.HOME = previousHome;
|
||||||
|
}
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always passes --approval-mode yolo", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-yolo-"));
|
||||||
|
const workspace = path.join(root, "workspace");
|
||||||
|
const commandPath = path.join(root, "gemini");
|
||||||
|
const capturePath = path.join(root, "capture.json");
|
||||||
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
|
await writeFakeGeminiCommand(commandPath);
|
||||||
|
|
||||||
|
const previousHome = process.env.HOME;
|
||||||
|
process.env.HOME = root;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execute({
|
||||||
|
runId: "run-yolo",
|
||||||
|
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
|
||||||
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||||
|
config: {
|
||||||
|
command: commandPath,
|
||||||
|
cwd: workspace,
|
||||||
|
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
authToken: "t",
|
||||||
|
onLog: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||||
|
expect(capture.argv).toContain("--approval-mode");
|
||||||
|
expect(capture.argv).toContain("yolo");
|
||||||
|
expect(capture.argv).not.toContain("--policy");
|
||||||
|
expect(capture.argv).not.toContain("--allow-all");
|
||||||
|
expect(capture.argv).not.toContain("--allow-read");
|
||||||
} finally {
|
} finally {
|
||||||
if (previousHome === undefined) {
|
if (previousHome === undefined) {
|
||||||
delete process.env.HOME;
|
delete process.env.HOME;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
agentWakeupRequests,
|
agentWakeupRequests,
|
||||||
heartbeatRunEvents,
|
heartbeatRunEvents,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
costEvents,
|
|
||||||
issues,
|
issues,
|
||||||
projects,
|
projects,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
@@ -22,6 +21,7 @@ import { getServerAdapter, runningProcesses } from "../adapters/index.js";
|
|||||||
import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js";
|
import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js";
|
||||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||||
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||||
|
import { costService } from "./costs.js";
|
||||||
import { secretService } from "./secrets.js";
|
import { secretService } from "./secrets.js";
|
||||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||||
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
||||||
@@ -1029,8 +1029,8 @@ export function heartbeatService(db: Db) {
|
|||||||
.where(eq(agentRuntimeState.agentId, agent.id));
|
.where(eq(agentRuntimeState.agentId, agent.id));
|
||||||
|
|
||||||
if (additionalCostCents > 0 || hasTokenUsage) {
|
if (additionalCostCents > 0 || hasTokenUsage) {
|
||||||
await db.insert(costEvents).values({
|
const costs = costService(db);
|
||||||
companyId: agent.companyId,
|
await costs.createEvent(agent.companyId, {
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
provider: result.provider ?? "unknown",
|
provider: result.provider ?? "unknown",
|
||||||
model: result.model ?? "unknown",
|
model: result.model ?? "unknown",
|
||||||
@@ -1040,16 +1040,6 @@ export function heartbeatService(db: Db) {
|
|||||||
occurredAt: new Date(),
|
occurredAt: new Date(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (additionalCostCents > 0) {
|
|
||||||
await db
|
|
||||||
.update(agents)
|
|
||||||
.set({
|
|
||||||
spentMonthlyCents: sql`${agents.spentMonthlyCents} + ${additionalCostCents}`,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(agents.id, agent.id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startNextQueuedRunForAgent(agentId: string) {
|
async function startNextQueuedRunForAgent(agentId: string) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { AdapterConfigFieldsProps } from "../types";
|
|||||||
import {
|
import {
|
||||||
DraftInput,
|
DraftInput,
|
||||||
Field,
|
Field,
|
||||||
ToggleField,
|
|
||||||
} from "../../components/agent-config-primitives";
|
} from "../../components/agent-config-primitives";
|
||||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||||
|
|
||||||
@@ -45,20 +44,6 @@ export function GeminiLocalConfigFields({
|
|||||||
<ChoosePathButton />
|
<ChoosePathButton />
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
<ToggleField
|
|
||||||
label="Yolo mode"
|
|
||||||
hint="Run Gemini with --approval-mode yolo for unattended operation."
|
|
||||||
checked={
|
|
||||||
isCreate
|
|
||||||
? values!.dangerouslyBypassSandbox
|
|
||||||
: eff("adapterConfig", "yolo", config.yolo === true)
|
|
||||||
}
|
|
||||||
onChange={(v) =>
|
|
||||||
isCreate
|
|
||||||
? set!({ dangerouslyBypassSandbox: v })
|
|
||||||
: mark("adapterConfig", "yolo", v)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -587,6 +587,23 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
{(issue.createdByAgentId || issue.createdByUserId) && (
|
||||||
|
<PropertyRow label="Created by">
|
||||||
|
{issue.createdByAgentId ? (
|
||||||
|
<Link
|
||||||
|
to={`/agents/${issue.createdByAgentId}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
<Identity name={agentName(issue.createdByAgentId) ?? issue.createdByAgentId.slice(0, 8)} size="sm" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{creatorUserLabel ?? "User"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PropertyRow>
|
||||||
|
)}
|
||||||
{issue.startedAt && (
|
{issue.startedAt && (
|
||||||
<PropertyRow label="Started">
|
<PropertyRow label="Started">
|
||||||
<span className="text-sm">{formatDate(issue.startedAt)}</span>
|
<span className="text-sm">{formatDate(issue.startedAt)}</span>
|
||||||
|
|||||||
@@ -104,23 +104,12 @@ export function Layout() {
|
|||||||
|
|
||||||
const togglePanel = togglePanelVisible;
|
const togglePanel = togglePanelVisible;
|
||||||
|
|
||||||
// Cmd+1..9 to switch companies
|
|
||||||
const switchCompany = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index < companies.length) {
|
|
||||||
setSelectedCompanyId(companies[index]!.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[companies, setSelectedCompanyId],
|
|
||||||
);
|
|
||||||
|
|
||||||
useCompanyPageMemory();
|
useCompanyPageMemory();
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
onNewIssue: () => openNewIssue(),
|
onNewIssue: () => openNewIssue(),
|
||||||
onToggleSidebar: toggleSidebar,
|
onToggleSidebar: toggleSidebar,
|
||||||
onTogglePanel: togglePanel,
|
onTogglePanel: togglePanel,
|
||||||
onSwitchCompany: switchCompany,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -699,7 +699,12 @@ export function NewIssueDialog() {
|
|||||||
}}
|
}}
|
||||||
readOnly={createIssue.isPending}
|
readOnly={createIssue.isPending}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
|
if (
|
||||||
|
e.key === "Enter" &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.nativeEvent.isComposing
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
descriptionEditorRef.current?.focus();
|
descriptionEditorRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const defaultCreateValues: CreateConfigValues = {
|
|||||||
model: "",
|
model: "",
|
||||||
thinkingEffort: "",
|
thinkingEffort: "",
|
||||||
chrome: false,
|
chrome: false,
|
||||||
dangerouslySkipPermissions: false,
|
dangerouslySkipPermissions: true,
|
||||||
search: false,
|
search: false,
|
||||||
dangerouslyBypassSandbox: false,
|
dangerouslyBypassSandbox: false,
|
||||||
command: "",
|
command: "",
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ interface ShortcutHandlers {
|
|||||||
onNewIssue?: () => void;
|
onNewIssue?: () => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
onTogglePanel?: () => void;
|
onTogglePanel?: () => void;
|
||||||
onSwitchCompany?: (index: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany }: ShortcutHandlers) {
|
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
// Don't fire shortcuts when typing in inputs
|
// Don't fire shortcuts when typing in inputs
|
||||||
@@ -16,13 +15,6 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+1..9 → Switch company
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
|
|
||||||
e.preventDefault();
|
|
||||||
onSwitchCompany?.(parseInt(e.key, 10) - 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// C → New Issue
|
// C → New Issue
|
||||||
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -44,5 +36,5 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
|
|||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany]);
|
}, [onNewIssue, onToggleSidebar, onTogglePanel]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1313,7 +1313,7 @@ export function DesignGuide() {
|
|||||||
["C", "New Issue (outside inputs)"],
|
["C", "New Issue (outside inputs)"],
|
||||||
["[", "Toggle Sidebar"],
|
["[", "Toggle Sidebar"],
|
||||||
["]", "Toggle Properties Panel"],
|
["]", "Toggle Properties Panel"],
|
||||||
["Cmd+1..9 / Ctrl+1..9", "Switch Company (by rail order)"],
|
|
||||||
["Cmd+Enter / Ctrl+Enter", "Submit markdown comment"],
|
["Cmd+Enter / Ctrl+Enter", "Submit markdown comment"],
|
||||||
].map(([key, desc]) => (
|
].map(([key, desc]) => (
|
||||||
<div key={key} className="flex items-center justify-between px-4 py-2">
|
<div key={key} className="flex items-center justify-between px-4 py-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user